diff --git a/CHANGELOG.md b/CHANGELOG.md index 9bb5396..c7c16b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ - `Bundlebun.system(args)` method: runs Bun as a subprocess and returns to Ruby. Returns `true` if Bun exited successfully. Use this when you need to continue executing Ruby code after Bun finishes. - Default behaviour remains: `Bundlebun.()` / `Bundlebun.call` / `Bundlebun.exec` all replace the current Ruby process with Bun (never return). This is what binstubs and wrappers use. + +- ActiveSupport::Notifications instrumentation added. `system.bundlebun` for `Bundlebun.system` calls, `exec.bundlebun` for exec calls (binstubs, wrappers). Payload: `{ command: args }`. - Added RBS type signatures for the public API ## [0.3.0] - 2026-01-29 diff --git a/README.md b/README.md index f1304a0..5a5d3b6 100644 --- a/README.md +++ b/README.md @@ -223,6 +223,23 @@ Alternatively, you can load the monkey-patch manually: Bundlebun::Integrations::ExecJS.bun! ``` +#### ActiveSupport::Notifications (Instrumentation) + +When ActiveSupport is available, bundlebun emits instrumentation events that you can subscribe to for logging or monitoring: + +- `system.bundlebun` for `Bundlebun.system` (calling Bun and returning to Ruby code); +- `exec.bundlebun` for `Bundlebun.call` / `Bundlebun.exec` (binstubs and wrappers: exiting to Bun). + +The payload is `{ command: args }`: the arguments you passed to the method (string or array). + +Example subscriber: + +```ruby +ActiveSupport::Notifications.subscribe('system.bundlebun') do |event| + Rails.logger.info "Bun: #{event.payload[:command]} (#{event.duration.round(1)}ms)" +end +``` + ## Versioning bundlebun uses the `#{bundlebun.version}.#{bun.version}` versioning scheme. Meaning: gem bundlebun version `0.1.0.1.1.38` is a distribution that includes a gem with its own code version `0.1.0` and a Bun runtime with version `1.1.38`. diff --git a/lib/bundlebun.rb b/lib/bundlebun.rb index 6357ed5..4db23c3 100644 --- a/lib/bundlebun.rb +++ b/lib/bundlebun.rb @@ -33,7 +33,7 @@ class << self # Replaces the current Ruby process with Bun. # # This is the default way to run Bun. The Ruby process is replaced by Bun - # and never returns. Also available via the +.()+ shorthand syntax. + # and never returns. Also available via the `.()` shorthand syntax. # # @param arguments [String, Array] Command arguments to pass to Bun # @return [void] This method never returns @@ -70,8 +70,8 @@ def exec(...) # Use this when you need to run Bun and then continue executing Ruby code. # # @param arguments [String, Array] Command arguments to pass to Bun - # @return [Boolean, nil] +true+ if Bun exited successfully (status 0), - # +false+ if it exited with an error, +nil+ if execution failed + # @return [Boolean, nil] `true` if Bun exited successfully (status 0), + # `false` if it exited with an error, `nil` if execution failed # # @example Run install and check result # if Bundlebun.system('install') diff --git a/lib/bundlebun/runner.rb b/lib/bundlebun/runner.rb index 12677c4..01eaa57 100644 --- a/lib/bundlebun/runner.rb +++ b/lib/bundlebun/runner.rb @@ -28,6 +28,10 @@ class Runner class << self # Replaces the current Ruby process with Bun. # + # When `ActiveSupport::Notifications` is available, this method publishes an + # `exec.bundlebun` event before replacing the process. The payload contains + # `{ command: arguments }` where `arguments` is what was passed to the method. + # # @param arguments [String, Array] Command arguments to pass to Bun # @return [void] This method never returns # @@ -43,7 +47,7 @@ def exec(...) end # Replaces the current Ruby process with Bun. Alias for {.exec}. - # Also available via the +.()+ shorthand syntax. + # Also available via the `.()` shorthand syntax. # # @param arguments [String, Array] Command arguments to pass to Bun # @return [void] This method never returns @@ -66,9 +70,13 @@ def call(...) # Unlike {.call} and {.exec}, this method does not replace the current process. # Use this when you need to run Bun and then continue executing Ruby code. # + # When `ActiveSupport::Notifications` is available, this method publishes a + # `system.bundlebun` event with timing information. The payload contains + # `{ command: arguments }` where `arguments` is what was passed to the method. + # # @param arguments [String, Array] Command arguments to pass to Bun - # @return [Boolean, nil] +true+ if Bun exited successfully (status 0), - # +false+ if it exited with an error, +nil+ if execution failed + # @return [Boolean, nil] `true` if Bun exited successfully (status 0), + # `false` if it exited with an error, `nil` if execution failed # # @example Run install and check result # if Bundlebun.system('install') @@ -172,6 +180,10 @@ def initialize(arguments = '') # Replaces the current Ruby process with Bun. # This is the default behavior. # + # When `ActiveSupport::Notifications` is available, this method publishes an + # `exec.bundlebun` event before replacing the process. The payload contains + # `{ command: arguments }` where `arguments` is what was passed to the runner. + # # @return [void] This method never returns # # @example @@ -181,6 +193,7 @@ def initialize(arguments = '') # @see #system def exec check_executable! + instrument('exec.bundlebun') Kernel.exec(command) end @@ -198,8 +211,12 @@ def call # Unlike {#call} and {#exec}, this method does not replace the current process. # Use this when you need to run Bun and then continue executing Ruby code. # - # @return [Boolean, nil] +true+ if Bun exited successfully (status 0), - # +false+ if it exited with an error, +nil+ if execution failed + # When `ActiveSupport::Notifications` is available, this method publishes a + # `system.bundlebun` event with timing information. The payload contains + # `{ command: arguments }` where `arguments` is what was passed to the runner. + # + # @return [Boolean, nil] `true` if Bun exited successfully (status 0), + # `false` if it exited with an error, `nil` if execution failed # # @example # runner = Bundlebun::Runner.new('install') @@ -210,13 +227,26 @@ def call # @see #exec def system check_executable! - Kernel.system(command) + instrument('system.bundlebun') do + Kernel.system(command) + end end private attr_reader :arguments + def instrument(event, &block) + return block&.call unless defined?(ActiveSupport::Notifications) + + payload = {command: arguments} + if block + ActiveSupport::Notifications.instrument(event, payload, &block) + else + ActiveSupport::Notifications.instrument(event, payload) + end + end + def check_executable! return if self.class.binary_path_exist? diff --git a/spec/integration/instrumentation_spec.rb b/spec/integration/instrumentation_spec.rb new file mode 100644 index 0000000..df0fbf8 --- /dev/null +++ b/spec/integration/instrumentation_spec.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +require 'active_support' +require 'active_support/notifications' + +RSpec.describe 'ActiveSupport::Notifications instrumentation', type: :integration do + let(:binary_path) { Bundlebun::Runner.binary_path } + let(:events) { [] } + + before do + allow(File).to receive(:exist?).with(binary_path).and_return(true) + allow(Kernel).to receive(:system).and_return(true) + allow(Kernel).to receive(:exec) + end + + describe 'system.bundlebun' do + before do + ActiveSupport::Notifications.subscribe('system.bundlebun') do |*args| + events << ActiveSupport::Notifications::Event.new(*args) + end + end + + after do + ActiveSupport::Notifications.unsubscribe('system.bundlebun') + end + + it 'emits an event with command payload' do + Bundlebun.system('install') + + expect(events.size).to eq(1) + expect(events.first.name).to eq('system.bundlebun') + expect(events.first.payload).to eq({command: 'install'}) + end + + it 'includes timing information' do + Bundlebun.system('build') + + expect(events.first.duration).to be >= 0 + end + + it 'works with array arguments' do + Bundlebun.system(['add', 'postcss']) + + expect(events.first.payload).to eq({command: ['add', 'postcss']}) + end + end + + describe 'exec.bundlebun' do + before do + ActiveSupport::Notifications.subscribe('exec.bundlebun') do |*args| + events << ActiveSupport::Notifications::Event.new(*args) + end + end + + after do + ActiveSupport::Notifications.unsubscribe('exec.bundlebun') + end + + it 'emits an event with command payload' do + Bundlebun.call('install') + + expect(events.size).to eq(1) + expect(events.first.name).to eq('exec.bundlebun') + expect(events.first.payload).to eq({command: 'install'}) + end + + it 'works with array arguments' do + Bundlebun.call(['run', 'dev']) + + expect(events.first.payload).to eq({command: ['run', 'dev']}) + end + end +end diff --git a/spec/lib/bundlebun/runner_spec.rb b/spec/lib/bundlebun/runner_spec.rb index d913c74..3882678 100644 --- a/spec/lib/bundlebun/runner_spec.rb +++ b/spec/lib/bundlebun/runner_spec.rb @@ -307,4 +307,60 @@ expect(described_class.system('--version')).to be true end end + + describe 'instrumentation' do + before do + allow(File).to receive(:exist?).with(binary_path).and_return(true) + stub_const('ActiveSupport::Notifications', double('ActiveSupport::Notifications')) + end + + describe '#exec' do + it 'instruments exec.bundlebun before replacing process' do + runner = described_class.new('install') + expect(ActiveSupport::Notifications).to receive(:instrument) + .with('exec.bundlebun', {command: 'install'}) + expect(Kernel).to receive(:exec) + runner.exec + end + end + + describe '#system' do + it 'instruments system.bundlebun with a block' do + runner = described_class.new('install') + expect(ActiveSupport::Notifications).to receive(:instrument) + .with('system.bundlebun', {command: 'install'}) + .and_yield + expect(Kernel).to receive(:system).and_return(true) + runner.system + end + + it 'passes the command as payload' do + runner = described_class.new(['add', 'postcss']) + expect(ActiveSupport::Notifications).to receive(:instrument) + .with('system.bundlebun', {command: ['add', 'postcss']}) + .and_yield + expect(Kernel).to receive(:system).and_return(true) + runner.system + end + end + end + + describe 'without ActiveSupport' do + before do + allow(File).to receive(:exist?).with(binary_path).and_return(true) + # ActiveSupport::Notifications is not defined in this context + end + + it 'still works for #system' do + runner = described_class.new('install') + expect(Kernel).to receive(:system).and_return(true) + expect(runner.system).to be true + end + + it 'still works for #exec' do + runner = described_class.new('install') + expect(Kernel).to receive(:exec) + runner.exec + end + end end