twiglet 1.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 8780baf991dc54df9731f5cb2f02cbf1472c4f026d4b5a0604fe332b96f84384
4
+ data.tar.gz: f059d2eafbb0ac5b5ba74c0f430508ca3966326ec832fb4b56df3efc3b436faf
5
+ SHA512:
6
+ metadata.gz: 1d133f5b631a3a192f1dd7d7f54fecb90c481d593a747ee1ce2b9772eb91c6209af78efb0ec1ebfebe06dabbc76946029edc6a70e6c6493f2351be933ffae7e4
7
+ data.tar.gz: 343cb5284055f7c5172f7451bbdeb73d1fd9f6c9f53c7341a44d997a63ffbc2f90aecece369e63e27b5955d45aa570f787d83e0c5d83cc52267e7329f9073c02
@@ -0,0 +1,4 @@
1
+ # Add your project owners info here
2
+ # More information: https://help.github.com/articles/about-codeowners/
3
+ * @simplybusiness/application-tooling
4
+ *
@@ -0,0 +1,32 @@
1
+ name: Ruby CI
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - '*' # matches every branch
7
+ - '*/*' # matches every branch containing a single '/'
8
+
9
+ jobs:
10
+ build:
11
+ runs-on: ubuntu-latest
12
+ defaults:
13
+ run:
14
+ working-directory: ./
15
+
16
+ strategy:
17
+ matrix:
18
+ ruby-version: [2.6, 2.7]
19
+
20
+ steps:
21
+ - uses: actions/checkout@v2
22
+ - name: Set up Ruby
23
+ uses: ruby/setup-ruby@v1
24
+ with:
25
+ ruby-version: ${{ matrix.ruby-version }}
26
+ - name: Install dependencies
27
+ run: bundle install
28
+ - name: Rubocop Check
29
+ run: bundle exec rubocop
30
+ - name: Run all tests
31
+ run: bundle exec ruby test/*
32
+ shell: bash
@@ -0,0 +1,56 @@
1
+ *.gem
2
+ *.rbc
3
+ /.config
4
+ /coverage/
5
+ /InstalledFiles
6
+ /pkg/
7
+ /spec/reports/
8
+ /spec/examples.txt
9
+ /test/tmp/
10
+ /test/version_tmp/
11
+ /tmp/
12
+
13
+ # Used by dotenv library to load environment variables.
14
+ # .env
15
+
16
+ # Ignore Byebug command history file.
17
+ .byebug_history
18
+
19
+ ## Specific to RubyMotion:
20
+ .dat*
21
+ .repl_history
22
+ build/
23
+ *.bridgesupport
24
+ build-iPhoneOS/
25
+ build-iPhoneSimulator/
26
+
27
+ ## Specific to RubyMotion (use of CocoaPods):
28
+ #
29
+ # We recommend against adding the Pods directory to your .gitignore. However
30
+ # you should judge for yourself, the pros and cons are mentioned at:
31
+ # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control
32
+ #
33
+ # vendor/Pods/
34
+
35
+ ## Documentation cache and generated files:
36
+ /.yardoc/
37
+ /_yardoc/
38
+ /doc/
39
+ /rdoc/
40
+
41
+ ## Environment normalization:
42
+ /.bundle/
43
+ /vendor/bundle
44
+ /lib/bundler/man/
45
+
46
+ # for a library or gem, you might want to ignore these files since the code is
47
+ # intended to run in multiple environments; otherwise, check them in:
48
+ # Gemfile.lock
49
+ # .ruby-version
50
+ # .ruby-gemset
51
+
52
+ # unless supporting rvm < 1.11.0 or doing something fancy, ignore this:
53
+ .rvmrc
54
+
55
+ # Used by RuboCop. Remote config files pulled in from inherit_from directive.
56
+ # .rubocop-https?--*
@@ -0,0 +1,15 @@
1
+ inherit_gem:
2
+ simplycop: .simplycop.yml
3
+
4
+ AllCops:
5
+ TargetRubyVersion: 2.6
6
+ Rails:
7
+ Enabled: false
8
+ Documentation:
9
+ Enabled: false
10
+ Metrics/BlockLength:
11
+ Max: 200
12
+ Metrics/AbcSize:
13
+ Max: 20
14
+ Metrics/MethodLength:
15
+ Max: 25
@@ -0,0 +1,76 @@
1
+ # Contributor Covenant Code of Conduct
2
+
3
+ ## Our Pledge
4
+
5
+ In the interest of fostering an open and welcoming environment, we as
6
+ contributors and maintainers pledge to making participation in our project and
7
+ our community a harassment-free experience for everyone, regardless of age, body
8
+ size, disability, ethnicity, sex characteristics, gender identity and expression,
9
+ level of experience, education, socio-economic status, nationality, personal
10
+ appearance, race, religion, or sexual identity and orientation.
11
+
12
+ ## Our Standards
13
+
14
+ Examples of behavior that contributes to creating a positive environment
15
+ include:
16
+
17
+ * Using welcoming and inclusive language
18
+ * Being respectful of differing viewpoints and experiences
19
+ * Gracefully accepting constructive criticism
20
+ * Focusing on what is best for the community
21
+ * Showing empathy towards other community members
22
+
23
+ Examples of unacceptable behavior by participants include:
24
+
25
+ * The use of sexualized language or imagery and unwelcome sexual attention or
26
+ advances
27
+ * Trolling, insulting/derogatory comments, and personal or political attacks
28
+ * Public or private harassment
29
+ * Publishing others' private information, such as a physical or electronic
30
+ address, without explicit permission
31
+ * Other conduct which could reasonably be considered inappropriate in a
32
+ professional setting
33
+
34
+ ## Our Responsibilities
35
+
36
+ Project maintainers are responsible for clarifying the standards of acceptable
37
+ behavior and are expected to take appropriate and fair corrective action in
38
+ response to any instances of unacceptable behavior.
39
+
40
+ Project maintainers have the right and responsibility to remove, edit, or
41
+ reject comments, commits, code, wiki edits, issues, and other contributions
42
+ that are not aligned to this Code of Conduct, or to ban temporarily or
43
+ permanently any contributor for other behaviors that they deem inappropriate,
44
+ threatening, offensive, or harmful.
45
+
46
+ ## Scope
47
+
48
+ This Code of Conduct applies both within project spaces and in public spaces
49
+ when an individual is representing the project or its community. Examples of
50
+ representing a project or community include using an official project e-mail
51
+ address, posting via an official social media account, or acting as an appointed
52
+ representative at an online or offline event. Representation of a project may be
53
+ further defined and clarified by project maintainers.
54
+
55
+ ## Enforcement
56
+
57
+ Instances of abusive, harassing, or otherwise unacceptable behavior may be
58
+ reported by contacting the project team at tech@simplybusiness.co.uk. All
59
+ complaints will be reviewed and investigated and will result in a response that
60
+ is deemed necessary and appropriate to the circumstances. The project team is
61
+ obligated to maintain confidentiality with regard to the reporter of an incident.
62
+ Further details of specific enforcement policies may be posted separately.
63
+
64
+ Project maintainers who do not follow or enforce the Code of Conduct in good
65
+ faith may face temporary or permanent repercussions as determined by other
66
+ members of the project's leadership.
67
+
68
+ ## Attribution
69
+
70
+ This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
71
+ available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html
72
+
73
+ [homepage]: https://www.contributor-covenant.org
74
+
75
+ For answers to common questions about this code of conduct, see
76
+ https://www.contributor-covenant.org/faq
data/Gemfile ADDED
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ source 'https://rubygems.org'
4
+ gem 'simplycop', git: 'https://github.com/simplybusiness/simplycop.git'
5
+ group :development, :test do
6
+ gem 'minitest'
7
+ end
@@ -0,0 +1,60 @@
1
+ GIT
2
+ remote: https://github.com/simplybusiness/simplycop.git
3
+ revision: 02b417277e3ff9eeab34d6b7c30a95a17d876731
4
+ specs:
5
+ simplycop (0.5.4)
6
+ rubocop (~> 0.80.0)
7
+ rubocop-rails
8
+ rubocop-rspec
9
+
10
+ GEM
11
+ remote: https://rubygems.org/
12
+ specs:
13
+ activesupport (6.0.3.1)
14
+ concurrent-ruby (~> 1.0, >= 1.0.2)
15
+ i18n (>= 0.7, < 2)
16
+ minitest (~> 5.1)
17
+ tzinfo (~> 1.1)
18
+ zeitwerk (~> 2.2, >= 2.2.2)
19
+ ast (2.4.0)
20
+ concurrent-ruby (1.1.6)
21
+ i18n (1.8.2)
22
+ concurrent-ruby (~> 1.0)
23
+ jaro_winkler (1.5.4)
24
+ minitest (5.14.0)
25
+ parallel (1.19.1)
26
+ parser (2.7.1.3)
27
+ ast (~> 2.4.0)
28
+ rack (2.2.2)
29
+ rainbow (3.0.0)
30
+ rexml (3.2.4)
31
+ rubocop (0.80.1)
32
+ jaro_winkler (~> 1.5.1)
33
+ parallel (~> 1.10)
34
+ parser (>= 2.7.0.1)
35
+ rainbow (>= 2.2.2, < 4.0)
36
+ rexml
37
+ ruby-progressbar (~> 1.7)
38
+ unicode-display_width (>= 1.4.0, < 1.7)
39
+ rubocop-rails (2.5.2)
40
+ activesupport
41
+ rack (>= 1.1)
42
+ rubocop (>= 0.72.0)
43
+ rubocop-rspec (1.39.0)
44
+ rubocop (>= 0.68.1)
45
+ ruby-progressbar (1.10.1)
46
+ thread_safe (0.3.6)
47
+ tzinfo (1.2.7)
48
+ thread_safe (~> 0.1)
49
+ unicode-display_width (1.6.1)
50
+ zeitwerk (2.3.0)
51
+
52
+ PLATFORMS
53
+ ruby
54
+
55
+ DEPENDENCIES
56
+ minitest
57
+ simplycop!
58
+
59
+ BUNDLED WITH
60
+ 2.1.4
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2020 Simply Business
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,126 @@
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 [README](../README.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
+ Create a new logger like so:
15
+
16
+ ```ruby
17
+ require 'twiglet/logger'
18
+ logger = Twiglet::Logger.new(conf: { service: 'petshop' })
19
+ ```
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).
22
+
23
+ To use, simply invoke like most other loggers:
24
+
25
+ ```ruby
26
+ logger.error({ event: { action: 'startup' }, message: "Emergency! There's an Emergency going on" })
27
+ ```
28
+
29
+ This will write to STDOUT a JSON string:
30
+
31
+ ```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"}
33
+ ```
34
+
35
+ Obviously the timestamp will be different.
36
+
37
+ Add log event specific information simply as attributes in a hash:
38
+
39
+ ```ruby
40
+ logger.info({
41
+ event: { action: 'HTTP request' },
42
+ message: 'GET /pets success',
43
+ trace: { id: '1c8a5fb2-fecd-44d8-92a4-449eb2ce4dcb' },
44
+ http: {
45
+ request: { method: 'get' },
46
+ response: { status_code: 200 }
47
+ },
48
+ url: { path: '/pets' }
49
+ })
50
+ ```
51
+
52
+ This writes:
53
+
54
+ ```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"}}
56
+ ```
57
+
58
+ 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
+
60
+ ```ruby
61
+ request_log = logger.with({ event: { action: 'HTTP request'}, trace: { id: '1c8a5fb2-fecd-44d8-92a4-449eb2ce4dcb' }})
62
+ ```
63
+
64
+ This can be used like any other Logger instance:
65
+
66
+ ```ruby
67
+ request_logger.error({
68
+ message: 'Error 500 in /pets/buy',
69
+ http: {
70
+ request: { method: 'post', 'url.path': '/pet/buy' },
71
+ response: { status_code: 500 }
72
+ }
73
+ })
74
+ ```
75
+
76
+ which will print:
77
+
78
+ ```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}}}
80
+ ```
81
+
82
+ ## Use of dotted keys
83
+
84
+ 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:
85
+
86
+ ```ruby
87
+ logger.info({
88
+ 'event.action': 'HTTP request',
89
+ message: 'GET /pets success',
90
+ 'trace.id': '1c8a5fb2-fecd-44d8-92a4-449eb2ce4dcb',
91
+ 'http.request.method': 'get',
92
+ 'http.response.status_code': 200,
93
+ 'url.path': '/pets'
94
+ })
95
+ ```
96
+
97
+ or mix between dotted keys and nested objects:
98
+
99
+ ```ruby
100
+ logger.info({
101
+ 'event.action': 'HTTP request',
102
+ message: 'GET /pets success',
103
+ trace: { id: '1c8a5fb2-fecd-44d8-92a4-449eb2ce4dcb' },
104
+ 'http.request.method': 'get',
105
+ 'http.response.status_code': 200,
106
+ url: { path: '/pets' }
107
+ })
108
+ ```
109
+
110
+ Both cases would print out exact the same log item:
111
+
112
+ ```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"}}
114
+ ```
115
+
116
+ ## How to contribute
117
+
118
+ First: Please read our project [Code of Conduct](../CODE_OF_CONDUCT.md).
119
+
120
+ Second: run the tests and make sure your changes don't break anything:
121
+
122
+ ```bash
123
+ for file in test/*test.rb; do ruby $file; done
124
+ ```
125
+
126
+ Then please feel free to submit a PR.
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'lib/twiglet/logger'
4
+
5
+ PORT = 8080
6
+
7
+ logger = Logger.new(conf: { service: 'petshop' })
8
+
9
+ # Start our petshop
10
+ logger.info({
11
+ event: {
12
+ action: 'startup'
13
+ },
14
+ message: "Ready to go, listening on port #{PORT}",
15
+ server: {
16
+ port: PORT
17
+ }
18
+ })
19
+
20
+ # We get a request
21
+ request_logger = logger.with({
22
+ event: {
23
+ action: 'HTTP request'
24
+ },
25
+ trace: {
26
+ id: '126bb6fa-28a2-470f-b013-eefbf9182b2d'
27
+ }
28
+ })
29
+
30
+ # Oh noes!
31
+ db_err = StandardError.new('Connection timed-out')
32
+
33
+ request_logger.error({ message: 'DB connection failed.' }, db_err) if db_err
34
+
35
+ # We return an error to the requester
36
+ request_logger.info({
37
+ message: 'Internal Server Error',
38
+ http: {
39
+ request: {
40
+ method: 'get'
41
+ },
42
+ response: {
43
+ status_code: 500
44
+ }
45
+ }
46
+ })
47
+
48
+ # Logging with a non-empty message is an anti-pattern and is therefore forbidden
49
+ # Both of the following lines would throw an error
50
+ # request_logger.error({ message: "" })
51
+ # logger.debug({ message: " " })
@@ -0,0 +1,29 @@
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
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'time'
4
+ require 'json'
5
+ require_relative '../elastic_common_schema'
6
+
7
+ module Twiglet
8
+ class Logger
9
+ include ElasticCommonSchema
10
+
11
+ def initialize(conf:, scoped_properties: {})
12
+ @service = conf[:service]
13
+ @now = conf[:now] || -> { Time.now.utc }
14
+ @output = conf[:output] || $stdout
15
+
16
+ raise 'configuration must have a service name' \
17
+ unless @service.is_a?(String) && !@service.strip.empty?
18
+
19
+ @scoped_properties = scoped_properties
20
+ end
21
+
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)
35
+ if error
36
+ message = message.merge({
37
+ error_name: error.message,
38
+ backtrace: error.backtrace
39
+ })
40
+ end
41
+
42
+ log(level: 'error', message: message)
43
+ end
44
+
45
+ def critical(message)
46
+ log(level: 'critical', message: message)
47
+ end
48
+
49
+ def with(scoped_properties)
50
+ Logger.new(conf: { service: @service,
51
+ now: @now,
52
+ output: @output },
53
+ scoped_properties: scoped_properties)
54
+ end
55
+
56
+ private
57
+
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
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Twiglet
4
+ VERSION = '1.1.0'
5
+ end
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'minitest/autorun'
4
+ require_relative '../lib/elastic_common_schema'
5
+
6
+ describe ElasticCommonSchema do
7
+ before do
8
+ @ecs = Object.new
9
+ @ecs.extend(ElasticCommonSchema)
10
+ end
11
+
12
+ it 'should retain an object without . in any keys' do
13
+ actual = {
14
+ message: 'Out of pets exception',
15
+ service: {
16
+ name: 'petshop'
17
+ },
18
+ log: {
19
+ level: 'error'
20
+ },
21
+ "@timestamp": '2020-05-09T15:13:20.736Z'
22
+ }
23
+
24
+ expected = @ecs.to_nested(actual)
25
+ assert_equal actual, expected
26
+ end
27
+
28
+ it 'should convert keys with . into nested objects' do
29
+ actual = {
30
+ "service.name": 'petshop',
31
+ "log.level": 'error'
32
+ }
33
+
34
+ nested = @ecs.to_nested(actual)
35
+
36
+ assert_equal 'petshop', nested[:service][:name]
37
+ assert_equal 'error', nested[:log][:level]
38
+ end
39
+
40
+ it 'should group nested objects' do
41
+ actual = {
42
+ "service.name": 'petshop',
43
+ "service.id": 'ps001',
44
+ "service.version": '0.9.1',
45
+ "log.level": 'error'
46
+ }
47
+
48
+ nested = @ecs.to_nested(actual)
49
+
50
+ assert_equal 'petshop', nested[:service][:name]
51
+ assert_equal 'ps001', nested[:service][:id]
52
+ assert_equal '0.9.1', nested[:service][:version]
53
+ assert_equal 'error', nested[:log][:level]
54
+ end
55
+
56
+ it 'should cope with more than two levels' do
57
+ actual = {
58
+ "http.request.method": 'get',
59
+ "http.request.body.bytes": 112,
60
+ "http.response.bytes": 1564,
61
+ "http.response.status_code": 200
62
+ }
63
+
64
+ nested = @ecs.to_nested(actual)
65
+
66
+ assert_equal 'get', nested[:http][:request][:method]
67
+ assert_equal 112, nested[:http][:request][:body][:bytes]
68
+ assert_equal 1564, nested[:http][:response][:bytes]
69
+ assert_equal 200, nested[:http][:response][:status_code]
70
+ end
71
+
72
+ it '#deep_merge() should work with two hashes without common keys' do
73
+ first = { id: 1, name: 'petshop' }
74
+ second = { level: 'debug', code: 5 }
75
+
76
+ actual = @ecs.deep_merge(first, second)
77
+
78
+ assert_equal 1, actual[:id]
79
+ assert_equal 'petshop', actual[:name]
80
+ assert_equal 'debug', actual[:level]
81
+ assert_equal 5, actual[:code]
82
+ end
83
+
84
+ it '#deep_merge() should use the second value for shared keys' do
85
+ first = { id: 1, name: 'petshop', level: 'debug' }
86
+ second = { name: 'petstore', level: 'error', code: 5 }
87
+
88
+ actual = @ecs.deep_merge(first, second)
89
+
90
+ assert_equal 1, actual[:id]
91
+ assert_equal 'petstore', actual[:name]
92
+ assert_equal 'error', actual[:level]
93
+ assert_equal 5, actual[:code]
94
+ end
95
+
96
+ it '#deep_merge() should merge two sub-keys' do
97
+ first = { service: { name: 'petshop' } }
98
+ second = { service: { id: 'ps001' } }
99
+
100
+ actual = @ecs.deep_merge(first, second)
101
+ assert_equal 'petshop', actual[:service][:name]
102
+ assert_equal 'ps001', actual[:service][:id]
103
+ end
104
+
105
+ it '#deep_merge() should merge sub-keys in more than 2 levels' do
106
+ first = { http: { request: { method: 'get', bytes: 124 } } }
107
+ second = { http: { response: { status_code: 200, bytes: 5001 } } }
108
+
109
+ actual = @ecs.deep_merge(first, second)
110
+
111
+ assert_equal 'get', actual[:http][:request][:method]
112
+ assert_equal 124, actual[:http][:request][:bytes]
113
+ assert_equal 200, actual[:http][:response][:status_code]
114
+ assert_equal 5001, actual[:http][:response][:bytes]
115
+ end
116
+
117
+ it '#deep_merge() should work when the first key is empty' do
118
+ first = {}
119
+ second = { id: 1 }
120
+
121
+ actual = @ecs.deep_merge(first, second)
122
+
123
+ assert_equal 1, actual[:id]
124
+ end
125
+
126
+ it '#deep_merge() should work when the second key is empty' do
127
+ first = { id: 1 }
128
+ second = {}
129
+
130
+ actual = @ecs.deep_merge(first, second)
131
+
132
+ assert_equal 1, actual[:id]
133
+ end
134
+ end
@@ -0,0 +1,190 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'minitest/autorun'
4
+ require_relative '../lib/twiglet/logger'
5
+
6
+ describe Twiglet::Logger do
7
+ before do
8
+ @now = -> { Time.utc(2020, 5, 11, 15, 1, 1) }
9
+ @buffer = StringIO.new
10
+ @logger = Twiglet::Logger.new(conf: {
11
+ service: 'petshop',
12
+ now: @now,
13
+ output: @buffer
14
+ })
15
+ end
16
+
17
+ it 'should throw an error with an empty service name' do
18
+ assert_raises RuntimeError do
19
+ Twiglet::Logger.new(conf: { service: ' ' })
20
+ end
21
+ end
22
+
23
+ it 'should throw an error with an empty message' do
24
+ assert_raises RuntimeError do
25
+ @logger.info('')
26
+ end
27
+ end
28
+
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'
41
+ }
42
+ }
43
+
44
+ assert_equal expected_log, actual_log
45
+ end
46
+
47
+ it 'should log the provided message' do
48
+ @logger.error({ event:
49
+ { action: 'exception' },
50
+ message: 'Emergency! Emergency!' })
51
+ log = read_json(@buffer)
52
+
53
+ assert_equal 'exception', log[:event][:action]
54
+ assert_equal 'Emergency! Emergency!', log[:message]
55
+ end
56
+
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',
69
+ now: @now,
70
+ output: output
71
+ },
72
+ scoped_properties: extra_properties)
73
+
74
+ logger.error({ message: 'GET /cats' })
75
+ log = read_json output
76
+
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
81
+
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
104
+
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)
110
+
111
+ assert_equal 'Guinea pigs arrived', log[:message]
112
+ end
113
+
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
130
+
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
146
+
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]
168
+ end
169
+
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)
175
+ end
176
+
177
+ actual_log = read_json(@buffer)
178
+
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
182
+ end
183
+
184
+ private
185
+
186
+ def read_json(buffer)
187
+ buffer.rewind
188
+ JSON.parse(buffer.read, symbolize_names: true)
189
+ end
190
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require File.expand_path('lib/twiglet/version', __dir__)
4
+
5
+ $LOAD_PATH.push File.expand_path('lib', __dir__)
6
+
7
+ Gem::Specification.new do |gem|
8
+ gem.name = 'twiglet'
9
+ gem.version = Twiglet::VERSION
10
+ gem.authors = ['Simply Business']
11
+ gem.email = ['tech@simplybusiness.co.uk']
12
+ gem.homepage = 'https://github.com/simplybusiness/twiglet'
13
+
14
+ gem.summary = 'Twiglet'
15
+ gem.description = 'Like a log, only smaller.'
16
+
17
+ gem.files = `git ls-files`.split("\n")
18
+ gem.test_files = `git ls-files -- {test}/*`.split("\n")
19
+
20
+ gem.require_paths = ['lib']
21
+ gem.required_ruby_version = '>= 2.6'
22
+
23
+ gem.license = 'Copyright SimplyBusiness'
24
+ end
metadata ADDED
@@ -0,0 +1,59 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: twiglet
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Simply Business
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2020-06-01 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description: Like a log, only smaller.
14
+ email:
15
+ - tech@simplybusiness.co.uk
16
+ executables: []
17
+ extensions: []
18
+ extra_rdoc_files: []
19
+ files:
20
+ - ".github/CODEOWNERS"
21
+ - ".github/workflows/ruby.yml"
22
+ - ".gitignore"
23
+ - ".rubocop.yml"
24
+ - CODE_OF_CONDUCT.md
25
+ - Gemfile
26
+ - Gemfile.lock
27
+ - LICENSE
28
+ - README.md
29
+ - example_app.rb
30
+ - lib/elastic_common_schema.rb
31
+ - lib/twiglet/logger.rb
32
+ - lib/twiglet/version.rb
33
+ - test/elastic_common_schema_test.rb
34
+ - test/logger_test.rb
35
+ - twiglet.gemspec
36
+ homepage: https://github.com/simplybusiness/twiglet
37
+ licenses:
38
+ - Copyright SimplyBusiness
39
+ metadata: {}
40
+ post_install_message:
41
+ rdoc_options: []
42
+ require_paths:
43
+ - lib
44
+ required_ruby_version: !ruby/object:Gem::Requirement
45
+ requirements:
46
+ - - ">="
47
+ - !ruby/object:Gem::Version
48
+ version: '2.6'
49
+ required_rubygems_version: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '0'
54
+ requirements: []
55
+ rubygems_version: 3.0.3
56
+ signing_key:
57
+ specification_version: 4
58
+ summary: Twiglet
59
+ test_files: []