loga 2.3.0 → 2.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 99d4525427d3cfa44d95be9d47f82188246743e71aab9f8d40489214e02e4c02
4
- data.tar.gz: 33fc287f4ecabf93cdc6d789f81a040c64eb411d7a018459fa39c91ae03c46c8
3
+ metadata.gz: 346f092fca3d84834d02408f3307462e0ec017f9634bf08396355e46a1681ec2
4
+ data.tar.gz: 93949c198f5a4a697ca261bc6d13eabdbd3349c2ab2d57e845abbd5289279095
5
5
  SHA512:
6
- metadata.gz: 6ce2b115d84a4b3c1c93bf8070b0f3d7ed148df4eb1169ab57d8de2357ad03ea23c21fab2f30f6c0b24236919cbaee21565eee7fed007949a7fa6601118ef510
7
- data.tar.gz: df05bfcd3ba233ef8a64e743fcb40c9948caeb1a82039340ae6cb24a839b2cab08044da8009f91196bf444b0b960cbcc29bd2fe9b08f8f3ac003f2757ad5d320
6
+ metadata.gz: c64276706dd59404d3dc19370dc5b85a8390b60d80f6c781826d6417db086c445051b28ce9b85511da071da4d1c5172f7c4703e2782298f09a2e271399ee7a3f
7
+ data.tar.gz: 19c9919c468fd33bcabf84877b429e631ffdffa1fe3e8b2cbf3319d8b894ffdf3e3db830549f5fa39a57b8745aa9d37d6f691fd9feeb0f4cc2a0a2538f6b5d24
@@ -58,21 +58,21 @@ jobs:
58
58
  command: |
59
59
  ./tmp/cc-test-reporter sum-coverage tmp/codeclimate.*.json -o tmp/codeclimate.total.json
60
60
  ./tmp/cc-test-reporter upload-coverage -i tmp/codeclimate.total.json -r $CODECLIMATE_REPO_TOKEN
61
- ruby-2.2:
62
- docker:
63
- - image: circleci/ruby:2.2.10
64
- <<: *test_build
65
61
  ruby-2.3:
66
62
  docker:
67
- - image: circleci/ruby:2.3.7
63
+ - image: circleci/ruby:2.3
68
64
  <<: *test_build
69
65
  ruby-2.4:
70
66
  docker:
71
- - image: circleci/ruby:2.4.4
67
+ - image: circleci/ruby:2.4
72
68
  <<: *test_build
73
69
  ruby-2.5:
74
70
  docker:
75
- - image: circleci/ruby:2.5.1
71
+ - image: circleci/ruby:2.5
72
+ <<: *test_build
73
+ ruby-2.6:
74
+ docker:
75
+ - image: circleci/ruby:2.6
76
76
  <<: *test_build
77
77
  rubocop:
78
78
  <<: *basic_build
@@ -111,25 +111,25 @@ workflows:
111
111
  filters:
112
112
  tags:
113
113
  only: /.*/
114
- - ruby-2.2:
114
+ - ruby-2.3:
115
115
  filters:
116
116
  tags:
117
117
  only: /.*/
118
118
  requires:
119
119
  - build
120
- - ruby-2.3:
120
+ - ruby-2.4:
121
121
  filters:
122
122
  tags:
123
123
  only: /.*/
124
124
  requires:
125
125
  - build
126
- - ruby-2.4:
126
+ - ruby-2.5:
127
127
  filters:
128
128
  tags:
129
129
  only: /.*/
130
130
  requires:
131
131
  - build
132
- - ruby-2.5:
132
+ - ruby-2.6:
133
133
  filters:
134
134
  tags:
135
135
  only: /.*/
@@ -140,10 +140,10 @@ workflows:
140
140
  tags:
141
141
  only: /.*/
142
142
  requires:
143
- - ruby-2.2
144
143
  - ruby-2.3
145
144
  - ruby-2.4
146
145
  - ruby-2.5
146
+ - ruby-2.6
147
147
  - push-to-rubygems:
148
148
  filters:
149
149
  tags:
@@ -152,7 +152,7 @@ workflows:
152
152
  ignore: /.*/
153
153
  requires:
154
154
  - rubocop
155
- - ruby-2.2
156
155
  - ruby-2.3
157
156
  - ruby-2.4
158
157
  - ruby-2.5
158
+ - ruby-2.6
@@ -2,5 +2,3 @@ engines:
2
2
  rubocop:
3
3
  enabled: true
4
4
  channel: rubocop-0-57
5
- config:
6
- file: rubocop.yml
data/Appraisals CHANGED
@@ -24,6 +24,16 @@ appraise 'rails52' do
24
24
  gem 'rails', '~> 5.2.0'
25
25
  end
26
26
 
27
+ if Gem::Version.new(RUBY_VERSION) > Gem::Version.new('2.5.0')
28
+ appraise 'rails60' do
29
+ gem 'rails', '~> 6.0.0'
30
+ end
31
+
32
+ appraise 'sidekiq6' do
33
+ gem 'sidekiq', '~> 6.0'
34
+ end
35
+ end
36
+
27
37
  appraise 'sidekiq51' do
28
38
  gem 'sidekiq', '~> 5.1.0'
29
39
  end
@@ -4,6 +4,30 @@ All notable changes to this project will be documented in this file.
4
4
  The format is based on [Keep a Changelog](http://keepachangelog.com/)
5
5
  and this project adheres to [Semantic Versioning](http://semver.org/).
6
6
 
7
+ ## [2.5.2] - 2020-10-21
8
+ ### Fixed
9
+ - Support for sidekiq 6
10
+
11
+ ## [2.5.1] - 2020-01-02
12
+ ### Fixed
13
+ - Fixed a long standing bug that would mask exceptions raised by the host application when serving requests. The original exception would be replaced with a `TypeError` one due to a HTTP status code not being available within `Loga::Rack::Logger`.
14
+
15
+ ## [2.5.0] - 2019-11-12
16
+ ### Added
17
+ - Add support for rails 6
18
+
19
+ ## [2.4.0] - 2019-09-03
20
+ ### Fixed
21
+ - `duration` in the `sidekiq` integration is now calculated correctly
22
+ ### Added
23
+ - Add build for ruby 2.6
24
+ ### Removed
25
+ - Remove build for ruby 2.2
26
+
27
+ ## [2.3.1] - 2019-05-14
28
+ ### Added
29
+ New configuration option `hide_pii` which defaults to `true` to hide email addresses in logs that get generate when an email is sent through action_mailer
30
+
7
31
  ## [2.3.0] - 2018-06-29
8
32
  ### Added
9
33
  Support for Sidekiq `~> 5.0`.
data/Gemfile CHANGED
@@ -4,5 +4,5 @@ source 'https://rubygems.org'
4
4
  gemspec
5
5
 
6
6
  group :test do
7
- gem 'simplecov'
7
+ gem 'simplecov', '~> 0.17.0'
8
8
  end
@@ -0,0 +1,11 @@
1
+ # This file was generated by Appraisal
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "rails", "~> 6.0.0"
6
+
7
+ group :test do
8
+ gem "simplecov"
9
+ end
10
+
11
+ gemspec path: "../"
@@ -0,0 +1,11 @@
1
+ # This file was generated by Appraisal
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "sidekiq", "~> 6.0"
6
+
7
+ group :test do
8
+ gem "simplecov"
9
+ end
10
+
11
+ gemspec path: "../"
@@ -15,9 +15,10 @@ module Loga
15
15
  ].freeze
16
16
 
17
17
  attr_accessor :device, :filter_exceptions, :filter_parameters,
18
- :host, :level, :service_version, :sync, :tags
18
+ :host, :level, :service_version, :sync, :tags, :hide_pii
19
19
  attr_reader :logger, :format, :service_name
20
20
 
21
+ # rubocop:disable Metrics/MethodLength
21
22
  def initialize(user_options = {}, framework_options = {})
22
23
  options = default_options.merge(framework_options)
23
24
  .merge(environment_options)
@@ -33,11 +34,13 @@ module Loga
33
34
  self.service_version = options[:service_version] || ServiceVersionStrategies.call
34
35
  self.sync = options[:sync]
35
36
  self.tags = options[:tags]
37
+ self.hide_pii = options[:hide_pii]
36
38
 
37
39
  validate
38
40
 
39
41
  @logger = initialize_logger
40
42
  end
43
+ # rubocop:enable Metrics/MethodLength
41
44
 
42
45
  def format=(name)
43
46
  @format = name.to_s.to_sym
@@ -68,6 +71,7 @@ module Loga
68
71
  level: :info,
69
72
  sync: true,
70
73
  tags: [],
74
+ hide_pii: true,
71
75
  }
72
76
  end
73
77
 
@@ -9,7 +9,11 @@ module Loga
9
9
  recipients = event.payload[:to].join(',')
10
10
  unique_id = event.payload[:unique_id]
11
11
  duration = event.duration.round(1)
12
- message = "#{mailer}: Sent mail to #{recipients} in (#{duration}ms)"
12
+ message = ''.tap do |string|
13
+ string << "#{mailer}: Sent mail"
14
+ string << " to #{recipients}" unless hide_pii?
15
+ string << " in (#{duration}ms)"
16
+ end
13
17
 
14
18
  loga_event = Event.new(
15
19
  data: { mailer: mailer, unique_id: unique_id },
@@ -41,10 +45,15 @@ module Loga
41
45
  from = event.payload[:from]
42
46
  mailer = event.payload[:mailer]
43
47
  unique_id = event.payload[:unique_id]
48
+ message = ''.tap do |string|
49
+ string << 'Received mail'
50
+ string << " from #{from}" unless hide_pii?
51
+ string << " in (#{event.duration.round(1)}ms)"
52
+ end
44
53
 
45
54
  loga_event = Event.new(
46
55
  data: { mailer: mailer, unique_id: unique_id },
47
- message: "Received mail #{from} in (#{event.duration.round(1)}ms)",
56
+ message: message,
48
57
  type: 'action_mailer',
49
58
  )
50
59
 
@@ -54,6 +63,10 @@ module Loga
54
63
  def logger
55
64
  Loga.logger
56
65
  end
66
+
67
+ def hide_pii?
68
+ Loga.configuration.hide_pii
69
+ end
57
70
  end
58
71
  end
59
72
  end
@@ -44,6 +44,9 @@ module Loga
44
44
  data['user_agent'] = request.user_agent
45
45
  data['controller'] = request.controller_action_name if request.controller_action_name
46
46
  data['duration'] = duration_in_ms(started_at, Time.now)
47
+
48
+ # If data['status'] is nil we assume an exception was raised when calling the application
49
+ data['status'] ||= 500
47
50
  end
48
51
  # rubocop:enable Metrics/LineLength
49
52
 
@@ -125,7 +125,7 @@ module Loga
125
125
  def silence_rails_rack_logger
126
126
  case Rails::VERSION::MAJOR
127
127
  when 3 then require 'loga/ext/rails/rack/logger3.rb'
128
- when 4..5 then require 'loga/ext/rails/rack/logger.rb'
128
+ when 4..6 then require 'loga/ext/rails/rack/logger.rb'
129
129
  else
130
130
  raise Loga::ConfigurationError,
131
131
  "Rails #{Rails::VERSION::MAJOR} is unsupported"
@@ -10,7 +10,7 @@ module Loga
10
10
  config.options[:job_logger] = Loga::Sidekiq::JobLogger
11
11
  end
12
12
 
13
- ::Sidekiq::Logging.logger = Loga.configuration.logger
13
+ ::Sidekiq.logger = Loga.configuration.logger
14
14
  end
15
15
  end
16
16
  end
@@ -9,14 +9,16 @@ module Loga
9
9
 
10
10
  EVENT_TYPE = 'sidekiq'.freeze
11
11
 
12
- attr_reader :started_at, :data
12
+ def started_at
13
+ @started_at ||= Time.now
14
+ end
13
15
 
14
- def initialize
15
- @started_at = Time.now
16
- @data = {}
16
+ def data
17
+ @data ||= {}
17
18
  end
18
19
 
19
20
  def call(item, _queue)
21
+ reset_data
20
22
  yield
21
23
  rescue Exception => ex # rubocop:disable Lint/RescueException
22
24
  data['exception'] = ex
@@ -29,6 +31,11 @@ module Loga
29
31
 
30
32
  private
31
33
 
34
+ def reset_data
35
+ @data = {}
36
+ @started_at = Time.now
37
+ end
38
+
32
39
  def assign_data(item)
33
40
  data['created_at'] = item['created_at']
34
41
  data['enqueued_at'] = item['enqueued_at']
@@ -1,3 +1,3 @@
1
1
  module Loga
2
- VERSION = '2.3.0'.freeze
2
+ VERSION = '2.5.2'.freeze
3
3
  end
@@ -0,0 +1,80 @@
1
+ require 'action_controller/railtie'
2
+ require 'action_mailer/railtie'
3
+
4
+ Bundler.require(*Rails.groups)
5
+
6
+ STREAM = StringIO.new unless defined?(STREAM)
7
+
8
+ class Dummy < Rails::Application
9
+ config.eager_load = true
10
+ config.filter_parameters += [:password]
11
+ config.secret_key_base = '2624599ca9ab3cf3823626240138a128118a87683bf03ab8f155844c33b3cd8cbbfa3ef5e29db6f5bd182f8bd4776209d9577cfb46ac51bfd232b00ab0136b24'
12
+ config.session_store :cookie_store, key: '_rails60_session'
13
+
14
+ config.log_tags = [:uuid, 'TEST_TAG']
15
+ config.loga = {
16
+ device: STREAM,
17
+ host: 'bird.example.com',
18
+ service_name: 'hello_world_app',
19
+ service_version: '1.0',
20
+ }
21
+ config.action_mailer.delivery_method = :test
22
+ end
23
+
24
+ class ApplicationController < ActionController::Base
25
+ include Rails.application.routes.url_helpers
26
+ protect_from_forgery with: :null_session
27
+
28
+ def ok
29
+ render plain: 'Hello Rails'
30
+ end
31
+
32
+ def error
33
+ nil.name
34
+ end
35
+
36
+ def show
37
+ render json: params
38
+ end
39
+
40
+ def create
41
+ render json: params
42
+ end
43
+
44
+ def new
45
+ redirect_to :ok
46
+ end
47
+
48
+ def update
49
+ @id = params[:id]
50
+ render '/user'
51
+ end
52
+ end
53
+
54
+ class FakeMailer < ActionMailer::Base
55
+ default from: 'notifications@example.com'
56
+
57
+ def self.send_email
58
+ basic_mail.deliver_now
59
+ end
60
+
61
+ def basic_mail
62
+ mail(
63
+ to: 'user@example.com',
64
+ subject: 'Welcome to My Awesome Site',
65
+ body: 'Banana muffin',
66
+ content_type: 'text/html',
67
+ )
68
+ end
69
+ end
70
+
71
+ Dummy.routes.append do
72
+ get 'ok' => 'application#ok'
73
+ get 'error' => 'application#error'
74
+ get 'show' => 'application#show'
75
+ post 'users' => 'application#create'
76
+ get 'new' => 'application#new'
77
+ put 'users/:id' => 'application#update'
78
+ end
79
+
80
+ Dummy.initialize!
@@ -44,7 +44,7 @@ RSpec.describe Loga::LogSubscribers::ActionMailer, if: Rails.env.production? do
44
44
  it 'has the proper payload for message delivery' do
45
45
  FakeMailer.send_email
46
46
 
47
- message_pattern = /^FakeMailer: Sent mail to user@example.com in \(*/
47
+ message_pattern = /^FakeMailer: Sent mail \(*/
48
48
  expect(last_log_entry['short_message']).to match(message_pattern)
49
49
  end
50
50
 
@@ -21,9 +21,9 @@ end
21
21
  describe 'Sidekiq client logger' do
22
22
  let(:target) { StringIO.new }
23
23
 
24
- let(:json_line) do
24
+ def read_json_log(line:)
25
25
  target.rewind
26
- JSON.parse(target.read)
26
+ JSON.parse(target.each_line.drop(line - 1).first)
27
27
  end
28
28
 
29
29
  before do
@@ -45,10 +45,6 @@ describe 'Sidekiq client logger' do
45
45
  expect(Sidekiq.options[:job_logger]).to eq job_logger
46
46
  end
47
47
 
48
- it 'has the proper logger Sidekiq::Logging.logger' do
49
- expect(Sidekiq::Logging.logger).to eq Loga.logger
50
- end
51
-
52
48
  it 'has the proper logger for Sidekiq.logger' do
53
49
  expect(Sidekiq.logger).to eq Loga.logger
54
50
  end
@@ -67,6 +63,10 @@ describe 'Sidekiq client logger' do
67
63
  end
68
64
 
69
65
  if ENV['BUNDLE_GEMFILE'] =~ /sidekiq51/
66
+ it 'has the proper logger Sidekiq::Logging.logger' do
67
+ expect(Sidekiq::Logging.logger).to eq Loga.logger
68
+ end
69
+
70
70
  # https://github.com/mperham/sidekiq/blob/97363210b47a4f8a1d8c1233aaa059d6643f5040/test/test_actors.rb#L57-L79
71
71
  let(:mgr) do
72
72
  Class.new do
@@ -117,6 +117,8 @@ describe 'Sidekiq client logger' do
117
117
  'version'=> '1.1',
118
118
  }
119
119
 
120
+ json_line = read_json_log(line: 1)
121
+
120
122
  aggregate_failures do
121
123
  expect(json_line).to include(expected_attributes)
122
124
 
@@ -126,6 +128,20 @@ describe 'Sidekiq client logger' do
126
128
 
127
129
  expect(json_line['short_message']).to match(/MySidekiqWorker with jid:*/)
128
130
  end
131
+
132
+ # This was a bug - the duration was constantly incresing based on when
133
+ # the logger was created. https://github.com/FundingCircle/loga/pull/117
134
+ #
135
+ # Test that after sleeping for few seconds the duration is still under 500ms
136
+ sleep 1
137
+
138
+ MySidekiqWorker.perform_async('Bob')
139
+
140
+ sleep 1
141
+
142
+ json_line = read_json_log(line: 2)
143
+
144
+ expect(json_line['_duration']).to be < 500
129
145
  end
130
146
  end
131
147
  end
@@ -24,11 +24,34 @@ RSpec.describe Loga::LogSubscribers::ActionMailer do
24
24
  to: ['user@example.com'],
25
25
  }
26
26
  end
27
+ let(:config) { instance_double Loga::Configuration, hide_pii: hide_pii }
27
28
 
28
- it 'logs an info message' do
29
+ before do
29
30
  allow(Loga.logger).to receive(:info)
30
- mailer.deliver(event)
31
- expect(Loga.logger).to have_received(:info).with(kind_of(Loga::Event))
31
+ allow(Loga).to receive(:configuration).and_return(config)
32
+ end
33
+
34
+ context 'when configuration hide_pii is true' do
35
+ let(:hide_pii) { true }
36
+
37
+ it 'logs an info message' do
38
+ mailer.deliver(event)
39
+ expect(Loga.logger).to have_received(:info).with(Loga::Event) do |event|
40
+ expect(event.message).to include('FakeMailer: Sent mail')
41
+ expect(event.message).not_to include('user@example.com')
42
+ end
43
+ end
44
+ end
45
+
46
+ context 'when configuration option hide_pii is false' do
47
+ let(:hide_pii) { false }
48
+
49
+ it 'logs an info message' do
50
+ mailer.deliver(event)
51
+ expect(Loga.logger).to have_received(:info).with(Loga::Event) do |event|
52
+ expect(event.message).to include('FakeMailer: Sent mail to user@example.com')
53
+ end
54
+ end
32
55
  end
33
56
  end
34
57
  end
@@ -45,7 +68,12 @@ RSpec.describe Loga::LogSubscribers::ActionMailer do
45
68
  it 'logs an info message' do
46
69
  allow(Loga.logger).to receive(:debug)
47
70
  mailer.process(event)
48
- expect(Loga.logger).to have_received(:debug).with(kind_of(Loga::Event))
71
+ expect(Loga.logger).to have_received(:debug)
72
+ .with(kind_of(Loga::Event)) do |event|
73
+ expect(event.message).to include(
74
+ 'FakeMailer#hello_world: Processed outbound mail',
75
+ )
76
+ end
49
77
  end
50
78
  end
51
79
  end
@@ -59,11 +87,38 @@ RSpec.describe Loga::LogSubscribers::ActionMailer do
59
87
  subject: 'Lorem ipsum',
60
88
  }
61
89
  end
90
+ let(:config) { instance_double Loga::Configuration, hide_pii: hide_pii }
62
91
 
63
- it 'logs an info message' do
92
+ before do
64
93
  allow(Loga.logger).to receive(:info)
65
- mailer.receive(event)
66
- expect(Loga.logger).to have_received(:info).with(kind_of(Loga::Event))
94
+ allow(Loga).to receive(:configuration).and_return(config)
95
+ end
96
+
97
+ context 'when configuration hide_pii is true' do
98
+ let(:hide_pii) { true }
99
+
100
+ it 'logs an info message without email' do
101
+ mailer.receive(event)
102
+ expect(Loga.logger).to have_received(:info)
103
+ .with(kind_of(Loga::Event)) do |event|
104
+ expect(event.message).to include('Received mail')
105
+ expect(event.message).not_to include('loremipsum@example.com')
106
+ end
107
+ end
108
+ end
109
+
110
+ context 'when configuration option hide_pii is false' do
111
+ let(:hide_pii) { false }
112
+
113
+ it 'logs an info message with email' do
114
+ mailer.receive(event)
115
+ expect(Loga.logger).to have_received(:info)
116
+ .with(kind_of(Loga::Event)) do |event|
117
+ expect(event.message).to include(
118
+ 'Received mail from loremipsum@example.com',
119
+ )
120
+ end
121
+ end
67
122
  end
68
123
  end
69
124
  end
@@ -65,12 +65,13 @@ describe Loga::Rack::Logger do
65
65
  let(:exception) { StandardError.new }
66
66
  let(:logged_exception) { nil }
67
67
  let(:response_status) { 200 }
68
+ let(:exception_class) { Class.new(StandardError) }
68
69
 
69
70
  context 'when an exception is raised' do
70
- let(:app) { ->(_env) { raise exception } }
71
+ let(:app) { ->(_env) { raise exception_class } }
71
72
 
72
73
  it 'does not rescue the exception' do
73
- expect { subject.call(env) }.to raise_error(StandardError)
74
+ expect { subject.call(env) }.to raise_error(exception_class)
74
75
  end
75
76
  end
76
77
 
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: loga
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.3.0
4
+ version: 2.5.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Funding Circle
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2018-07-02 00:00:00.000000000 Z
11
+ date: 2020-10-21 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport
@@ -259,7 +259,9 @@ files:
259
259
  - gemfiles/rails42.gemfile
260
260
  - gemfiles/rails50.gemfile
261
261
  - gemfiles/rails52.gemfile
262
+ - gemfiles/rails60.gemfile
262
263
  - gemfiles/sidekiq51.gemfile
264
+ - gemfiles/sidekiq6.gemfile
263
265
  - gemfiles/sinatra14.gemfile
264
266
  - gemfiles/unit.gemfile
265
267
  - lib/loga.rb
@@ -290,6 +292,7 @@ files:
290
292
  - spec/fixtures/rails42.rb
291
293
  - spec/fixtures/rails50.rb
292
294
  - spec/fixtures/rails52.rb
295
+ - spec/fixtures/rails60.rb
293
296
  - spec/fixtures/random_bin
294
297
  - spec/integration/rails/action_mailer_spec.rb
295
298
  - spec/integration/rails/railtie_spec.rb
@@ -345,6 +348,7 @@ test_files:
345
348
  - spec/fixtures/rails42.rb
346
349
  - spec/fixtures/rails50.rb
347
350
  - spec/fixtures/rails52.rb
351
+ - spec/fixtures/rails60.rb
348
352
  - spec/fixtures/random_bin
349
353
  - spec/integration/rails/action_mailer_spec.rb
350
354
  - spec/integration/rails/railtie_spec.rb