├── .github ├── dependabot.yml ├── release.yaml └── workflows │ └── main.yml ├── .gitignore ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── Gemfile ├── Gemfile.lock ├── LICENSE.txt ├── README.md ├── Rakefile ├── bin ├── console └── setup ├── lib ├── tracer.rb └── tracer │ ├── base.rb │ ├── call_tracer.rb │ ├── color.rb │ ├── exception_tracer.rb │ ├── helper.rb │ ├── irb.rb │ ├── ivar_tracer.rb │ ├── line_tracer.rb │ ├── object_tracer.rb │ └── version.rb ├── test ├── lib │ ├── envutil.rb │ └── find_executable.rb ├── test_helper.rb └── tracer │ ├── call_tracer_test.rb │ ├── exception_tracer_test.rb │ ├── helper_test.rb │ ├── ivar_tracer_test.rb │ ├── line_tracer_test.rb │ └── object_tracer_test.rb └── tracer.gemspec /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | -------------------------------------------------------------------------------- /.github/release.yaml: -------------------------------------------------------------------------------- 1 | # release.yml 2 | 3 | changelog: 4 | categories: 5 | - title: ✨ Enhancements 6 | labels: 7 | - enhancement 8 | - title: 🐛 Bug Fixes 9 | labels: 10 | - bug 11 | - title: 🛠 Other Changes 12 | labels: 13 | - "*" 14 | 15 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Ruby 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - master 10 | 11 | jobs: 12 | ruby-versions: 13 | uses: ruby/actions/.github/workflows/ruby_versions.yml@master 14 | with: 15 | engine: cruby 16 | min_version: 2.7 17 | 18 | build: 19 | needs: ruby-versions 20 | name: ${{ matrix.os }} ${{ matrix.ruby }} 21 | runs-on: ${{ matrix.os }} 22 | strategy: 23 | fail-fast: false 24 | matrix: 25 | ruby: ${{ fromJson(needs.ruby-versions.outputs.versions) }} 26 | os: 27 | - ubuntu-latest 28 | 29 | steps: 30 | - uses: actions/checkout@v4 31 | - name: Set up Ruby 32 | uses: ruby/setup-ruby@v1 33 | with: 34 | ruby-version: ${{ matrix.ruby }} 35 | bundler-cache: true 36 | rubygems: latest 37 | - name: Run the default task 38 | run: bundle exec rake 39 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /_yardoc/ 4 | /coverage/ 5 | /doc/ 6 | /pkg/ 7 | /spec/reports/ 8 | /tmp/ 9 | 10 | *.gem 11 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [Unreleased] 2 | 3 | ## [0.1.0] - 2023-02-07 4 | 5 | - Initial release 6 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. 8 | 9 | ## Our Standards 10 | 11 | Examples of behavior that contributes to a positive environment for our community include: 12 | 13 | * Demonstrating empathy and kindness toward other people 14 | * Being respectful of differing opinions, viewpoints, and experiences 15 | * Giving and gracefully accepting constructive feedback 16 | * Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience 17 | * Focusing on what is best not just for us as individuals, but for the overall community 18 | 19 | Examples of unacceptable behavior include: 20 | 21 | * The use of sexualized language or imagery, and sexual attention or 22 | advances of any kind 23 | * Trolling, insulting or derogatory comments, and personal or political attacks 24 | * Public or private harassment 25 | * Publishing others' private information, such as a physical or email 26 | address, without their explicit permission 27 | * Other conduct which could reasonably be considered inappropriate in a 28 | professional setting 29 | 30 | ## Enforcement Responsibilities 31 | 32 | Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. 33 | 34 | Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. 35 | 36 | ## Scope 37 | 38 | This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. 39 | 40 | ## Enforcement 41 | 42 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at stan.lo@shopify.com. All complaints will be reviewed and investigated promptly and fairly. 43 | 44 | All community leaders are obligated to respect the privacy and security of the reporter of any incident. 45 | 46 | ## Enforcement Guidelines 47 | 48 | Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: 49 | 50 | ### 1. Correction 51 | 52 | **Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. 53 | 54 | **Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. 55 | 56 | ### 2. Warning 57 | 58 | **Community Impact**: A violation through a single incident or series of actions. 59 | 60 | **Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. 61 | 62 | ### 3. Temporary Ban 63 | 64 | **Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. 65 | 66 | **Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. 67 | 68 | ### 4. Permanent Ban 69 | 70 | **Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. 71 | 72 | **Consequence**: A permanent ban from any sort of public interaction within the community. 73 | 74 | ## Attribution 75 | 76 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.0, 77 | available at https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 78 | 79 | Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity). 80 | 81 | [homepage]: https://www.contributor-covenant.org 82 | 83 | For answers to common questions about this code of conduct, see the FAQ at 84 | https://www.contributor-covenant.org/faq. Translations are available at https://www.contributor-covenant.org/translations. 85 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | # Specify your gem's dependencies in ruby-tracer.gemspec 6 | gemspec 7 | 8 | gem "rake", "~> 13.0" 9 | gem "irb" 10 | 11 | gem "test-unit", "~> 3.0" 12 | gem "syntax_tree" 13 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | tracer (0.2.3) 5 | 6 | GEM 7 | remote: https://rubygems.org/ 8 | specs: 9 | io-console (0.7.2) 10 | irb (1.12.0) 11 | rdoc 12 | reline (>= 0.4.2) 13 | power_assert (2.0.3) 14 | prettier_print (1.2.1) 15 | psych (5.1.2) 16 | stringio 17 | rake (13.1.0) 18 | rdoc (6.6.3.1) 19 | psych (>= 4.0.0) 20 | reline (0.4.3) 21 | io-console (~> 0.5) 22 | stringio (3.1.0) 23 | syntax_tree (6.2.0) 24 | prettier_print (>= 1.2.0) 25 | test-unit (3.6.2) 26 | power_assert 27 | 28 | PLATFORMS 29 | arm64-darwin-21 30 | arm64-darwin-22 31 | arm64-darwin-23 32 | x86_64-linux 33 | 34 | DEPENDENCIES 35 | irb 36 | rake (~> 13.0) 37 | syntax_tree 38 | test-unit (~> 3.0) 39 | tracer! 40 | 41 | BUNDLED WITH 42 | 2.4.2 43 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (C) 1993-2013 Yukihiro Matsumoto. All rights reserved. 2 | 3 | Redistribution and use in source and binary forms, with or without 4 | modification, are permitted provided that the following conditions 5 | are met: 6 | 1. Redistributions of source code must retain the above copyright 7 | notice, this list of conditions and the following disclaimer. 8 | 2. Redistributions in binary form must reproduce the above copyright 9 | notice, this list of conditions and the following disclaimer in the 10 | documentation and/or other materials provided with the distribution. 11 | 12 | THIS SOFTWARE IS PROVIDED BY THE AUTHOR AND CONTRIBUTORS ``AS IS'' AND 13 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 14 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 15 | ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS BE LIABLE 16 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 17 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS 18 | OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) 19 | HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT 20 | LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY 21 | OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF 22 | SUCH DAMAGE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Tracer 2 | 3 | [![Ruby](https://github.com/ruby/tracer/actions/workflows/main.yml/badge.svg)](https://github.com/ruby/tracer/actions/workflows/main.yml) 4 | [![Gem Version](https://badge.fury.io/rb/tracer.svg)](https://badge.fury.io/rb/tracer) 5 | 6 | The `tracer` gem provides helpful tracing utilities to help users observe their program's runtime behaviour. 7 | 8 | The currently supported tracers are: 9 | 10 | - [`ObjectTracer`](#objecttracer) 11 | - [`IvarTracer`](#ivartracer) 12 | - [`CallTracer`](#calltracer) 13 | - [`ExceptionTracer`](#exceptiontracer) 14 | - [`LineTracer`](#linetracer) 15 | 16 | It also comes with experimental [IRB integration](#irb-integration) to allow quick access from REPL. 17 | 18 | ## Installation 19 | 20 | ```shell 21 | $ bundle add tracer --group=development,test 22 | ``` 23 | 24 | Or directly add it to your `Gemfile` 25 | 26 | ```rb 27 | group :development, :test do 28 | gem "tracer" 29 | end 30 | ``` 31 | 32 | If bundler is not being used to manage dependencies, install the gem by executing: 33 | 34 | ```shell 35 | $ gem install tracer 36 | ``` 37 | 38 | ## Usage 39 | 40 | ```rb 41 | Tracer.trace(object) { ... } # trace object's activities in the given block 42 | Tracer.trace_call { ... } # trace method calls in the given block 43 | Tracer.trace_exception { ... } # trace exceptions in the given block 44 | ``` 45 | 46 | **Example** 47 | 48 | ```rb 49 | require "tracer" 50 | 51 | obj = Object.new 52 | 53 | def obj.foo 54 | 100 55 | end 56 | 57 | def bar(obj) 58 | obj.foo 59 | end 60 | 61 | Tracer.trace(obj) { bar(obj) } 62 | #depth:1 # is used as a parameter obj of Object#bar at test.rb:13:in `block in
' 63 | #depth:2 # receives .foo at test.rb:10:in `bar' 64 | ``` 65 | 66 | ### `tracer/helper` 67 | 68 | If you want to avoid the `Tracer` namespace, you can do `require "tracer/helper"` instead: 69 | 70 | ```rb 71 | require "tracer/helper" 72 | 73 | trace(object) { ... } # trace object's activities in the given block 74 | trace_call { ... } # trace method calls in the given block 75 | trace_exception { ... } # trace exceptions in the given block 76 | ``` 77 | 78 | ### Tracer Classes 79 | 80 | If you want to have more control over individual traces, you can use individual tracer classes: 81 | 82 | #### ObjectTracer 83 | 84 | ```rb 85 | class User 86 | def initialize(name) = (@name = name) 87 | 88 | def name() = @name 89 | end 90 | 91 | def authorized?(user) 92 | user.name == "John" 93 | end 94 | 95 | user = User.new("John") 96 | tracer = ObjectTracer.new(user) 97 | tracer.start do 98 | user.name 99 | authorized?(user) 100 | end 101 | 102 | #depth:3 # receives #name (User#name) at test.rb:14:in `block in
' 103 | #depth:3 # is used as a parameter user of Object#authorized? at test.rb:15:in `block in
' 104 | #depth:4 # receives #name (User#name) at test.rb:8:in `authorized?' 105 | ``` 106 | 107 | #### IvarTracer 108 | 109 | > [!Note] 110 | > Ruby 3.0 and below's accessor calls don't trigger TracePoint properly so the result may be inaccurate with those versions. 111 | 112 | ```rb 113 | require "tracer" 114 | 115 | class Cat 116 | attr_accessor :name 117 | end 118 | 119 | cat = Cat.new 120 | 121 | tracer = IvarTracer.new(cat, :@name) 122 | tracer.start do 123 | cat.name = "Kitty" 124 | cat.instance_variable_set(:@name, "Ketty") 125 | end 126 | 127 | #depth:3 Cat#name= sets @name = "Kitty" at test.rb:11 128 | #depth:3 Kernel#instance_variable_set sets @name = "Ketty" at test.rb:12 129 | ``` 130 | 131 | #### ExceptionTracer 132 | 133 | ```rb 134 | ExceptionTracer.new.start 135 | 136 | begin 137 | raise "boom" 138 | rescue StandardError 139 | nil 140 | end 141 | 142 | #depth:0 # raised at test.rb:4 143 | #depth:1 # rescued at test.rb:6 144 | ``` 145 | 146 | #### CallTracer 147 | 148 | ```rb 149 | class User 150 | def initialize(name) = (@name = name) 151 | 152 | def name() = @name 153 | end 154 | 155 | def authorized?(user) 156 | user.name == "John" 157 | end 158 | 159 | user = User.new("John") 160 | tracer = CallTracer.new 161 | tracer.start do 162 | user.name 163 | authorized?(user) 164 | end 165 | 166 | #depth:4 > block at test.rb:13 167 | #depth:5 > User#name at test.rb:4 168 | #depth:5 < User#name #=> "John" at test.rb:4 169 | #depth:5 > Object#authorized? at test.rb:7 170 | #depth:6 > User#name at test.rb:4 171 | #depth:6 < User#name #=> "John" at test.rb:4 172 | #depth:6 > String#== at test.rb:8 173 | #depth:6 < String#== #=> true at test.rb:8 174 | #depth:5 < Object#authorized? #=> true at test.rb:9 175 | #depth:4 < block #=> true at test.rb:16 176 | ``` 177 | 178 | #### LineTracer 179 | 180 | ```rb 181 | class User 182 | def initialize(name) = (@name = name) 183 | 184 | def name() = @name 185 | end 186 | 187 | def authorized?(user) 188 | user.name == "John" 189 | end 190 | 191 | user = User.new("John") 192 | tracer = LineTracer.new 193 | tracer.start do 194 | user.name 195 | authorized?(user) 196 | end 197 | 198 | #depth:4 at test.rb:14 199 | #depth:4 at test.rb:15 200 | #depth:5 at test.rb:8 201 | ``` 202 | 203 | ### IRB-integration 204 | 205 | Once required, `tracer` registers a few IRB commands to help you trace Ruby expressions: 206 | 207 | ``` 208 | trace Trace the target object (or self) in the given expression. Usage: `trace [target,] ` 209 | trace_call Trace method calls in the given expression. Usage: `trace_call ` 210 | trace_exception Trace exceptions in the given expression. Usage: `trace_exception ` 211 | ``` 212 | 213 | **Example** 214 | 215 | ```rb 216 | # test.rb 217 | require "tracer" 218 | 219 | obj = Object.new 220 | 221 | def obj.foo 222 | 100 223 | end 224 | 225 | def bar(obj) 226 | obj.foo 227 | end 228 | 229 | binding.irb 230 | ``` 231 | 232 | ```shell 233 | irb(main):001:0> trace obj, bar(obj) 234 | #depth:23 # is used as a parameter obj of Object#bar at (eval):1:in `
' 235 | #depth:24 # receives .foo at test.rb:10:in `bar' 236 | => 100 237 | irb(main):002:0> trace_call bar(obj) 238 | #depth:23> Object#bar at (eval):1:in `
' 239 | #depth:24> #.foo at test.rb:10:in `bar' 240 | #depth:24< #.foo #=> 100 at test.rb:10:in `bar' 241 | #depth:23< Object#bar #=> 100 at (eval):1:in `
' 242 | => 100 243 | ``` 244 | 245 | ## Customization 246 | 247 | TBD 248 | 249 | ## Acknowledgements 250 | 251 | A big shout-out to [@ko1](https://github.com/ko1) (Koichi Sasada) for his awesome work on [`ruby/debug`](https://github.com/ruby/debug). 252 | The [tracers in `ruby/debug`](https://github.com/ruby/debug/blob/master/lib/debug/tracer.rb) were an inspiration and laid the groundwork for this project. 253 | 254 | ## Development 255 | 256 | After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test-unit` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. 257 | 258 | To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org). 259 | 260 | ## Contributing 261 | 262 | Bug reports and pull requests are welcome on GitHub at https://github.com/ruby/tracer. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/ruby/tracer/blob/master/CODE_OF_CONDUCT.md). 263 | 264 | ## License 265 | 266 | The gem is available as open source under the terms of the [2-Clause BSD License](https://opensource.org/licenses/BSD-2-Clause). 267 | 268 | ## Code of Conduct 269 | 270 | Everyone interacting in the Ruby::Tracer project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/ruby/tracer/blob/master/CODE_OF_CONDUCT.md). 271 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bundler/gem_tasks" 4 | require "rake/testtask" 5 | 6 | Rake::TestTask.new(:test) do |t| 7 | t.libs << "test" 8 | t.libs << "lib" 9 | t.test_files = FileList["test/**/*_test.rb"] 10 | end 11 | 12 | task default: :test 13 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require "bundler/setup" 5 | require "tracer" 6 | 7 | # You can add fixtures and/or initialization code here to make experimenting 8 | # with your gem easier. You can also use a different console, if you like. 9 | 10 | # (If you use this, don't forget to add pry to your Gemfile!) 11 | # require "pry" 12 | # Pry.start 13 | 14 | require "irb" 15 | IRB.start(__FILE__) 16 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /lib/tracer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "tracer/version" 4 | require_relative "tracer/line_tracer" 5 | require_relative "tracer/call_tracer" 6 | require_relative "tracer/exception_tracer" 7 | require_relative "tracer/object_tracer" 8 | require_relative "tracer/ivar_tracer" 9 | 10 | module Tracer 11 | module Helper 12 | DEPTH_OFFSET = 3 13 | 14 | def trace_exception(&block) 15 | tracer = ExceptionTracer.new(depth_offset: DEPTH_OFFSET) 16 | tracer.start(&block) 17 | end 18 | 19 | def trace_call(&block) 20 | tracer = CallTracer.new(depth_offset: DEPTH_OFFSET) 21 | tracer.start(&block) 22 | end 23 | 24 | def trace(target, &block) 25 | tracer = ObjectTracer.new(target, depth_offset: DEPTH_OFFSET) 26 | tracer.start(&block) 27 | end 28 | end 29 | 30 | extend Helper 31 | end 32 | 33 | require_relative "tracer/irb" 34 | -------------------------------------------------------------------------------- /lib/tracer/base.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "pp" 4 | require_relative "color" 5 | 6 | module Tracer 7 | class Base 8 | DIR = __dir__ 9 | M_OBJECT_ID = Object.instance_method(:object_id) 10 | M_INSPECT = Object.instance_method(:inspect) 11 | M_CLASS = Object.instance_method(:class) 12 | M_IS_A = Object.instance_method(:is_a?) 13 | M_INSTANCE_VARIABLE_GET = Object.instance_method(:instance_variable_get) 14 | HOME = ENV["HOME"] ? (ENV["HOME"] + "/") : nil 15 | 16 | include Color 17 | 18 | class LimitedPP 19 | def self.pp(obj, max) 20 | out = self.new(max) 21 | catch out do 22 | PP.singleline_pp(obj, out) 23 | end 24 | out.buf 25 | end 26 | 27 | attr_reader :buf 28 | 29 | def initialize(max) 30 | @max = max 31 | @cnt = 0 32 | @buf = String.new 33 | end 34 | 35 | def <<(other) 36 | @buf << other 37 | 38 | if @buf.size >= @max 39 | @buf = @buf[0..@max] + "..." 40 | throw self 41 | end 42 | end 43 | end 44 | 45 | def safe_inspect(obj, max_length: 40) 46 | LimitedPP.pp(obj, max_length) 47 | rescue NoMethodError => e 48 | klass, oid = M_CLASS.bind_call(obj), M_OBJECT_ID.bind_call(obj) 49 | if obj == (r = e.receiver) 50 | "#<#{klass.name}#{oid} does not have \#inspect>" 51 | else 52 | rklass, roid = M_CLASS.bind_call(r), M_OBJECT_ID.bind_call(r) 53 | "#<#{klass.name}:#{roid} contains #<#{rklass}:#{roid} and it does not have #inspect>" 54 | end 55 | rescue Exception => e 56 | "<#inspect raises #{e.inspect}>" 57 | end 58 | 59 | def pretty_path(path) 60 | return "#" unless path 61 | 62 | case 63 | when path.start_with?(dir = RbConfig::CONFIG["rubylibdir"] + "/") 64 | path.sub(dir, "$(rubylibdir)/") 65 | when Gem.path.any? { |gp| path.start_with?(dir = gp + "/gems/") } 66 | path.sub(dir, "$(Gem)/") 67 | when HOME && path.start_with?(HOME) 68 | path.sub(HOME, "~/") 69 | else 70 | path 71 | end 72 | end 73 | 74 | attr_reader :header 75 | 76 | def initialize( 77 | output: STDOUT, 78 | pattern: nil, 79 | colorize: nil, 80 | depth_offset: 0, 81 | header: nil 82 | ) 83 | @name = self.class.name 84 | @type = @name.sub(/Tracer\z/, "") 85 | @output = output 86 | @depth_offset = depth_offset 87 | @colorize = colorize || colorizable? 88 | @header = header 89 | 90 | if pattern 91 | @pattern = Regexp.compile(pattern) 92 | else 93 | @pattern = nil 94 | end 95 | 96 | @tp = setup_tp 97 | end 98 | 99 | def key 100 | [@type, @pattern, @into].freeze 101 | end 102 | 103 | def to_s 104 | s = "#{@name} #{description}" 105 | s += " with pattern #{@pattern.inspect}" if @pattern 106 | s 107 | end 108 | 109 | def description 110 | "(#{@tp.enabled? ? "enabled" : "disabled"})" 111 | end 112 | 113 | def start(&block) 114 | puts "PID:#{Process.pid} #{self}" if @output.is_a?(File) 115 | 116 | if block 117 | @tp.enable(&block) 118 | else 119 | @tp.enable 120 | self 121 | end 122 | end 123 | 124 | def stop 125 | @tp.disable 126 | end 127 | 128 | def started? 129 | @tp.enabled? 130 | end 131 | 132 | def stopped? 133 | !started? 134 | end 135 | 136 | def skip?(tp) 137 | skip_internal?(tp) || skip_with_pattern?(tp) 138 | end 139 | 140 | def skip_with_pattern?(tp) 141 | @pattern && !tp.path.match?(@pattern) 142 | end 143 | 144 | def skip_internal?(tp) 145 | tp.path.match?(DIR) 146 | end 147 | 148 | def out(tp, msg = nil, depth: caller.size - 1, location: nil) 149 | location ||= "#{tp.path}:#{tp.lineno}" 150 | if header 151 | str = +"#{header} " 152 | else 153 | str = +"" 154 | end 155 | str << "\#depth:#{"%-2d" % depth}#{msg} at #{colorize("#{location}", [:GREEN])}" 156 | 157 | puts str 158 | end 159 | 160 | def puts(msg) 161 | @output.puts msg 162 | @output.flush 163 | end 164 | 165 | def minfo(tp) 166 | return "block{}" if tp.event == :b_call 167 | 168 | klass = tp.defined_class 169 | 170 | if klass.singleton_class? 171 | "#{tp.self}.#{tp.method_id}" 172 | else 173 | "#{klass}\##{tp.method_id}" 174 | end 175 | end 176 | 177 | def colorizable? 178 | no_color = (nc = ENV["NO_COLOR"]).nil? || nc.empty? 179 | @output.is_a?(IO) && @output.tty? && no_color 180 | end 181 | end 182 | end 183 | -------------------------------------------------------------------------------- /lib/tracer/call_tracer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "base" 4 | 5 | class CallTracer < Tracer::Base 6 | def setup_tp 7 | TracePoint.new(:a_call, :a_return) do |tp| 8 | next if skip?(tp) 9 | 10 | location = caller_locations(2, 1).first.to_s 11 | next if location.match?(DIR) || location.match?(/#{sp}#{call_identifier_str}", 24 | depth: depth - 2 - @depth_offset, 25 | location: location 26 | when :return, :c_return, :b_return 27 | depth += 1 if tp.event == :c_return 28 | sp = " " * depth 29 | return_str = colorize_magenta(safe_inspect(tp.return_value)) 30 | out tp, 31 | "<#{sp}#{call_identifier_str} #=> #{return_str}", 32 | depth: depth - 2 - @depth_offset, 33 | location: location 34 | end 35 | end 36 | end 37 | 38 | def skip_with_pattern?(tp) 39 | super && !tp.method_id&.match?(@pattern) 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /lib/tracer/color.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Tracer 4 | module Color 5 | CLEAR = 0 6 | BOLD = 1 7 | UNDERLINE = 4 8 | REVERSE = 7 9 | RED = 31 10 | GREEN = 32 11 | YELLOW = 33 12 | BLUE = 34 13 | MAGENTA = 35 14 | CYAN = 36 15 | 16 | class << self 17 | def colorize(text, seq) 18 | seq = seq.map { |s| "\e[#{const_get(s)}m" }.join("") 19 | "#{seq}#{text}#{clear}" 20 | end 21 | 22 | def clear 23 | "\e[#{CLEAR}m" 24 | end 25 | end 26 | 27 | def colorize(str, seq, colorize: @colorize) 28 | !colorize ? str : Color.colorize(str, seq) 29 | end 30 | 31 | def colorize_cyan(str) 32 | colorize(str, %i[CYAN BOLD]) 33 | end 34 | 35 | def colorize_blue(str) 36 | colorize(str, %i[BLUE BOLD]) 37 | end 38 | 39 | def colorize_magenta(str) 40 | colorize(str, %i[MAGENTA BOLD]) 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /lib/tracer/exception_tracer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "base" 4 | 5 | class ExceptionTracer < Tracer::Base 6 | def setup_tp 7 | if RUBY_VERSION >= "3.3.0" 8 | TracePoint.new(:raise, :rescue) do |tp| 9 | next if skip?(tp) 10 | 11 | exc = tp.raised_exception 12 | 13 | action = tp.event == :raise ? "raised" : "rescued" 14 | 15 | out tp, 16 | " #{colorize_magenta(exc.inspect)} #{action}", 17 | depth: caller.size - (1 + @depth_offset) 18 | rescue Exception => e 19 | p e 20 | end 21 | else 22 | TracePoint.new(:raise) do |tp| 23 | next if skip?(tp) 24 | 25 | exc = tp.raised_exception 26 | 27 | out tp, 28 | " #{colorize_magenta(exc.inspect)} raised", 29 | depth: caller.size - (1 + @depth_offset) 30 | rescue Exception => e 31 | p e 32 | end 33 | end 34 | end 35 | 36 | def skip_with_pattern?(tp) 37 | super && !tp.raised_exception.inspect.match?(@pattern) 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /lib/tracer/helper.rb: -------------------------------------------------------------------------------- 1 | require "tracer" 2 | 3 | Object.include(Tracer::Helper) 4 | -------------------------------------------------------------------------------- /lib/tracer/irb.rb: -------------------------------------------------------------------------------- 1 | require "irb/cmd/nop" 2 | require "irb" 3 | 4 | if Gem::Version.new(IRB::VERSION) < Gem::Version.new("1.6.0") 5 | warn <<~MSG 6 | Your version of IRB is too old so Tracer cannot register its commands. 7 | Please upgrade IRB by adding `gem "irb", "~> 1.6.0"` to your Gemfile. 8 | MSG 9 | 10 | return 11 | end 12 | 13 | module Tracer 14 | def self.register_irb_commands 15 | ec = IRB::ExtendCommandBundle.instance_variable_get(:@EXTEND_COMMANDS) 16 | 17 | [ 18 | [:trace, :Trace, nil, [:trace, IRB::ExtendCommandBundle::OVERRIDE_ALL]], 19 | [ 20 | :trace_call, 21 | :TraceCall, 22 | nil, 23 | [:trace_call, IRB::ExtendCommandBundle::OVERRIDE_ALL] 24 | ], 25 | [ 26 | :trace_exception, 27 | :TraceException, 28 | nil, 29 | [:trace_exception, IRB::ExtendCommandBundle::OVERRIDE_ALL] 30 | ] 31 | ].each do |ecconfig| 32 | ec.push(ecconfig) 33 | IRB::ExtendCommandBundle.def_extend_command(*ecconfig) 34 | end 35 | end 36 | end 37 | 38 | module IRB 39 | module ExtendCommand 40 | class TraceCommand < Nop 41 | class << self 42 | def transform_args(args) 43 | # Return a string literal as is for backward compatibility 44 | if args.empty? || string_literal?(args) 45 | args 46 | else # Otherwise, consider the input as a String for convenience 47 | args.strip.dump 48 | end 49 | end 50 | end 51 | end 52 | 53 | class Trace < TraceCommand 54 | category "Tracing" 55 | description "Trace the target object (or self) in the given expression. Usage: `trace [target,] `" 56 | 57 | def execute(*args) 58 | if args.empty? 59 | puts "Please provide the expression to trace. Usage: `trace [target,] `" 60 | return 61 | end 62 | 63 | args = args.first.split(/,/, 2) 64 | 65 | case args.size 66 | when 1 67 | target = irb_context.workspace.main 68 | expression = args.first 69 | when 2 70 | target = eval(args.first, irb_context.workspace.binding) 71 | expression = args.last 72 | else 73 | puts "Please provide the expression to trace. Usage: `trace [target,] `" 74 | return 75 | end 76 | 77 | unless expression 78 | puts "Please provide the expression to trace. Usage: `trace [target,] `" 79 | return 80 | end 81 | 82 | b = irb_context.workspace.binding 83 | Tracer.trace(target) { eval(expression, b) } 84 | end 85 | end 86 | 87 | class TraceCall < TraceCommand 88 | category "Tracing" 89 | description "Trace method calls in the given expression. Usage: `trace_call `" 90 | 91 | def execute(*args) 92 | expression = args.first 93 | 94 | unless expression 95 | puts "Please provide the expression to trace. Usage: `trace_call `" 96 | return 97 | end 98 | 99 | b = irb_context.workspace.binding 100 | Tracer.trace_call { eval(expression, b) } 101 | end 102 | end 103 | 104 | class TraceException < TraceCommand 105 | category "Tracing" 106 | description "Trace exceptions in the given expression. Usage: `trace_exception `" 107 | 108 | def execute(*args) 109 | expression = args.first 110 | 111 | unless expression 112 | puts "Please provide the expression to trace. Usage: `trace_exception `" 113 | return 114 | end 115 | 116 | b = irb_context.workspace.binding 117 | Tracer.trace_exception { eval(expression, b) } 118 | end 119 | end 120 | end 121 | end 122 | 123 | Tracer.register_irb_commands 124 | -------------------------------------------------------------------------------- /lib/tracer/ivar_tracer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "base" 4 | 5 | class IvarTracer < Tracer::Base 6 | def initialize(target, var_name, **kw) 7 | @target = target 8 | @var_name = var_name 9 | @original_value = M_INSTANCE_VARIABLE_GET.bind_call(target, var_name) 10 | super(**kw) 11 | end 12 | 13 | def key 14 | [@type, @target, @var_name, @pattern, @into].freeze 15 | end 16 | 17 | def description 18 | "for #{@var_name} of #{@target} #{super}" 19 | end 20 | 21 | def setup_tp 22 | TracePoint.new(:a_return) do |tp| 23 | next if skip?(tp) 24 | 25 | if tp.self == @target && 26 | value = M_INSTANCE_VARIABLE_GET.bind_call(@target, @var_name) 27 | if tp.event == :c_return 28 | location = nil 29 | else 30 | location = caller_locations(2, 1).first.to_s 31 | next if location.match?(DIR) || location.match?(/") 16 | super(**kw) 17 | end 18 | 19 | def key 20 | [@type, @target_id, @pattern, @into].freeze 21 | end 22 | 23 | def description 24 | "for #{@target_label} #{super}" 25 | end 26 | 27 | def colorized_target_label 28 | colorize_magenta(@target_label) 29 | end 30 | 31 | PRIMITIVE_METHOD_SOURCES = [Module, Class, Object, Kernel] 32 | 33 | def setup_tp 34 | TracePoint.new(:a_call) do |tp| 35 | next if skip?(tp) 36 | 37 | if M_OBJECT_ID.bind_call(tp.self) == @target_id 38 | if PRIMITIVE_METHOD_SOURCES.any? { |klass| klass == tp.defined_class } 39 | next 40 | end 41 | 42 | internal_depth = 2 43 | klass = tp.defined_class 44 | method = tp.method_id 45 | method_info = 46 | method_info = 47 | if klass 48 | if klass.singleton_class? 49 | if M_IS_A.bind_call(tp.self, Class) 50 | ".#{method} (#{klass}.#{method})" 51 | else 52 | ".#{method}" 53 | end 54 | else 55 | "##{method} (#{klass}##{method})" 56 | end 57 | else 58 | if method 59 | "##{method} (##{method})" 60 | else 61 | "" 62 | end 63 | end 64 | 65 | out tp, 66 | " #{colorized_target_label} receives #{colorize_blue(method_info)}", 67 | location: caller_locations(internal_depth, 1).first, 68 | depth: caller.size - internal_depth - @depth_offset 69 | elsif !tp.parameters.empty? 70 | b = tp.binding 71 | method_info = colorize_blue(minfo(tp)) 72 | 73 | tp.parameters.each do |type, name| 74 | next unless name 75 | 76 | colorized_name = colorize_cyan(name) 77 | 78 | case type 79 | when :req, :opt, :key, :keyreq 80 | if M_OBJECT_ID.bind_call(b.local_variable_get(name)) == @target_id 81 | internal_depth = 4 82 | out tp, 83 | " #{colorized_target_label} is used as a parameter #{colorized_name} of #{method_info}", 84 | location: caller_locations(internal_depth, 1).first, 85 | depth: caller.size - internal_depth - @depth_offset 86 | end 87 | when :rest 88 | next if name == :"*" 89 | 90 | internal_depth = 6 91 | ary = b.local_variable_get(name) 92 | ary.each do |e| 93 | if M_OBJECT_ID.bind_call(e) == @target_id 94 | out tp, 95 | " #{colorized_target_label} is used as a parameter in #{colorized_name} of #{method_info}", 96 | location: caller_locations(internal_depth, 1).first, 97 | depth: caller.size - internal_depth - @depth_offset 98 | end 99 | end 100 | when :keyrest 101 | next if name == :"**" 102 | internal_depth = 6 103 | h = b.local_variable_get(name) 104 | h.each do |k, e| 105 | if M_OBJECT_ID.bind_call(e) == @target_id 106 | out tp, 107 | " #{colorized_target_label} is used as a parameter in #{colorized_name} of #{method_info}", 108 | location: caller_locations(internal_depth, 1).first, 109 | depth: caller.size - internal_depth - @depth_offset 110 | end 111 | end 112 | end 113 | end 114 | end 115 | end 116 | end 117 | end 118 | -------------------------------------------------------------------------------- /lib/tracer/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Tracer 4 | VERSION = "0.2.3" 5 | end 6 | -------------------------------------------------------------------------------- /test/lib/envutil.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "open3" 3 | require "timeout" 4 | require_relative "find_executable" 5 | begin 6 | require "rbconfig" 7 | rescue LoadError 8 | end 9 | begin 10 | require "rbconfig/sizeof" 11 | rescue LoadError 12 | end 13 | 14 | module EnvUtil 15 | def rubybin 16 | if ruby = ENV["RUBY"] 17 | return ruby 18 | end 19 | ruby = "ruby" 20 | exeext = RbConfig::CONFIG["EXEEXT"] 21 | rubyexe = (ruby + exeext if exeext and !exeext.empty?) 22 | 3.times do 23 | if File.exist? ruby and File.executable? ruby and !File.directory? ruby 24 | return File.expand_path(ruby) 25 | end 26 | if rubyexe and File.exist? rubyexe and File.executable? rubyexe 27 | return File.expand_path(rubyexe) 28 | end 29 | ruby = File.join("..", ruby) 30 | end 31 | defined?(RbConfig.ruby) ? RbConfig.ruby : "ruby" 32 | end 33 | module_function :rubybin 34 | 35 | LANG_ENVS = %w[LANG LC_ALL LC_CTYPE] 36 | 37 | DEFAULT_SIGNALS = Signal.list 38 | DEFAULT_SIGNALS.delete("TERM") if /mswin|mingw/ =~ RUBY_PLATFORM 39 | 40 | RUBYLIB = ENV["RUBYLIB"] 41 | 42 | class << self 43 | attr_accessor :timeout_scale 44 | attr_reader :original_internal_encoding, 45 | :original_external_encoding, 46 | :original_verbose, 47 | :original_warning 48 | 49 | def capture_global_values 50 | @original_internal_encoding = Encoding.default_internal 51 | @original_external_encoding = Encoding.default_external 52 | @original_verbose = $VERBOSE 53 | @original_warning = 54 | ( 55 | if defined?(Warning.[]) 56 | %i[deprecated experimental].to_h { |i| [i, Warning[i]] } 57 | else 58 | nil 59 | end 60 | ) 61 | end 62 | end 63 | 64 | def apply_timeout_scale(t) 65 | if scale = EnvUtil.timeout_scale 66 | t * scale 67 | else 68 | t 69 | end 70 | end 71 | module_function :apply_timeout_scale 72 | 73 | def timeout(sec, klass = nil, message = nil, &blk) 74 | return yield(sec) if sec == nil or sec.zero? 75 | sec = apply_timeout_scale(sec) 76 | Timeout.timeout(sec, klass, message, &blk) 77 | end 78 | module_function :timeout 79 | 80 | def terminate(pid, signal = :TERM, pgroup = nil, reprieve = 1) 81 | reprieve = apply_timeout_scale(reprieve) if reprieve 82 | 83 | signals = 84 | Array(signal).select do |sig| 85 | begin 86 | DEFAULT_SIGNALS[sig.to_s] or DEFAULT_SIGNALS[Signal.signame(sig)] 87 | rescue StandardError 88 | false 89 | end 90 | end 91 | signals |= %i[ABRT KILL] 92 | case pgroup 93 | when 0, true 94 | pgroup = -pid 95 | when nil, false 96 | pgroup = pid 97 | end 98 | 99 | lldb = true if /darwin/ =~ RUBY_PLATFORM 100 | 101 | while signal = signals.shift 102 | if lldb and %i[ABRT KILL].include?(signal) 103 | lldb = false 104 | # sudo -n: --non-interactive 105 | # lldb -p: attach 106 | # -o: run command 107 | system( 108 | *%W[ 109 | sudo 110 | -n 111 | lldb 112 | -p 113 | #{pid} 114 | --batch 115 | -o 116 | bt\ all 117 | -o 118 | call\ rb_vmdebug_stack_dump_all_threads() 119 | -o 120 | quit 121 | ] 122 | ) 123 | true 124 | end 125 | 126 | begin 127 | Process.kill signal, pgroup 128 | rescue Errno::EINVAL 129 | next 130 | rescue Errno::ESRCH 131 | break 132 | end 133 | if signals.empty? or !reprieve 134 | Process.wait(pid) 135 | else 136 | begin 137 | Timeout.timeout(reprieve) { Process.wait(pid) } 138 | rescue Timeout::Error 139 | else 140 | break 141 | end 142 | end 143 | end 144 | $? 145 | end 146 | module_function :terminate 147 | 148 | def invoke_ruby( 149 | args, 150 | stdin_data = "", 151 | capture_stdout = false, 152 | capture_stderr = false, 153 | encoding: nil, 154 | timeout: 10, 155 | reprieve: 1, 156 | timeout_error: Timeout::Error, 157 | stdout_filter: nil, 158 | stderr_filter: nil, 159 | ios: nil, 160 | signal: :TERM, 161 | rubybin: EnvUtil.rubybin, 162 | precommand: nil, 163 | **opt 164 | ) 165 | timeout = apply_timeout_scale(timeout) 166 | 167 | in_c, in_p = IO.pipe 168 | out_p, out_c = IO.pipe if capture_stdout 169 | err_p, err_c = IO.pipe if capture_stderr && 170 | capture_stderr != :merge_to_stdout 171 | opt[:in] = in_c 172 | opt[:out] = out_c if capture_stdout 173 | opt[:err] = ( 174 | if capture_stderr == :merge_to_stdout 175 | out_c 176 | else 177 | err_c 178 | end 179 | ) if capture_stderr 180 | if encoding 181 | out_p.set_encoding(encoding) if out_p 182 | err_p.set_encoding(encoding) if err_p 183 | end 184 | ios.each { |i, o = i| opt[i] = o } if ios 185 | 186 | c = "C" 187 | child_env = {} 188 | LANG_ENVS.each { |lc| child_env[lc] = c } 189 | child_env.update(args.shift) if Array === args and Hash === args.first 190 | if RUBYLIB and lib = child_env["RUBYLIB"] 191 | child_env["RUBYLIB"] = [lib, RUBYLIB].join(File::PATH_SEPARATOR) 192 | end 193 | 194 | # remain env 195 | %w[ASAN_OPTIONS RUBY_ON_BUG].each do |name| 196 | child_env[name] = ENV[name] if ENV[name] 197 | end 198 | 199 | args = [args] if args.kind_of?(String) 200 | pid = spawn(child_env, *precommand, rubybin, *args, opt) 201 | in_c.close 202 | out_c&.close 203 | out_c = nil 204 | err_c&.close 205 | err_c = nil 206 | if block_given? 207 | return yield in_p, out_p, err_p, pid 208 | else 209 | th_stdout = Thread.new { out_p.read } if capture_stdout 210 | th_stderr = Thread.new { err_p.read } if capture_stderr && 211 | capture_stderr != :merge_to_stdout 212 | in_p.write stdin_data.to_str unless stdin_data.empty? 213 | in_p.close 214 | if (!th_stdout || th_stdout.join(timeout)) && 215 | (!th_stderr || th_stderr.join(timeout)) 216 | timeout_error = nil 217 | else 218 | status = terminate(pid, signal, opt[:pgroup], reprieve) 219 | terminated = Time.now 220 | end 221 | stdout = th_stdout.value if capture_stdout 222 | stderr = th_stderr.value if capture_stderr && 223 | capture_stderr != :merge_to_stdout 224 | out_p.close if capture_stdout 225 | err_p.close if capture_stderr && capture_stderr != :merge_to_stdout 226 | status ||= Process.wait2(pid)[1] 227 | stdout = stdout_filter.call(stdout) if stdout_filter 228 | stderr = stderr_filter.call(stderr) if stderr_filter 229 | if timeout_error 230 | bt = caller_locations 231 | msg = "execution of #{bt.shift.label} expired timeout (#{timeout} sec)" 232 | msg = 233 | failure_description( 234 | status, 235 | terminated, 236 | msg, 237 | [stdout, stderr].join("\n") 238 | ) 239 | raise timeout_error, msg, bt.map(&:to_s) 240 | end 241 | return stdout, stderr, status 242 | end 243 | ensure 244 | [th_stdout, th_stderr].each { |th| th.kill if th } 245 | [in_c, in_p, out_c, out_p, err_c, err_p].each { |io| io&.close } 246 | [th_stdout, th_stderr].each { |th| th.join if th } 247 | end 248 | module_function :invoke_ruby 249 | 250 | def verbose_warning 251 | class << (stderr = "".dup) 252 | alias write concat 253 | def flush 254 | end 255 | end 256 | stderr, $stderr = $stderr, stderr 257 | $VERBOSE = true 258 | yield stderr 259 | return $stderr 260 | ensure 261 | stderr, $stderr = $stderr, stderr 262 | $VERBOSE = EnvUtil.original_verbose 263 | EnvUtil.original_warning&.each { |i, v| Warning[i] = v } 264 | end 265 | module_function :verbose_warning 266 | 267 | def default_warning 268 | $VERBOSE = false 269 | yield 270 | ensure 271 | $VERBOSE = EnvUtil.original_verbose 272 | end 273 | module_function :default_warning 274 | 275 | def suppress_warning 276 | $VERBOSE = nil 277 | yield 278 | ensure 279 | $VERBOSE = EnvUtil.original_verbose 280 | end 281 | module_function :suppress_warning 282 | 283 | def under_gc_stress(stress = true) 284 | stress, GC.stress = GC.stress, stress 285 | yield 286 | ensure 287 | GC.stress = stress 288 | end 289 | module_function :under_gc_stress 290 | 291 | def with_default_external(enc) 292 | suppress_warning { Encoding.default_external = enc } 293 | yield 294 | ensure 295 | suppress_warning do 296 | Encoding.default_external = EnvUtil.original_external_encoding 297 | end 298 | end 299 | module_function :with_default_external 300 | 301 | def with_default_internal(enc) 302 | suppress_warning { Encoding.default_internal = enc } 303 | yield 304 | ensure 305 | suppress_warning do 306 | Encoding.default_internal = EnvUtil.original_internal_encoding 307 | end 308 | end 309 | module_function :with_default_internal 310 | 311 | def labeled_module(name, &block) 312 | Module.new do 313 | singleton_class.class_eval do 314 | define_method(:to_s) { name } 315 | alias inspect to_s 316 | alias name to_s 317 | end 318 | class_eval(&block) if block 319 | end 320 | end 321 | module_function :labeled_module 322 | 323 | def labeled_class(name, superclass = Object, &block) 324 | Class.new(superclass) do 325 | singleton_class.class_eval do 326 | define_method(:to_s) { name } 327 | alias inspect to_s 328 | alias name to_s 329 | end 330 | class_eval(&block) if block 331 | end 332 | end 333 | module_function :labeled_class 334 | 335 | if /darwin/ =~ RUBY_PLATFORM 336 | DIAGNOSTIC_REPORTS_PATH = 337 | File.expand_path("~/Library/Logs/DiagnosticReports") 338 | DIAGNOSTIC_REPORTS_TIMEFORMAT = "%Y-%m-%d-%H%M%S" 339 | @ruby_install_name = RbConfig::CONFIG["RUBY_INSTALL_NAME"] 340 | 341 | def self.diagnostic_reports(signame, pid, now) 342 | return unless %w[ABRT QUIT SEGV ILL TRAP].include?(signame) 343 | cmd = File.basename(rubybin) 344 | cmd = @ruby_install_name if "ruby-runner#{RbConfig::CONFIG["EXEEXT"]}" == 345 | cmd 346 | path = DIAGNOSTIC_REPORTS_PATH 347 | timeformat = DIAGNOSTIC_REPORTS_TIMEFORMAT 348 | pat = "#{path}/#{cmd}_#{now.strftime(timeformat)}[-_]*.crash" 349 | first = true 350 | 30.times do 351 | first ? (first = false) : sleep(0.1) 352 | Dir.glob(pat) do |name| 353 | log = 354 | begin 355 | File.read(name) 356 | rescue StandardError 357 | next 358 | end 359 | if /\AProcess:\s+#{cmd} \[#{pid}\]$/ =~ log 360 | File.unlink(name) 361 | begin 362 | File.unlink("#{path}/.#{File.basename(name)}.plist") 363 | rescue StandardError 364 | nil 365 | end 366 | return log 367 | end 368 | end 369 | end 370 | nil 371 | end 372 | else 373 | def self.diagnostic_reports(signame, pid, now) 374 | end 375 | end 376 | 377 | def self.failure_description(status, now, message = "", out = "") 378 | pid = status.pid 379 | if signo = status.termsig 380 | signame = Signal.signame(signo) 381 | sigdesc = "signal #{signo}" 382 | end 383 | log = diagnostic_reports(signame, pid, now) 384 | sigdesc = "SIG#{signame} (#{sigdesc})" if signame 385 | sigdesc = "#{sigdesc} (core dumped)" if status.coredump? 386 | full_message = "".dup 387 | message = message.call if Proc === message 388 | full_message << message << "\n" if message and !message.empty? 389 | full_message << "pid #{pid}" 390 | full_message << " exit #{status.exitstatus}" if status.exited? 391 | full_message << " killed by #{sigdesc}" if sigdesc 392 | if out and !out.empty? 393 | full_message << "\n" << out.b.gsub(/^/, "| ") 394 | full_message.sub!(/(? Object#bar at .*/foo\.rb:13}, 38 | %r{^#depth:1 > #\.foo at .*/foo\.rb:8}, 39 | %r{^#depth:1 < #\.foo #=> 100 at .*/foo\.rb:8}, 40 | %r{^#depth:0 < Object#bar #=> 100 at .*/foo\.rb:13} 41 | ], 42 | out 43 | ) 44 | end 45 | 46 | def test_object_tracer_with_header 47 | file = write_file("foo.rb", <<~RUBY) 48 | obj = Object.new 49 | 50 | def obj.foo 51 | 100 52 | end 53 | 54 | def bar(obj) 55 | obj.foo 56 | end 57 | 58 | CallTracer.new(header: "tracer-1").start 59 | 60 | bar(obj) 61 | RUBY 62 | 63 | out, err = execute_file(file) 64 | 65 | assert_empty(err) 66 | assert_traces( 67 | [ 68 | %r{^tracer-1 #depth:0 > Object#bar at .*/foo\.rb:13}, 69 | %r{^tracer-1 #depth:1 > #\.foo at .*/foo\.rb:8}, 70 | %r{^tracer-1 #depth:1 < #\.foo #=> 100 at .*/foo\.rb:8}, 71 | %r{^tracer-1 #depth:0 < Object#bar #=> 100 at .*/foo\.rb:13} 72 | ], 73 | out 74 | ) 75 | end 76 | end 77 | end 78 | -------------------------------------------------------------------------------- /test/tracer/exception_tracer_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "../test_helper" 2 | 3 | module Tracer 4 | class ExceptionTracerTest < TestCase 5 | include ActivationTests 6 | 7 | private 8 | 9 | def build_tracer 10 | ExceptionTracer.new(output: @output, colorize: false) 11 | end 12 | end 13 | 14 | class ExceptionTracerIntegrationTest < IntegrationTestCase 15 | def test_exception_tracer_traces_exceptions 16 | file = write_file("foo.rb", <<~RUBY) 17 | ExceptionTracer.new.start 18 | 19 | begin 20 | raise "boom" 21 | rescue 22 | end 23 | RUBY 24 | 25 | out, err = execute_file(file) 26 | 27 | expected_traces = [ 28 | /^#depth:0 # raised at .*foo.rb:4/ 29 | ] 30 | 31 | if RUBY_VERSION >= "3.3.0" 32 | expected_traces << /^#depth:1 # rescued at .*foo.rb:5/ 33 | end 34 | 35 | assert_empty(err) 36 | assert_traces(expected_traces, out) 37 | end 38 | 39 | def test_exception_tracer_with_header 40 | file = write_file("foo.rb", <<~RUBY) 41 | ExceptionTracer.new(header: "tracer-1").start 42 | 43 | begin 44 | raise "boom" 45 | rescue 46 | end 47 | RUBY 48 | 49 | out, err = execute_file(file) 50 | 51 | expected_traces = [ 52 | /^tracer-1 #depth:0 # raised at .*foo.rb:4/ 53 | ] 54 | 55 | if RUBY_VERSION >= "3.3.0" 56 | expected_traces << /^tracer-1 #depth:1 # rescued at .*foo.rb:5/ 57 | end 58 | 59 | assert_empty(err) 60 | assert_traces(expected_traces, out) 61 | end 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /test/tracer/helper_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "../test_helper" 2 | 3 | module Tracer 4 | class HelperIntegrationTest < IntegrationTestCase 5 | def test_trace_exception 6 | file = write_file("foo.rb", <<~RUBY) 7 | require "tracer/helper" 8 | 9 | trace_exception do 10 | raise "boom" rescue nil 11 | end 12 | RUBY 13 | 14 | out, err = execute_file(file) 15 | 16 | expected_traces = [ 17 | /^#depth:1 # raised at .*foo.rb:4/ 18 | ] 19 | 20 | if RUBY_VERSION >= "3.3.0" 21 | expected_traces << /^#depth:2 # rescued at .*foo.rb:4/ 22 | end 23 | 24 | assert_empty(err) 25 | assert_traces(expected_traces, out) 26 | end 27 | 28 | def test_trace_call 29 | file = write_file("foo.rb", <<~RUBY) 30 | require "tracer/helper" 31 | 32 | obj = Object.new 33 | 34 | def obj.foo 35 | 100 36 | end 37 | 38 | def bar(obj) 39 | obj.foo 40 | end 41 | 42 | trace_call do 43 | bar(obj) 44 | end 45 | RUBY 46 | 47 | out, err = execute_file(file) 48 | 49 | assert_empty(err) 50 | assert_traces( 51 | [ 52 | %r{#depth:1 > Object#bar at .*/foo\.rb:14}, 53 | %r{#depth:2 > #\.foo at .*/foo\.rb:10}, 54 | %r{#depth:2 < #\.foo #=> 100 at .*/foo\.rb:10}, 55 | %r{#depth:1 < Object#bar #=> 100 at .*/foo\.rb:14} 56 | ], 57 | out 58 | ) 59 | end 60 | 61 | def test_trace 62 | file = write_file("foo.rb", <<~RUBY) 63 | require "tracer/helper" 64 | 65 | obj = Object.new 66 | 67 | def obj.foo 68 | 100 69 | end 70 | 71 | def bar(obj) 72 | obj.foo 73 | end 74 | 75 | trace(obj) do 76 | bar(obj) 77 | end 78 | RUBY 79 | 80 | out, err = execute_file(file) 81 | 82 | assert_empty(err) 83 | assert_traces( 84 | [ 85 | /#depth:1 # is used as a parameter obj of Object#bar at .*foo\.rb:14/, 86 | /#depth:2 # receives .foo at .*foo\.rb:10/ 87 | ], 88 | out 89 | ) 90 | end 91 | end 92 | end 93 | -------------------------------------------------------------------------------- /test/tracer/ivar_tracer_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "../test_helper" 2 | 3 | module Tracer 4 | class IvarTracerTest < TestCase 5 | include ActivationTests 6 | 7 | def build_tracer 8 | stub_object = Object.new 9 | IvarTracer.new(stub_object, :@foo, output: @output) 10 | end 11 | end 12 | 13 | class IvarTracerIntegrationTest < IntegrationTestCase 14 | def test_ivar_tracer_traces_attr_accessor_changes 15 | # Ruby 3.0 and below's attr_accessor calls don't trigger TracePoint properly 16 | omit if RUBY_VERSION < "3.1" 17 | 18 | file = write_file("foo.rb", <<~RUBY) 19 | class Foo 20 | attr_accessor :bar 21 | end 22 | 23 | obj = Foo.new 24 | 25 | IvarTracer.new(obj, :@bar).start 26 | 27 | obj.bar = 100 28 | RUBY 29 | 30 | out, err = execute_file(file) 31 | 32 | assert_empty(err) 33 | assert_traces( 34 | [%r{^#depth:0 Foo#bar= sets @bar = 100 at .*/foo\.rb:9}], 35 | out 36 | ) 37 | end 38 | 39 | def test_ivar_tracer_traces_method_changes 40 | file = write_file("foo.rb", <<~RUBY) 41 | class Foo 42 | def bar=(value) 43 | @bar = value 44 | end 45 | end 46 | 47 | obj = Foo.new 48 | 49 | IvarTracer.new(obj, :@bar).start 50 | 51 | obj.bar = 100 52 | RUBY 53 | 54 | out, err = execute_file(file) 55 | 56 | assert_empty(err) 57 | assert_traces( 58 | [%r{^#depth:0 Foo#bar= sets @bar = 100 at .*/foo\.rb:11}], 59 | out 60 | ) 61 | end 62 | 63 | def test_ivar_tracer_with_header 64 | file = write_file("foo.rb", <<~RUBY) 65 | class Foo 66 | def bar=(value) 67 | @bar = value 68 | end 69 | end 70 | 71 | obj = Foo.new 72 | 73 | IvarTracer.new(obj, :@bar, header: "trace-foo@bar").start 74 | 75 | obj.bar = 100 76 | RUBY 77 | 78 | out, err = execute_file(file) 79 | 80 | assert_empty(err) 81 | assert_traces( 82 | [%r{^trace-foo@bar #depth:0 Foo#bar= sets @bar = 100 at .*/foo\.rb:11}], 83 | out 84 | ) 85 | end 86 | 87 | def test_ivar_tracer_works_with_basic_object 88 | file = write_file("foo.rb", <<~RUBY) 89 | class Foo < BasicObject 90 | def bar=(value) 91 | @bar = value 92 | end 93 | end 94 | 95 | obj = Foo.new 96 | 97 | IvarTracer.new(obj, :@bar).start 98 | 99 | obj.bar = 100 100 | RUBY 101 | 102 | out, err = execute_file(file) 103 | 104 | assert_empty(err) 105 | assert_traces( 106 | [%r{^#depth:0 Foo#bar= sets @bar = 100 at .*/foo\.rb:11}], 107 | out 108 | ) 109 | end 110 | end 111 | end 112 | -------------------------------------------------------------------------------- /test/tracer/line_tracer_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "../test_helper" 2 | 3 | module Tracer 4 | class LineTracerTest < TestCase 5 | include ActivationTests 6 | 7 | private 8 | 9 | def build_tracer 10 | LineTracer.new(output: @output) 11 | end 12 | end 13 | 14 | class LineTracerIntegrationTest < IntegrationTestCase 15 | def test_line_tracer_traces_line_executions 16 | file = write_file("foo.rb", <<~RUBY) 17 | LineTracer.new.start 18 | 19 | a = 1 20 | b = 2 21 | RUBY 22 | 23 | out, err = execute_file(file) 24 | 25 | assert_empty(err) 26 | assert_traces([/^#depth:1 at .*foo.rb:3/, /#depth:1 at .*foo.rb:4/], out) 27 | end 28 | 29 | def test_line_tracer_with_header 30 | file = write_file("foo.rb", <<~RUBY) 31 | LineTracer.new(header: "tracer-1").start 32 | 33 | a = 1 34 | b = 2 35 | RUBY 36 | 37 | out, err = execute_file(file) 38 | 39 | assert_empty(err) 40 | assert_traces([/^tracer-1 #depth:1 at .*foo.rb:3/, /#depth:1 at .*foo.rb:4/], out) 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /test/tracer/object_tracer_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "../test_helper" 2 | 3 | module Tracer 4 | class ObjectTracerTest < TestCase 5 | include ActivationTests 6 | 7 | def test_object_tracer_arguments_fallback 8 | obj = Object.new 9 | tracer = ObjectTracer.new(obj) 10 | assert_equal(obj.object_id, tracer.target_id) 11 | assert_equal(obj.inspect, tracer.target_label) 12 | 13 | tracer = ObjectTracer.new(target_id: obj.object_id, target_label: "foo") 14 | assert_equal(obj.object_id, tracer.target_id) 15 | assert_equal("foo", tracer.target_label) 16 | 17 | tracer = ObjectTracer.new(target_id: obj.object_id) 18 | assert_equal(obj.object_id, tracer.target_id) 19 | assert_equal("", tracer.target_label) 20 | end 21 | 22 | private 23 | 24 | def build_tracer 25 | stub_object = Object.new 26 | ObjectTracer.new(stub_object, output: @output) 27 | end 28 | end 29 | 30 | class ObjectTracerIntegrationTest < IntegrationTestCase 31 | def test_object_tracer_traces_object_usage 32 | file = write_file("foo.rb", <<~RUBY) 33 | obj = Object.new 34 | 35 | def obj.foo 36 | 100 37 | end 38 | 39 | def bar(obj) 40 | obj.foo 41 | end 42 | 43 | ObjectTracer.new(obj).start 44 | 45 | bar(obj) 46 | RUBY 47 | 48 | out, err = execute_file(file) 49 | 50 | assert_empty(err) 51 | assert_traces( 52 | [ 53 | %r{^#depth:0 # is used as a parameter obj of Object#bar at .*/foo\.rb:13}, 54 | %r{^#depth:1 # receives \.foo at .*/foo\.rb:8} 55 | ], 56 | out 57 | ) 58 | end 59 | 60 | def test_object_tracer_with_header 61 | file = write_file("foo.rb", <<~RUBY) 62 | obj = Object.new 63 | 64 | def obj.foo 65 | 100 66 | end 67 | 68 | def bar(obj) 69 | obj.foo 70 | end 71 | 72 | ObjectTracer.new(obj, header: "tracer-1").start 73 | 74 | bar(obj) 75 | RUBY 76 | 77 | out, err = execute_file(file) 78 | 79 | assert_empty(err) 80 | assert_traces( 81 | [ 82 | %r{tracer-1 #depth:0 # is used as a parameter obj of Object#bar at .*/foo\.rb:13}, 83 | %r{tracer-1 #depth:1 # receives \.foo at .*/foo\.rb:8} 84 | ], 85 | out 86 | ) 87 | end 88 | 89 | def test_object_tracer_handles_rest_arguments 90 | file = write_file("foo.rb", <<~RUBY) 91 | obj = Object.new 92 | 93 | def foo(*args) 94 | end 95 | 96 | def bar(**kwargs) 97 | end 98 | 99 | ObjectTracer.new(obj).start 100 | 101 | foo(obj) 102 | bar(obj: obj) 103 | RUBY 104 | 105 | out, err = execute_file(file) 106 | 107 | assert_empty(err) 108 | assert_traces( 109 | [ 110 | %r{^#depth:0 # is used as a parameter in args of Object#foo at .*/foo\.rb:11}, 111 | %r{^#depth:0 # is used as a parameter in kwargs of Object#bar at .*/foo\.rb:12} 112 | ], 113 | out 114 | ) 115 | end 116 | 117 | def test_object_tracer_calculates_depth_correctly 118 | file = write_file("foo.rb", <<~RUBY) 119 | obj = Object.new 120 | 121 | def foo(*args) 122 | yield args.first 123 | end 124 | 125 | def bar(**kwargs) 126 | end 127 | 128 | ObjectTracer.new(obj).start 129 | 130 | foo(obj) do |obj| 131 | bar(obj: obj) 132 | end 133 | RUBY 134 | 135 | out, err = execute_file(file) 136 | 137 | assert_empty(err) 138 | assert_traces( 139 | [ 140 | %r{^#depth:0 # is used as a parameter in args of Object#foo at .*/foo\.rb:12}, 141 | %r{^#depth:1 # is used as a parameter obj of block{} at .*/foo\.rb:4}, 142 | %r{^#depth:2 # is used as a parameter in kwargs of Object#bar at .*/foo\.rb:13} 143 | ], 144 | out 145 | ) 146 | end 147 | 148 | def test_object_tracer_works_with_object_id_and_label 149 | file = write_file("foo.rb", <<~RUBY) 150 | obj = Object.new 151 | 152 | def obj.foo 153 | 100 154 | end 155 | 156 | def bar(obj) 157 | obj.foo 158 | end 159 | 160 | ObjectTracer.new(target_id: obj.object_id, target_label: obj.inspect).start 161 | 162 | bar(obj) 163 | RUBY 164 | 165 | out, err = execute_file(file) 166 | 167 | assert_empty(err) 168 | assert_traces( 169 | [ 170 | %r{^#depth:0 # is used as a parameter obj of Object#bar at .*/foo\.rb:13}, 171 | %r{^#depth:1 # receives \.foo at .*/foo\.rb:8} 172 | ], 173 | out 174 | ) 175 | end 176 | 177 | def test_object_tracer_skips_primitive_methods 178 | file = write_file("foo.rb", <<~RUBY) 179 | obj = Object.new 180 | 181 | obj.to_s 182 | obj.respond_to?(:to_s) 183 | 184 | ObjectTracer.new(obj).start 185 | RUBY 186 | 187 | out, err = execute_file(file) 188 | 189 | assert_empty(err) 190 | assert_traces([], out) 191 | end 192 | 193 | def test_object_tracer_works_with_nil_defined_class 194 | file = write_file("foo.rb", <<~RUBY) 195 | obj = Object.new 196 | ObjectTracer.new(obj).start 197 | 198 | obj.instance_eval(&proc {}) 199 | RUBY 200 | 201 | out, err = execute_file(file) 202 | 203 | assert_empty(err) 204 | assert_traces( 205 | [ 206 | /^#depth:-1 # receives #instance_eval \(BasicObject#instance_eval\) at .*foo\.rb:4/, 207 | /^#depth:1 # receives at .*foo.rb:4:in (?:`|'BasicObject#)instance_eval'/ 208 | ], 209 | out 210 | ) 211 | end 212 | 213 | def test_object_tracer_works_with_basic_object 214 | file = write_file("foo.rb", <<~RUBY) 215 | class Foo < BasicObject 216 | def baz; end 217 | end 218 | 219 | obj = Foo.new 220 | 221 | def obj.foo 222 | 100 223 | end 224 | 225 | def bar(obj) 226 | obj.foo 227 | end 228 | 229 | ObjectTracer.new(obj).start 230 | 231 | bar(obj) 232 | obj.baz 233 | RUBY 234 | 235 | out, err = execute_file(file) 236 | 237 | assert_empty(err) 238 | assert_traces( 239 | [ 240 | %r{^#depth:0 # is used as a parameter obj of Object#bar at .*/foo\.rb:17}, 241 | %r{^#depth:1 # receives \.foo at .*/foo\.rb:12}, 242 | %r{^#depth:0 # receives #baz \(Foo#baz\) at .*/foo\.rb:18} 243 | ], 244 | out 245 | ) 246 | end 247 | end 248 | end 249 | -------------------------------------------------------------------------------- /tracer.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "lib/tracer/version" 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = "tracer" 7 | spec.version = Tracer::VERSION 8 | spec.authors = ["Stan Lo", "Keiju ISHITSUKA"] 9 | spec.email = %w[stan001212@gmail.com keiju@ruby-lang.org] 10 | 11 | spec.summary = "A Ruby tracer" 12 | spec.description = "A Ruby tracer" 13 | spec.homepage = "https://github.com/ruby/tracer" 14 | spec.licenses = %w[Ruby BSD-2-Clause] 15 | spec.required_ruby_version = ">= 2.7.0" 16 | 17 | spec.metadata["homepage_uri"] = spec.homepage 18 | spec.metadata["source_code_uri"] = "https://github.com/ruby/tracer" 19 | spec.metadata[ 20 | "changelog_uri" 21 | ] = "https://github.com/ruby/tracer/blob/main/CHANGELOG.md" 22 | 23 | # Specify which files should be added to the gem when it is released. 24 | # The `git ls-files -z` loads the files in the RubyGem that have been added into git. 25 | gemspec = File.basename(__FILE__) 26 | spec.files = 27 | Dir.chdir(__dir__) do 28 | `git ls-files -z`.split("\x0") 29 | .reject do |f| 30 | (f == gemspec) || 31 | f.match( 32 | %r{\A(?:(?:bin|test|spec|features)/|\.(?:git|circleci)|appveyor|(?:Rake|Gem)file)} 33 | ) 34 | end 35 | end 36 | spec.bindir = "exe" 37 | spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) } 38 | spec.require_paths = ["lib"] 39 | 40 | # Uncomment to register a new dependency of your gem 41 | 42 | # For more information and examples about making a new gem, check out our 43 | # guide at: https://bundler.io/guides/creating_gem.html 44 | end 45 | --------------------------------------------------------------------------------