appmap 0.28.0 → 0.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +32 -0
  3. data/README.md +54 -2
  4. data/Rakefile +1 -1
  5. data/appmap.gemspec +1 -0
  6. data/lib/appmap.rb +25 -14
  7. data/lib/appmap/algorithm/stats.rb +2 -1
  8. data/lib/appmap/class_map.rb +26 -28
  9. data/lib/appmap/config.rb +115 -0
  10. data/lib/appmap/event.rb +28 -19
  11. data/lib/appmap/hook.rb +88 -129
  12. data/lib/appmap/hook/method.rb +78 -0
  13. data/lib/appmap/metadata.rb +1 -1
  14. data/lib/appmap/minitest.rb +141 -0
  15. data/lib/appmap/open.rb +57 -0
  16. data/lib/appmap/rails/action_handler.rb +7 -7
  17. data/lib/appmap/rails/sql_handler.rb +10 -8
  18. data/lib/appmap/record.rb +27 -0
  19. data/lib/appmap/rspec.rb +2 -2
  20. data/lib/appmap/trace.rb +17 -9
  21. data/lib/appmap/util.rb +19 -0
  22. data/lib/appmap/version.rb +1 -1
  23. data/package-lock.json +3 -3
  24. data/spec/abstract_controller4_base_spec.rb +1 -1
  25. data/spec/abstract_controller_base_spec.rb +9 -2
  26. data/spec/config_spec.rb +3 -3
  27. data/spec/fixtures/hook/compare.rb +7 -0
  28. data/spec/fixtures/hook/singleton_method.rb +54 -0
  29. data/spec/hook_spec.rb +280 -53
  30. data/spec/open_spec.rb +19 -0
  31. data/spec/record_sql_rails_pg_spec.rb +56 -33
  32. data/spec/util_spec.rb +1 -1
  33. data/test/cli_test.rb +14 -4
  34. data/test/fixtures/minitest_recorder/Gemfile +5 -0
  35. data/test/fixtures/minitest_recorder/appmap.yml +3 -0
  36. data/test/fixtures/minitest_recorder/lib/hello.rb +5 -0
  37. data/test/fixtures/minitest_recorder/test/hello_test.rb +12 -0
  38. data/test/fixtures/openssl_recorder/Gemfile +3 -0
  39. data/test/fixtures/openssl_recorder/appmap.yml +3 -0
  40. data/test/fixtures/openssl_recorder/lib/openssl_cert_sign.rb +94 -0
  41. data/test/fixtures/openssl_recorder/lib/openssl_encrypt.rb +34 -0
  42. data/test/fixtures/openssl_recorder/lib/openssl_key_sign.rb +28 -0
  43. data/test/fixtures/process_recorder/appmap.yml +3 -0
  44. data/test/fixtures/process_recorder/hello.rb +9 -0
  45. data/test/minitest_test.rb +38 -0
  46. data/test/openssl_test.rb +203 -0
  47. data/test/record_process_test.rb +35 -0
  48. data/test/test_helper.rb +1 -0
  49. metadata +38 -4
  50. data/spec/fixtures/hook/class_method.rb +0 -17
@@ -15,24 +15,15 @@ module AppMap
15
15
  end
16
16
  end
17
17
 
18
- MethodEventStruct = Struct.new(:id, :event, :defined_class, :method_id, :path, :lineno, :static, :thread_id)
18
+ MethodEventStruct = Struct.new(:id, :event, :thread_id)
19
19
 
20
20
  class MethodEvent < MethodEventStruct
21
21
  LIMIT = 100
22
22
 
23
23
  class << self
24
- def build_from_invocation(me, event_type, defined_class, method)
25
- singleton = method.owner.singleton_class?
26
-
24
+ def build_from_invocation(me, event_type)
27
25
  me.id = AppMap::Event.next_id_counter
28
26
  me.event = event_type
29
- me.defined_class = defined_class
30
- me.method_id = method.name.to_s
31
- path = method.source_location[0]
32
- path = path[Dir.pwd.length + 1..-1] if path.index(Dir.pwd) == 0
33
- me.path = path
34
- me.lineno = method.source_location[1]
35
- me.static = singleton
36
27
  me.thread_id = Thread.current.object_id
37
28
  end
38
29
 
@@ -61,16 +52,25 @@ module AppMap
61
52
  (value_string||'')[0...LIMIT].encode('utf-8', invalid: :replace, undef: :replace, replace: '_')
62
53
  end
63
54
  end
64
-
65
- alias static? static
66
55
  end
67
56
 
68
57
  class MethodCall < MethodEvent
69
- attr_accessor :parameters, :receiver
58
+ attr_accessor :defined_class, :method_id, :path, :lineno, :parameters, :receiver, :static
70
59
 
71
60
  class << self
72
61
  def build_from_invocation(mc = MethodCall.new, defined_class, method, receiver, arguments)
73
62
  mc.tap do
63
+ static = receiver.is_a?(Module)
64
+ mc.defined_class = defined_class
65
+ mc.method_id = method.name.to_s
66
+ if method.source_location
67
+ path = method.source_location[0]
68
+ path = path[Dir.pwd.length + 1..-1] if path.index(Dir.pwd) == 0
69
+ mc.path = path
70
+ mc.lineno = method.source_location[1]
71
+ else
72
+ mc.path = [ defined_class, static ? '.' : '#', method.name ].join
73
+ end
74
74
  mc.parameters = method.parameters.map.with_index do |method_param, idx|
75
75
  param_type, param_name = method_param
76
76
  param_name ||= 'arg'
@@ -88,28 +88,37 @@ module AppMap
88
88
  object_id: receiver.__id__,
89
89
  value: display_string(receiver)
90
90
  }
91
- MethodEvent.build_from_invocation(mc, :call, defined_class, method)
91
+ mc.static = static
92
+ MethodEvent.build_from_invocation(mc, :call)
92
93
  end
93
94
  end
94
95
  end
95
96
 
96
97
  def to_h
97
98
  super.tap do |h|
99
+ h[:defined_class] = defined_class
100
+ h[:method_id] = method_id
101
+ h[:path] = path
102
+ h[:lineno] = lineno
103
+ h[:static] = static
98
104
  h[:parameters] = parameters
99
105
  h[:receiver] = receiver
106
+ h.delete_if { |_, v| v.nil? }
100
107
  end
101
108
  end
109
+
110
+ alias static? static
102
111
  end
103
112
 
104
113
  class MethodReturnIgnoreValue < MethodEvent
105
114
  attr_accessor :parent_id, :elapsed
106
115
 
107
116
  class << self
108
- def build_from_invocation(mr = MethodReturnIgnoreValue.new, defined_class, method, parent_id, elapsed)
117
+ def build_from_invocation(mr = MethodReturnIgnoreValue.new, parent_id, elapsed)
109
118
  mr.tap do |_|
110
119
  mr.parent_id = parent_id
111
120
  mr.elapsed = elapsed
112
- MethodEvent.build_from_invocation(mr, :return, defined_class, method)
121
+ MethodEvent.build_from_invocation(mr, :return)
113
122
  end
114
123
  end
115
124
  end
@@ -126,7 +135,7 @@ module AppMap
126
135
  attr_accessor :return_value, :exceptions
127
136
 
128
137
  class << self
129
- def build_from_invocation(mr = MethodReturn.new, defined_class, method, parent_id, elapsed, return_value, exception)
138
+ def build_from_invocation(mr = MethodReturn.new, parent_id, elapsed, return_value, exception)
130
139
  mr.tap do |_|
131
140
  if return_value
132
141
  mr.return_value = {
@@ -152,7 +161,7 @@ module AppMap
152
161
 
153
162
  mr.exceptions = exceptions
154
163
  end
155
- MethodReturnIgnoreValue.build_from_invocation(mr, defined_class, method, parent_id, elapsed)
164
+ MethodReturnIgnoreValue.build_from_invocation(mr, parent_id, elapsed)
156
165
  end
157
166
  end
158
167
  end
@@ -4,152 +4,111 @@ require 'English'
4
4
 
5
5
  module AppMap
6
6
  class Hook
7
- LOG = false
8
-
9
- Package = Struct.new(:path, :exclude) do
10
- def to_h
11
- {
12
- path: path,
13
- exclude: exclude.blank? ? nil : exclude
14
- }.compact
15
- end
16
- end
7
+ LOG = (ENV['DEBUG'] == 'true')
17
8
 
18
- Config = Struct.new(:name, :packages) do
19
- class << self
20
- # Loads configuration data from a file, specified by the file name.
21
- def load_from_file(config_file_name)
22
- require 'yaml'
23
- load YAML.safe_load(::File.read(config_file_name))
24
- end
9
+ class << self
10
+ def lock_builtins
11
+ return if @builtins_hooked
25
12
 
26
- # Loads configuration from a Hash.
27
- def load(config_data)
28
- packages = (config_data['packages'] || []).map do |package|
29
- Package.new(package['path'], package['exclude'] || [])
30
- end
31
- Config.new config_data['name'], packages
32
- end
13
+ @builtins_hooked = true
33
14
  end
34
15
 
35
- def initialize(name, packages = [])
36
- super name, packages || []
16
+ # Return the class, separator ('.' or '#'), and method name for
17
+ # the given method.
18
+ def qualify_method_name(method)
19
+ if method.owner.singleton_class?
20
+ # Singleton class names can take two forms:
21
+ # #<Class:Foo> or
22
+ # #<Class:#<Bar:0x0123ABC>>. Retrieve the name of
23
+ # the class from the string.
24
+ #
25
+ # (There really isn't a better way to do this. The
26
+ # singleton's reference to the class it was created
27
+ # from is stored in an instance variable named
28
+ # '__attached__'. It doesn't have the '@' prefix, so
29
+ # it's internal only, and not accessible from user
30
+ # code.)
31
+ class_name = /#<Class:((#<(?<cls>.*?):)|((?<cls>.*?)>))/.match(method.owner.to_s)['cls']
32
+ [ class_name, '.', method.name ]
33
+ else
34
+ [ method.owner.name, '#', method.name ]
35
+ end
37
36
  end
37
+ end
38
38
 
39
- def to_h
40
- {
41
- name: name,
42
- packages: packages.map(&:to_h)
43
- }
44
- end
39
+ attr_reader :config
40
+ def initialize(config)
41
+ @config = config
45
42
  end
46
43
 
47
- HOOK_DISABLE_KEY = 'AppMap::Hook.disable'
44
+ # Observe class loading and hook all methods which match the config.
45
+ def enable &block
46
+ require 'appmap/hook/method'
48
47
 
49
- class << self
50
- # Observe class loading and hook all methods which match the config.
51
- def hook(config = AppMap.configure)
52
- package_include_paths = config.packages.map(&:path)
53
- package_exclude_paths = config.packages.map do |pkg|
54
- pkg.exclude.map do |exclude|
55
- File.join(pkg.path, exclude)
56
- end
57
- end.flatten
48
+ hook_builtins
58
49
 
59
- before_hook = lambda do |defined_class, method, receiver, args|
60
- require 'appmap/event'
61
- call_event = AppMap::Event::MethodCall.build_from_invocation(defined_class, method, receiver, args)
62
- AppMap.tracing.record_event call_event, defined_class: defined_class, method: method
63
- [ call_event, Time.now ]
64
- end
50
+ tp = TracePoint.new(:end) do |trace_point|
51
+ cls = trace_point.self
65
52
 
66
- after_hook = lambda do |call_event, defined_class, method, start_time, return_value, exception|
67
- require 'appmap/event'
68
- elapsed = Time.now - start_time
69
- return_event = AppMap::Event::MethodReturn.build_from_invocation \
70
- defined_class, method, call_event.id, elapsed, return_value, exception
71
- AppMap.tracing.record_event return_event
72
- end
53
+ instance_methods = cls.public_instance_methods(false)
54
+ class_methods = cls.singleton_class.public_instance_methods(false) - instance_methods
55
+
56
+ hook = lambda do |hook_cls|
57
+ lambda do |method_id|
58
+ method = hook_cls.public_instance_method(method_id)
59
+ hook_method = Hook::Method.new(hook_cls, method)
73
60
 
74
- with_disabled_hook = lambda do |&fn|
75
- # Don't record functions, such as to_s and inspect, that might be called
76
- # by the fn. Otherwise there can be a stack oveflow.
77
- Thread.current[HOOK_DISABLE_KEY] = true
78
- begin
79
- fn.call
80
- ensure
81
- Thread.current[HOOK_DISABLE_KEY] = false
61
+ warn "AppMap: Examining #{hook_method.method_display_name}" if LOG
62
+
63
+ disasm = RubyVM::InstructionSequence.disasm(method)
64
+ # Skip methods that have no instruction sequence, as they are obviously trivial.
65
+ next unless disasm
66
+
67
+ # Don't try and trace the AppMap methods or there will be
68
+ # a stack overflow in the defined hook method.
69
+ next if /\AAppMap[:\.]/.match?(hook_method.method_display_name)
70
+
71
+ next unless \
72
+ config.always_hook?(hook_method.defined_class, method.name) ||
73
+ config.included_by_location?(method)
74
+
75
+ hook_method.activate
82
76
  end
83
77
  end
84
78
 
85
- TracePoint.trace(:end) do |tp|
86
- cls = tp.self
87
-
88
- instance_methods = cls.public_instance_methods(false)
89
- class_methods = cls.singleton_class.public_instance_methods(false) - instance_methods
90
-
91
- hook_method = lambda do |cls|
92
- lambda do |method_id|
93
- next if method_id.to_s =~ /_hooked_by_appmap$/
94
-
95
- method = cls.public_instance_method(method_id)
96
- location = method.source_location
97
- location_file, = location
98
- next unless location_file
99
-
100
- location_file = location_file[Dir.pwd.length + 1..-1] if location_file.index(Dir.pwd) == 0
101
- match = package_include_paths.find { |p| location_file.index(p) == 0 }
102
- match &&= !package_exclude_paths.find { |p| location_file.index(p) }
103
- next unless match
104
-
105
- disasm = RubyVM::InstructionSequence.disasm(method)
106
- # Skip methods that have no instruction sequence, as they are obviously trivial.
107
- next unless disasm
108
-
109
- defined_class, method_symbol = \
110
- if method.owner.singleton_class?
111
- # Singleton class name is like: #<Class:<(.*)>>
112
- class_name = method.owner.to_s['#<Class:<'.length-1..-2]
113
- [ class_name, '.' ]
114
- else
115
- [ method.owner.name, '#' ]
116
- end
117
-
118
- method_display_name = "#{defined_class}#{method_symbol}#{method.name}"
119
- # Don't try and trace the tracing method or there will be a stack overflow
120
- # in the defined hook method.
121
- next if method_display_name == "AppMap.tracing"
122
-
123
- warn "AppMap: Hooking #{method_display_name}" if LOG
124
-
125
- cls.define_method method_id do |*args, &block|
126
- base_method = method.bind(self).to_proc
127
-
128
- hook_disabled = Thread.current[HOOK_DISABLE_KEY]
129
- enabled = true if !hook_disabled && AppMap.tracing.enabled?
130
- return base_method.call(*args, &block) unless enabled
131
-
132
- call_event, start_time = with_disabled_hook.call do
133
- before_hook.call(defined_class, method, self, args)
134
- end
135
- return_value = nil
136
- exception = nil
137
- begin
138
- return_value = base_method.call(*args, &block)
139
- rescue
140
- exception = $ERROR_INFO
141
- raise
142
- ensure
143
- with_disabled_hook.call do
144
- after_hook.call(call_event, defined_class, method, start_time, return_value, exception)
145
- end
146
- end
147
- end
79
+ instance_methods.each(&hook.(cls))
80
+ class_methods.each(&hook.(cls.singleton_class))
81
+ end
82
+
83
+ tp.enable(&block)
84
+ end
85
+
86
+ def hook_builtins
87
+ return unless self.class.lock_builtins
88
+
89
+ class_from_string = lambda do |fq_class|
90
+ fq_class.split('::').inject(Object) do |mod, class_name|
91
+ mod.const_get(class_name)
92
+ end
93
+ end
94
+
95
+ Config::BUILTIN_METHODS.each do |class_name, hook|
96
+ require hook.package.package_name if hook.package.package_name
97
+ Array(hook.method_names).each do |method_name|
98
+ method_name = method_name.to_sym
99
+ cls = class_from_string.(class_name)
100
+ method = \
101
+ begin
102
+ cls.instance_method(method_name)
103
+ rescue NameError
104
+ cls.method(method_name) rescue nil
148
105
  end
149
- end
150
106
 
151
- instance_methods.each(&hook_method.call(cls))
152
- class_methods.each(&hook_method.call(cls.singleton_class))
107
+ if method
108
+ Hook::Method.new(cls, method).activate
109
+ else
110
+ warn "Method #{method_name} not found on #{cls.name}"
111
+ end
153
112
  end
154
113
  end
155
114
  end
@@ -0,0 +1,78 @@
1
+ module AppMap
2
+ class Hook
3
+ class Method
4
+ attr_reader :hook_class, :hook_method, :defined_class, :method_display_name
5
+
6
+ HOOK_DISABLE_KEY = 'AppMap::Hook.disable'
7
+ private_constant :HOOK_DISABLE_KEY
8
+
9
+ def initialize(hook_class, hook_method)
10
+ @hook_class = hook_class
11
+ @hook_method = hook_method
12
+ @defined_class, method_symbol = Hook.qualify_method_name(@hook_method)
13
+ @method_display_name = [@defined_class, method_symbol, @hook_method.name].join
14
+ end
15
+
16
+ def activate
17
+ warn "AppMap: Hooking #{method_display_name}" if Hook::LOG
18
+
19
+ hook_method = self.hook_method
20
+ before_hook = self.method(:before_hook)
21
+ after_hook = self.method(:after_hook)
22
+ with_disabled_hook = self.method(:with_disabled_hook)
23
+
24
+ hook_class.define_method hook_method.name do |*args, &block|
25
+ instance_method = hook_method.bind(self).to_proc
26
+
27
+ hook_disabled = Thread.current[HOOK_DISABLE_KEY]
28
+ enabled = true if !hook_disabled && AppMap.tracing.enabled?
29
+ return instance_method.call(*args, &block) unless enabled
30
+
31
+ call_event, start_time = with_disabled_hook.() do
32
+ before_hook.(self, args)
33
+ end
34
+ return_value = nil
35
+ exception = nil
36
+ begin
37
+ return_value = instance_method.(*args, &block)
38
+ rescue
39
+ exception = $ERROR_INFO
40
+ raise
41
+ ensure
42
+ with_disabled_hook.() do
43
+ after_hook.(call_event, start_time, return_value, exception)
44
+ end
45
+ end
46
+ end
47
+ end
48
+
49
+ protected
50
+
51
+ def before_hook(receiver, args)
52
+ require 'appmap/event'
53
+ call_event = AppMap::Event::MethodCall.build_from_invocation(defined_class, hook_method, receiver, args)
54
+ AppMap.tracing.record_event call_event, defined_class: defined_class, method: hook_method
55
+ [ call_event, Time.now ]
56
+ end
57
+
58
+ def after_hook(call_event, start_time, return_value, exception)
59
+ require 'appmap/event'
60
+ elapsed = Time.now - start_time
61
+ return_event = \
62
+ AppMap::Event::MethodReturn.build_from_invocation call_event.id, elapsed, return_value, exception
63
+ AppMap.tracing.record_event return_event
64
+ end
65
+
66
+ def with_disabled_hook(&fn)
67
+ # Don't record functions, such as to_s and inspect, that might be called
68
+ # by the fn. Otherwise there can be a stack overflow.
69
+ Thread.current[HOOK_DISABLE_KEY] = true
70
+ begin
71
+ fn.call
72
+ ensure
73
+ Thread.current[HOOK_DISABLE_KEY] = false
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
@@ -17,7 +17,7 @@ module AppMap
17
17
  version: AppMap::VERSION
18
18
  }
19
19
  }.tap do |m|
20
- if defined?(::Rails)
20
+ if defined?(::Rails) && defined?(::Rails.version)
21
21
  m[:frameworks] ||= []
22
22
  m[:frameworks] << {
23
23
  name: 'rails',
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'appmap/util'
4
+
5
+ module AppMap
6
+ # Integration of AppMap with Minitest. When enabled with APPMAP=true, the AppMap tracer will
7
+ # be activated around each test.
8
+ module Minitest
9
+ APPMAP_OUTPUT_DIR = 'tmp/appmap/minitest'
10
+ LOG = false
11
+
12
+ def self.metadata
13
+ AppMap.detect_metadata
14
+ end
15
+
16
+ Recording = Struct.new(:test) do
17
+ def initialize(test)
18
+ super
19
+
20
+ warn "Starting recording of test #{test.class}.#{test.name}" if AppMap::Minitest::LOG
21
+ @trace = AppMap.tracing.trace
22
+ end
23
+
24
+ def finish
25
+ warn "Finishing recording of test #{test.class}.#{test.name}" if AppMap::Minitest::LOG
26
+
27
+ events = []
28
+ AppMap.tracing.delete @trace
29
+
30
+ events << @trace.next_event.to_h while @trace.event?
31
+
32
+ AppMap::Minitest.add_event_methods @trace.event_methods
33
+
34
+ class_map = AppMap.class_map(@trace.event_methods)
35
+
36
+ feature_group = test.class.name.underscore.split('_')[0...-1].join('_').capitalize
37
+ feature_name = test.name.split('_')[1..-1].join(' ')
38
+ scenario_name = [ feature_group, feature_name ].join(' ')
39
+
40
+ AppMap::Minitest.save scenario_name,
41
+ class_map,
42
+ events: events,
43
+ feature_name: feature_name,
44
+ feature_group_name: feature_group
45
+ end
46
+ end
47
+
48
+ @recordings_by_test = {}
49
+ @event_methods = Set.new
50
+
51
+ class << self
52
+ def init
53
+ warn 'Configuring AppMap recorder for Minitest'
54
+
55
+ FileUtils.mkdir_p APPMAP_OUTPUT_DIR
56
+ end
57
+
58
+ def begin_test(test)
59
+ @recordings_by_test[test.object_id] = Recording.new(test)
60
+ end
61
+
62
+ def end_test(test)
63
+ recording = @recordings_by_test.delete(test.object_id)
64
+ return warn "No recording found for #{test}" unless recording
65
+
66
+ recording.finish
67
+ end
68
+
69
+ def config
70
+ @config or raise "AppMap is not configured"
71
+ end
72
+
73
+ def add_event_methods(event_methods)
74
+ @event_methods += event_methods
75
+ end
76
+
77
+ def save(example_name, class_map, events: nil, feature_name: nil, feature_group_name: nil, labels: nil)
78
+ metadata = AppMap::Minitest.metadata.tap do |m|
79
+ m[:name] = example_name
80
+ m[:app] = AppMap.configuration.name
81
+ m[:feature] = feature_name if feature_name
82
+ m[:feature_group] = feature_group_name if feature_group_name
83
+ m[:frameworks] ||= []
84
+ m[:frameworks] << {
85
+ name: 'minitest',
86
+ version: Gem.loaded_specs['minitest']&.version&.to_s
87
+ }
88
+ m[:recorder] = {
89
+ name: 'minitest'
90
+ }
91
+ end
92
+
93
+ appmap = {
94
+ version: AppMap::APPMAP_FORMAT_VERSION,
95
+ metadata: metadata,
96
+ classMap: class_map,
97
+ events: events
98
+ }.compact
99
+ fname = AppMap::Util.scenario_filename(example_name)
100
+
101
+ File.write(File.join(APPMAP_OUTPUT_DIR, fname), JSON.generate(appmap))
102
+ end
103
+
104
+ def print_inventory
105
+ class_map = AppMap.class_map(@event_methods)
106
+ save 'Inventory', class_map, labels: %w[inventory]
107
+ end
108
+
109
+ def enabled?
110
+ ENV['APPMAP'] == 'true'
111
+ end
112
+
113
+ def run
114
+ init
115
+ at_exit do
116
+ print_inventory
117
+ end
118
+ end
119
+ end
120
+ end
121
+ end
122
+
123
+ if AppMap::Minitest.enabled?
124
+ require 'appmap'
125
+ require 'minitest/test'
126
+
127
+ class ::Minitest::Test
128
+ alias run_without_hook run
129
+
130
+ def run
131
+ AppMap::Minitest.begin_test self
132
+ begin
133
+ run_without_hook
134
+ ensure
135
+ AppMap::Minitest.end_test self
136
+ end
137
+ end
138
+ end
139
+
140
+ AppMap::Minitest.run
141
+ end