diff --git a/.changesets/add-collector-attribute-options.md b/.changesets/add-collector-attribute-options.md new file mode 100644 index 000000000..e1caa6ae6 --- /dev/null +++ b/.changesets/add-collector-attribute-options.md @@ -0,0 +1,10 @@ +--- +bump: minor +type: add +--- + +Add configuration options that map to OpenTelemetry resource attributes in collector mode: `service_name`, `filter_attributes`, `filter_function_parameters`, `filter_request_query_parameters`, `filter_request_payload`, `response_headers`, `send_function_parameters`, `send_request_query_parameters` and `send_request_payload`. + +In collector mode, existing options are passed to the collector as resource attributes as well: `name`, environment, `hostname`, `revision`, `ignore_actions`, `ignore_errors`, `ignore_namespaces`, `request_headers`, `filter_session_data` and `send_session_data`. + +Setting any of these without `collector_endpoint`, or setting `filter_parameters`, `filter_metadata` or `send_params` with it, logs a warning at startup. diff --git a/.changesets/add-collector-mode.md b/.changesets/add-collector-mode.md new file mode 100644 index 000000000..a3a3c01b1 --- /dev/null +++ b/.changesets/add-collector-mode.md @@ -0,0 +1,8 @@ +--- +bump: major +type: add +--- + +Add a `collector_endpoint` configuration option (`APPSIGNAL_COLLECTOR_ENDPOINT` environment variable) that puts the integration in _collector mode_. In collector mode AppSignal reports traces, metrics and logs to an AppSignal Collector, over OTLP/HTTP. + +Collector mode requires Ruby 3.1 or newer, and the OpenTelemetry gems, which are not installed by default. Add the `appsignal-opentelemetry` gem alongside `appsignal` to install them. When they are missing or too old, AppSignal logs a warning and keeps reporting through its agent. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8ab31fe3c..75228b588 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,7 @@ # This is a generated file by the `rake build_matrix:github:generate` task. # See `build_matrix.yml` for the build matrix. # Generate this file with `rake build_matrix:github:generate`. -# Generated job count: 255 +# Generated job count: 432 --- name: Ruby gem CI 'on': @@ -124,7 +124,8 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &1 + name: Run tests run: "./script/bundler_wrapper exec rake test" - name: Run tests without extension run: "./script/bundler_wrapper exec rake test:failure" @@ -133,8 +134,8 @@ jobs: JRUBY_OPTS: '' COV: '1' BUNDLE_GEMFILE: gemfiles/no_dependencies.gemfile - ruby_4-0-0__capistrano2_ubuntu-latest: - name: Ruby 4.0.0 - capistrano2 + ruby_4-0-0__no_dependencies-collector_ubuntu-latest: + name: Ruby 4.0.0 - no_dependencies-collector needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: @@ -153,15 +154,14 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *1 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/capistrano2.gemfile - ruby_4-0-0__capistrano3_ubuntu-latest: - name: Ruby 4.0.0 - capistrano3 + BUNDLE_GEMFILE: gemfiles/no_dependencies-collector.gemfile + ruby_4-0-0__capistrano2_ubuntu-latest: + name: Ruby 4.0.0 - capistrano2 needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: @@ -180,15 +180,16 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &2 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/capistrano3.gemfile - ruby_4-0-0__code_ownership_ubuntu-latest: - name: Ruby 4.0.0 - code_ownership + BUNDLE_GEMFILE: gemfiles/capistrano2.gemfile + ruby_4-0-0__capistrano2-collector_ubuntu-latest: + name: Ruby 4.0.0 - capistrano2-collector needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: @@ -207,15 +208,14 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *2 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/code_ownership.gemfile - ruby_4-0-0__delayed_job_ubuntu-latest: - name: Ruby 4.0.0 - delayed_job + BUNDLE_GEMFILE: gemfiles/capistrano2-collector.gemfile + ruby_4-0-0__capistrano3_ubuntu-latest: + name: Ruby 4.0.0 - capistrano3 needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: @@ -234,15 +234,16 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &3 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/delayed_job.gemfile - ruby_4-0-0__dry-monitor_ubuntu-latest: - name: Ruby 4.0.0 - dry-monitor + BUNDLE_GEMFILE: gemfiles/capistrano3.gemfile + ruby_4-0-0__capistrano3-collector_ubuntu-latest: + name: Ruby 4.0.0 - capistrano3-collector needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: @@ -261,15 +262,14 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *3 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/dry-monitor.gemfile - ruby_4-0-0__excon_ubuntu-latest: - name: Ruby 4.0.0 - excon + BUNDLE_GEMFILE: gemfiles/capistrano3-collector.gemfile + ruby_4-0-0__code_ownership_ubuntu-latest: + name: Ruby 4.0.0 - code_ownership needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: @@ -288,15 +288,16 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &4 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/excon.gemfile - ruby_4-0-0__faraday-1_ubuntu-latest: - name: Ruby 4.0.0 - faraday-1 + BUNDLE_GEMFILE: gemfiles/code_ownership.gemfile + ruby_4-0-0__code_ownership-collector_ubuntu-latest: + name: Ruby 4.0.0 - code_ownership-collector needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: @@ -315,15 +316,14 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *4 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/faraday-1.gemfile - ruby_4-0-0__faraday-2_ubuntu-latest: - name: Ruby 4.0.0 - faraday-2 + BUNDLE_GEMFILE: gemfiles/code_ownership-collector.gemfile + ruby_4-0-0__delayed_job_ubuntu-latest: + name: Ruby 4.0.0 - delayed_job needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: @@ -342,15 +342,16 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &5 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/faraday-2.gemfile - ruby_4-0-0__grape_ubuntu-latest: - name: Ruby 4.0.0 - grape + BUNDLE_GEMFILE: gemfiles/delayed_job.gemfile + ruby_4-0-0__delayed_job-collector_ubuntu-latest: + name: Ruby 4.0.0 - delayed_job-collector needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: @@ -369,15 +370,14 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *5 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/grape.gemfile - ruby_4-0-0__http5_ubuntu-latest: - name: Ruby 4.0.0 - http5 + BUNDLE_GEMFILE: gemfiles/delayed_job-collector.gemfile + ruby_4-0-0__dry-monitor_ubuntu-latest: + name: Ruby 4.0.0 - dry-monitor needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: @@ -396,15 +396,16 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &6 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/http5.gemfile - ruby_4-0-0__http6_ubuntu-latest: - name: Ruby 4.0.0 - http6 + BUNDLE_GEMFILE: gemfiles/dry-monitor.gemfile + ruby_4-0-0__dry-monitor-collector_ubuntu-latest: + name: Ruby 4.0.0 - dry-monitor-collector needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: @@ -423,15 +424,14 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *6 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/http6.gemfile - ruby_4-0-0__mongo_ubuntu-latest: - name: Ruby 4.0.0 - mongo + BUNDLE_GEMFILE: gemfiles/dry-monitor-collector.gemfile + ruby_4-0-0__excon_ubuntu-latest: + name: Ruby 4.0.0 - excon needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: @@ -450,15 +450,16 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &7 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/mongo.gemfile - ruby_4-0-0__ownership_ubuntu-latest: - name: Ruby 4.0.0 - ownership + BUNDLE_GEMFILE: gemfiles/excon.gemfile + ruby_4-0-0__excon-collector_ubuntu-latest: + name: Ruby 4.0.0 - excon-collector needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: @@ -477,15 +478,14 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *7 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/ownership.gemfile - ruby_4-0-0__psych-3_ubuntu-latest: - name: Ruby 4.0.0 - psych-3 + BUNDLE_GEMFILE: gemfiles/excon-collector.gemfile + ruby_4-0-0__faraday-1_ubuntu-latest: + name: Ruby 4.0.0 - faraday-1 needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: @@ -504,15 +504,16 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &8 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/psych-3.gemfile - ruby_4-0-0__psych-4_ubuntu-latest: - name: Ruby 4.0.0 - psych-4 + BUNDLE_GEMFILE: gemfiles/faraday-1.gemfile + ruby_4-0-0__faraday-1-collector_ubuntu-latest: + name: Ruby 4.0.0 - faraday-1-collector needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: @@ -531,15 +532,14 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *8 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/psych-4.gemfile - ruby_4-0-0__que-0-14_ubuntu-latest: - name: Ruby 4.0.0 - que-0.14 + BUNDLE_GEMFILE: gemfiles/faraday-1-collector.gemfile + ruby_4-0-0__faraday-2_ubuntu-latest: + name: Ruby 4.0.0 - faraday-2 needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: @@ -558,15 +558,16 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &9 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/que-0.14.gemfile - ruby_4-0-0__que-1_ubuntu-latest: - name: Ruby 4.0.0 - que-1 + BUNDLE_GEMFILE: gemfiles/faraday-2.gemfile + ruby_4-0-0__faraday-2-collector_ubuntu-latest: + name: Ruby 4.0.0 - faraday-2-collector needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: @@ -585,15 +586,14 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *9 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/que-1.gemfile - ruby_4-0-0__que-2_ubuntu-latest: - name: Ruby 4.0.0 - que-2 + BUNDLE_GEMFILE: gemfiles/faraday-2-collector.gemfile + ruby_4-0-0__grape_ubuntu-latest: + name: Ruby 4.0.0 - grape needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: @@ -612,15 +612,16 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &10 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/que-2.gemfile - ruby_4-0-0__rails-7-0_ubuntu-latest: - name: Ruby 4.0.0 - rails-7.0 + BUNDLE_GEMFILE: gemfiles/grape.gemfile + ruby_4-0-0__grape-collector_ubuntu-latest: + name: Ruby 4.0.0 - grape-collector needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: @@ -639,15 +640,14 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *10 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/rails-7.0.gemfile - ruby_4-0-0__rails-7-1_ubuntu-latest: - name: Ruby 4.0.0 - rails-7.1 + BUNDLE_GEMFILE: gemfiles/grape-collector.gemfile + ruby_4-0-0__http5_ubuntu-latest: + name: Ruby 4.0.0 - http5 needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: @@ -666,15 +666,16 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &11 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/rails-7.1.gemfile - ruby_4-0-0__rails-7-2_ubuntu-latest: - name: Ruby 4.0.0 - rails-7.2 + BUNDLE_GEMFILE: gemfiles/http5.gemfile + ruby_4-0-0__http5-collector_ubuntu-latest: + name: Ruby 4.0.0 - http5-collector needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: @@ -693,15 +694,14 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *11 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/rails-7.2.gemfile - ruby_4-0-0__rails-8-0_ubuntu-latest: - name: Ruby 4.0.0 - rails-8.0 + BUNDLE_GEMFILE: gemfiles/http5-collector.gemfile + ruby_4-0-0__http6_ubuntu-latest: + name: Ruby 4.0.0 - http6 needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: @@ -720,15 +720,16 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &12 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/rails-8.0.gemfile - ruby_4-0-0__resque-2_ubuntu-latest: - name: Ruby 4.0.0 - resque-2 + BUNDLE_GEMFILE: gemfiles/http6.gemfile + ruby_4-0-0__http6-collector_ubuntu-latest: + name: Ruby 4.0.0 - http6-collector needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: @@ -747,15 +748,14 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *12 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/resque-2.gemfile - ruby_4-0-0__resque-3_ubuntu-latest: - name: Ruby 4.0.0 - resque-3 + BUNDLE_GEMFILE: gemfiles/http6-collector.gemfile + ruby_4-0-0__mongo_ubuntu-latest: + name: Ruby 4.0.0 - mongo needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: @@ -774,15 +774,16 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &13 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/resque-3.gemfile - ruby_4-0-0__sequel_ubuntu-latest: - name: Ruby 4.0.0 - sequel + BUNDLE_GEMFILE: gemfiles/mongo.gemfile + ruby_4-0-0__mongo-collector_ubuntu-latest: + name: Ruby 4.0.0 - mongo-collector needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: @@ -801,15 +802,14 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *13 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/sequel.gemfile - ruby_4-0-0__shoryuken-7_ubuntu-latest: - name: Ruby 4.0.0 - shoryuken-7 + BUNDLE_GEMFILE: gemfiles/mongo-collector.gemfile + ruby_4-0-0__ownership_ubuntu-latest: + name: Ruby 4.0.0 - ownership needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: @@ -828,15 +828,16 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &14 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/shoryuken-7.gemfile - ruby_4-0-0__sinatra_ubuntu-latest: - name: Ruby 4.0.0 - sinatra + BUNDLE_GEMFILE: gemfiles/ownership.gemfile + ruby_4-0-0__ownership-collector_ubuntu-latest: + name: Ruby 4.0.0 - ownership-collector needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: @@ -855,15 +856,14 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *14 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/sinatra.gemfile - ruby_4-0-0__webmachine2_ubuntu-latest: - name: Ruby 4.0.0 - webmachine2 + BUNDLE_GEMFILE: gemfiles/ownership-collector.gemfile + ruby_4-0-0__psych-3_ubuntu-latest: + name: Ruby 4.0.0 - psych-3 needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: @@ -882,15 +882,16 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &15 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/webmachine2.gemfile - ruby_4-0-0__redis-4_ubuntu-latest: - name: Ruby 4.0.0 - redis-4 + BUNDLE_GEMFILE: gemfiles/psych-3.gemfile + ruby_4-0-0__psych-3-collector_ubuntu-latest: + name: Ruby 4.0.0 - psych-3-collector needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: @@ -909,15 +910,14 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *15 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/redis-4.gemfile - ruby_4-0-0__redis-5_ubuntu-latest: - name: Ruby 4.0.0 - redis-5 + BUNDLE_GEMFILE: gemfiles/psych-3-collector.gemfile + ruby_4-0-0__psych-4_ubuntu-latest: + name: Ruby 4.0.0 - psych-4 needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: @@ -936,15 +936,16 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &16 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/redis-5.gemfile - ruby_4-0-0__sidekiq-7_ubuntu-latest: - name: Ruby 4.0.0 - sidekiq-7 + BUNDLE_GEMFILE: gemfiles/psych-4.gemfile + ruby_4-0-0__psych-4-collector_ubuntu-latest: + name: Ruby 4.0.0 - psych-4-collector needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: @@ -963,15 +964,14 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *16 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/sidekiq-7.gemfile - ruby_4-0-0__sidekiq-8_ubuntu-latest: - name: Ruby 4.0.0 - sidekiq-8 + BUNDLE_GEMFILE: gemfiles/psych-4-collector.gemfile + ruby_4-0-0__que-0-14_ubuntu-latest: + name: Ruby 4.0.0 - que-0.14 needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: @@ -990,17 +990,18 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &17 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/sidekiq-8.gemfile - ruby_4-0-0_macos-14: - name: Ruby 4.0.0 (macos-14) - needs: validation - runs-on: macos-14 + BUNDLE_GEMFILE: gemfiles/que-0.14.gemfile + ruby_4-0-0__que-0-14-collector_ubuntu-latest: + name: Ruby 4.0.0 - que-0.14-collector + needs: ruby_4-0-0_ubuntu-latest + runs-on: ubuntu-latest steps: - name: Check out repository uses: actions/checkout@v4 @@ -1017,18 +1018,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" - - name: Run tests without extension - run: "./script/bundler_wrapper exec rake test:failure" + - *17 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/no_dependencies.gemfile - ruby_3-4-1_ubuntu-latest: - name: Ruby 3.4.1 - needs: validation + BUNDLE_GEMFILE: gemfiles/que-0.14-collector.gemfile + ruby_4-0-0__que-1_ubuntu-latest: + name: Ruby 4.0.0 - que-1 + needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -1036,7 +1034,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.4.1 + ruby-version: 4.0.0 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -1046,18 +1044,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &18 + name: Run tests run: "./script/bundler_wrapper exec rake test" - - name: Run tests without extension - run: "./script/bundler_wrapper exec rake test:failure" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/no_dependencies.gemfile - ruby_3-4-1__capistrano2_ubuntu-latest: - name: Ruby 3.4.1 - capistrano2 - needs: ruby_3-4-1_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/que-1.gemfile + ruby_4-0-0__que-1-collector_ubuntu-latest: + name: Ruby 4.0.0 - que-1-collector + needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -1065,7 +1062,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.4.1 + ruby-version: 4.0.0 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -1075,16 +1072,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *18 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/capistrano2.gemfile - ruby_3-4-1__capistrano3_ubuntu-latest: - name: Ruby 3.4.1 - capistrano3 - needs: ruby_3-4-1_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/que-1-collector.gemfile + ruby_4-0-0__que-2_ubuntu-latest: + name: Ruby 4.0.0 - que-2 + needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -1092,7 +1088,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.4.1 + ruby-version: 4.0.0 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -1102,16 +1098,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &19 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/capistrano3.gemfile - ruby_3-4-1__code_ownership_ubuntu-latest: - name: Ruby 3.4.1 - code_ownership - needs: ruby_3-4-1_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/que-2.gemfile + ruby_4-0-0__que-2-collector_ubuntu-latest: + name: Ruby 4.0.0 - que-2-collector + needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -1119,7 +1116,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.4.1 + ruby-version: 4.0.0 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -1129,16 +1126,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *19 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/code_ownership.gemfile - ruby_3-4-1__delayed_job_ubuntu-latest: - name: Ruby 3.4.1 - delayed_job - needs: ruby_3-4-1_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/que-2-collector.gemfile + ruby_4-0-0__rails-7-0_ubuntu-latest: + name: Ruby 4.0.0 - rails-7.0 + needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -1146,7 +1142,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.4.1 + ruby-version: 4.0.0 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -1156,16 +1152,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &20 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/delayed_job.gemfile - ruby_3-4-1__dry-monitor_ubuntu-latest: - name: Ruby 3.4.1 - dry-monitor - needs: ruby_3-4-1_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/rails-7.0.gemfile + ruby_4-0-0__rails-7-0-collector_ubuntu-latest: + name: Ruby 4.0.0 - rails-7.0-collector + needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -1173,7 +1170,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.4.1 + ruby-version: 4.0.0 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -1183,16 +1180,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *20 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/dry-monitor.gemfile - ruby_3-4-1__excon_ubuntu-latest: - name: Ruby 3.4.1 - excon - needs: ruby_3-4-1_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/rails-7.0-collector.gemfile + ruby_4-0-0__rails-7-1_ubuntu-latest: + name: Ruby 4.0.0 - rails-7.1 + needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -1200,7 +1196,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.4.1 + ruby-version: 4.0.0 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -1210,16 +1206,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &21 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/excon.gemfile - ruby_3-4-1__faraday-1_ubuntu-latest: - name: Ruby 3.4.1 - faraday-1 - needs: ruby_3-4-1_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/rails-7.1.gemfile + ruby_4-0-0__rails-7-1-collector_ubuntu-latest: + name: Ruby 4.0.0 - rails-7.1-collector + needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -1227,7 +1224,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.4.1 + ruby-version: 4.0.0 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -1237,16 +1234,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *21 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/faraday-1.gemfile - ruby_3-4-1__faraday-2_ubuntu-latest: - name: Ruby 3.4.1 - faraday-2 - needs: ruby_3-4-1_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/rails-7.1-collector.gemfile + ruby_4-0-0__rails-7-2_ubuntu-latest: + name: Ruby 4.0.0 - rails-7.2 + needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -1254,7 +1250,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.4.1 + ruby-version: 4.0.0 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -1264,16 +1260,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &22 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/faraday-2.gemfile - ruby_3-4-1__grape_ubuntu-latest: - name: Ruby 3.4.1 - grape - needs: ruby_3-4-1_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/rails-7.2.gemfile + ruby_4-0-0__rails-7-2-collector_ubuntu-latest: + name: Ruby 4.0.0 - rails-7.2-collector + needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -1281,7 +1278,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.4.1 + ruby-version: 4.0.0 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -1291,16 +1288,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *22 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/grape.gemfile - ruby_3-4-1__hanami-2-0_ubuntu-latest: - name: Ruby 3.4.1 - hanami-2.0 - needs: ruby_3-4-1_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/rails-7.2-collector.gemfile + ruby_4-0-0__rails-8-0_ubuntu-latest: + name: Ruby 4.0.0 - rails-8.0 + needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -1308,7 +1304,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.4.1 + ruby-version: 4.0.0 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -1318,16 +1314,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &23 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/hanami-2.0.gemfile - ruby_3-4-1__hanami-2-1_ubuntu-latest: - name: Ruby 3.4.1 - hanami-2.1 - needs: ruby_3-4-1_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/rails-8.0.gemfile + ruby_4-0-0__rails-8-0-collector_ubuntu-latest: + name: Ruby 4.0.0 - rails-8.0-collector + needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -1335,7 +1332,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.4.1 + ruby-version: 4.0.0 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -1345,16 +1342,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *23 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/hanami-2.1.gemfile - ruby_3-4-1__hanami-2-2_ubuntu-latest: - name: Ruby 3.4.1 - hanami-2.2 - needs: ruby_3-4-1_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/rails-8.0-collector.gemfile + ruby_4-0-0__resque-2_ubuntu-latest: + name: Ruby 4.0.0 - resque-2 + needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -1362,7 +1358,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.4.1 + ruby-version: 4.0.0 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -1372,16 +1368,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &24 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/hanami-2.2.gemfile - ruby_3-4-1__http5_ubuntu-latest: - name: Ruby 3.4.1 - http5 - needs: ruby_3-4-1_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/resque-2.gemfile + ruby_4-0-0__resque-2-collector_ubuntu-latest: + name: Ruby 4.0.0 - resque-2-collector + needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -1389,7 +1386,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.4.1 + ruby-version: 4.0.0 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -1399,16 +1396,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *24 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/http5.gemfile - ruby_3-4-1__http6_ubuntu-latest: - name: Ruby 3.4.1 - http6 - needs: ruby_3-4-1_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/resque-2-collector.gemfile + ruby_4-0-0__resque-3_ubuntu-latest: + name: Ruby 4.0.0 - resque-3 + needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -1416,7 +1412,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.4.1 + ruby-version: 4.0.0 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -1426,16 +1422,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &25 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/http6.gemfile - ruby_3-4-1__mongo_ubuntu-latest: - name: Ruby 3.4.1 - mongo - needs: ruby_3-4-1_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/resque-3.gemfile + ruby_4-0-0__resque-3-collector_ubuntu-latest: + name: Ruby 4.0.0 - resque-3-collector + needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -1443,7 +1440,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.4.1 + ruby-version: 4.0.0 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -1453,16 +1450,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *25 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/mongo.gemfile - ruby_3-4-1__ownership_ubuntu-latest: - name: Ruby 3.4.1 - ownership - needs: ruby_3-4-1_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/resque-3-collector.gemfile + ruby_4-0-0__sequel_ubuntu-latest: + name: Ruby 4.0.0 - sequel + needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -1470,7 +1466,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.4.1 + ruby-version: 4.0.0 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -1480,16 +1476,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &26 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/ownership.gemfile - ruby_3-4-1__padrino_ubuntu-latest: - name: Ruby 3.4.1 - padrino - needs: ruby_3-4-1_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/sequel.gemfile + ruby_4-0-0__sequel-collector_ubuntu-latest: + name: Ruby 4.0.0 - sequel-collector + needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -1497,7 +1494,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.4.1 + ruby-version: 4.0.0 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -1507,16 +1504,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *26 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/padrino.gemfile - ruby_3-4-1__psych-3_ubuntu-latest: - name: Ruby 3.4.1 - psych-3 - needs: ruby_3-4-1_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/sequel-collector.gemfile + ruby_4-0-0__shoryuken-7_ubuntu-latest: + name: Ruby 4.0.0 - shoryuken-7 + needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -1524,7 +1520,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.4.1 + ruby-version: 4.0.0 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -1534,16 +1530,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &27 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/psych-3.gemfile - ruby_3-4-1__psych-4_ubuntu-latest: - name: Ruby 3.4.1 - psych-4 - needs: ruby_3-4-1_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/shoryuken-7.gemfile + ruby_4-0-0__shoryuken-7-collector_ubuntu-latest: + name: Ruby 4.0.0 - shoryuken-7-collector + needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -1551,7 +1548,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.4.1 + ruby-version: 4.0.0 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -1561,16 +1558,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *27 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/psych-4.gemfile - ruby_3-4-1__que-0-14_ubuntu-latest: - name: Ruby 3.4.1 - que-0.14 - needs: ruby_3-4-1_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/shoryuken-7-collector.gemfile + ruby_4-0-0__sinatra_ubuntu-latest: + name: Ruby 4.0.0 - sinatra + needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -1578,7 +1574,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.4.1 + ruby-version: 4.0.0 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -1588,16 +1584,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &28 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/que-0.14.gemfile - ruby_3-4-1__que-1_ubuntu-latest: - name: Ruby 3.4.1 - que-1 - needs: ruby_3-4-1_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/sinatra.gemfile + ruby_4-0-0__sinatra-collector_ubuntu-latest: + name: Ruby 4.0.0 - sinatra-collector + needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -1605,7 +1602,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.4.1 + ruby-version: 4.0.0 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -1615,16 +1612,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *28 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/que-1.gemfile - ruby_3-4-1__que-2_ubuntu-latest: - name: Ruby 3.4.1 - que-2 - needs: ruby_3-4-1_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/sinatra-collector.gemfile + ruby_4-0-0__webmachine2_ubuntu-latest: + name: Ruby 4.0.0 - webmachine2 + needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -1632,7 +1628,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.4.1 + ruby-version: 4.0.0 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -1642,16 +1638,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &29 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/que-2.gemfile - ruby_3-4-1__rails-7-0_ubuntu-latest: - name: Ruby 3.4.1 - rails-7.0 - needs: ruby_3-4-1_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/webmachine2.gemfile + ruby_4-0-0__webmachine2-collector_ubuntu-latest: + name: Ruby 4.0.0 - webmachine2-collector + needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -1659,7 +1656,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.4.1 + ruby-version: 4.0.0 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -1669,16 +1666,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *29 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/rails-7.0.gemfile - ruby_3-4-1__rails-7-1_ubuntu-latest: - name: Ruby 3.4.1 - rails-7.1 - needs: ruby_3-4-1_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/webmachine2-collector.gemfile + ruby_4-0-0__redis-4_ubuntu-latest: + name: Ruby 4.0.0 - redis-4 + needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -1686,7 +1682,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.4.1 + ruby-version: 4.0.0 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -1696,16 +1692,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &30 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/rails-7.1.gemfile - ruby_3-4-1__rails-7-2_ubuntu-latest: - name: Ruby 3.4.1 - rails-7.2 - needs: ruby_3-4-1_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/redis-4.gemfile + ruby_4-0-0__redis-4-collector_ubuntu-latest: + name: Ruby 4.0.0 - redis-4-collector + needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -1713,7 +1710,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.4.1 + ruby-version: 4.0.0 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -1723,16 +1720,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *30 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/rails-7.2.gemfile - ruby_3-4-1__rails-8-0_ubuntu-latest: - name: Ruby 3.4.1 - rails-8.0 - needs: ruby_3-4-1_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/redis-4-collector.gemfile + ruby_4-0-0__redis-5_ubuntu-latest: + name: Ruby 4.0.0 - redis-5 + needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -1740,7 +1736,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.4.1 + ruby-version: 4.0.0 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -1750,16 +1746,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &31 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/rails-8.0.gemfile - ruby_3-4-1__rails-8-1_ubuntu-latest: - name: Ruby 3.4.1 - rails-8.1 - needs: ruby_3-4-1_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/redis-5.gemfile + ruby_4-0-0__redis-5-collector_ubuntu-latest: + name: Ruby 4.0.0 - redis-5-collector + needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -1767,7 +1764,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.4.1 + ruby-version: 4.0.0 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -1777,16 +1774,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *31 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/rails-8.1.gemfile - ruby_3-4-1__resque-2_ubuntu-latest: - name: Ruby 3.4.1 - resque-2 - needs: ruby_3-4-1_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/redis-5-collector.gemfile + ruby_4-0-0__sidekiq-7_ubuntu-latest: + name: Ruby 4.0.0 - sidekiq-7 + needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -1794,7 +1790,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.4.1 + ruby-version: 4.0.0 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -1804,16 +1800,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &32 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/resque-2.gemfile - ruby_3-4-1__resque-3_ubuntu-latest: - name: Ruby 3.4.1 - resque-3 - needs: ruby_3-4-1_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/sidekiq-7.gemfile + ruby_4-0-0__sidekiq-7-collector_ubuntu-latest: + name: Ruby 4.0.0 - sidekiq-7-collector + needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -1821,7 +1818,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.4.1 + ruby-version: 4.0.0 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -1831,16 +1828,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *32 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/resque-3.gemfile - ruby_3-4-1__sequel_ubuntu-latest: - name: Ruby 3.4.1 - sequel - needs: ruby_3-4-1_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/sidekiq-7-collector.gemfile + ruby_4-0-0__sidekiq-8_ubuntu-latest: + name: Ruby 4.0.0 - sidekiq-8 + needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -1848,7 +1844,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.4.1 + ruby-version: 4.0.0 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -1858,16 +1854,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &33 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/sequel.gemfile - ruby_3-4-1__shoryuken-7_ubuntu-latest: - name: Ruby 3.4.1 - shoryuken-7 - needs: ruby_3-4-1_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/sidekiq-8.gemfile + ruby_4-0-0__sidekiq-8-collector_ubuntu-latest: + name: Ruby 4.0.0 - sidekiq-8-collector + needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -1875,7 +1872,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.4.1 + ruby-version: 4.0.0 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -1885,24 +1882,23 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *33 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/shoryuken-7.gemfile - ruby_3-4-1__sinatra_ubuntu-latest: - name: Ruby 3.4.1 - sinatra - needs: ruby_3-4-1_ubuntu-latest - runs-on: ubuntu-latest + BUNDLE_GEMFILE: gemfiles/sidekiq-8-collector.gemfile + ruby_4-0-0_macos-14: + name: Ruby 4.0.0 (macos-14) + needs: validation + runs-on: macos-14 steps: - name: Check out repository uses: actions/checkout@v4 - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.4.1 + ruby-version: 4.0.0 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -1914,14 +1910,16 @@ jobs: found'" - name: Run tests run: "./script/bundler_wrapper exec rake test" + - name: Run tests without extension + run: "./script/bundler_wrapper exec rake test:failure" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/sinatra.gemfile - ruby_3-4-1__webmachine2_ubuntu-latest: - name: Ruby 3.4.1 - webmachine2 - needs: ruby_3-4-1_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/no_dependencies.gemfile + ruby_3-4-1_ubuntu-latest: + name: Ruby 3.4.1 + needs: validation runs-on: ubuntu-latest steps: - name: Check out repository @@ -1939,15 +1937,18 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &34 + name: Run tests run: "./script/bundler_wrapper exec rake test" + - name: Run tests without extension + run: "./script/bundler_wrapper exec rake test:failure" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/webmachine2.gemfile - ruby_3-4-1__redis-4_ubuntu-latest: - name: Ruby 3.4.1 - redis-4 + BUNDLE_GEMFILE: gemfiles/no_dependencies.gemfile + ruby_3-4-1__no_dependencies-collector_ubuntu-latest: + name: Ruby 3.4.1 - no_dependencies-collector needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: @@ -1966,15 +1967,14 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *34 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/redis-4.gemfile - ruby_3-4-1__redis-5_ubuntu-latest: - name: Ruby 3.4.1 - redis-5 + BUNDLE_GEMFILE: gemfiles/no_dependencies-collector.gemfile + ruby_3-4-1__capistrano2_ubuntu-latest: + name: Ruby 3.4.1 - capistrano2 needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: @@ -1993,15 +1993,16 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &35 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/redis-5.gemfile - ruby_3-4-1__sidekiq-7_ubuntu-latest: - name: Ruby 3.4.1 - sidekiq-7 + BUNDLE_GEMFILE: gemfiles/capistrano2.gemfile + ruby_3-4-1__capistrano2-collector_ubuntu-latest: + name: Ruby 3.4.1 - capistrano2-collector needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: @@ -2020,15 +2021,14 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *35 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/sidekiq-7.gemfile - ruby_3-4-1__sidekiq-8_ubuntu-latest: - name: Ruby 3.4.1 - sidekiq-8 + BUNDLE_GEMFILE: gemfiles/capistrano2-collector.gemfile + ruby_3-4-1__capistrano3_ubuntu-latest: + name: Ruby 3.4.1 - capistrano3 needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: @@ -2047,17 +2047,18 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &36 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/sidekiq-8.gemfile - ruby_3-4-1_macos-14: - name: Ruby 3.4.1 (macos-14) - needs: validation - runs-on: macos-14 + BUNDLE_GEMFILE: gemfiles/capistrano3.gemfile + ruby_3-4-1__capistrano3-collector_ubuntu-latest: + name: Ruby 3.4.1 - capistrano3-collector + needs: ruby_3-4-1_ubuntu-latest + runs-on: ubuntu-latest steps: - name: Check out repository uses: actions/checkout@v4 @@ -2074,18 +2075,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" - - name: Run tests without extension - run: "./script/bundler_wrapper exec rake test:failure" + - *36 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/no_dependencies.gemfile - ruby_3-3-4_ubuntu-latest: - name: Ruby 3.3.4 - needs: validation + BUNDLE_GEMFILE: gemfiles/capistrano3-collector.gemfile + ruby_3-4-1__code_ownership_ubuntu-latest: + name: Ruby 3.4.1 - code_ownership + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -2093,7 +2091,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.3.4 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -2103,18 +2101,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &37 + name: Run tests run: "./script/bundler_wrapper exec rake test" - - name: Run tests without extension - run: "./script/bundler_wrapper exec rake test:failure" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/no_dependencies.gemfile - ruby_3-3-4__capistrano2_ubuntu-latest: - name: Ruby 3.3.4 - capistrano2 - needs: ruby_3-3-4_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/code_ownership.gemfile + ruby_3-4-1__code_ownership-collector_ubuntu-latest: + name: Ruby 3.4.1 - code_ownership-collector + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -2122,7 +2119,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.3.4 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -2132,16 +2129,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *37 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/capistrano2.gemfile - ruby_3-3-4__capistrano3_ubuntu-latest: - name: Ruby 3.3.4 - capistrano3 - needs: ruby_3-3-4_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/code_ownership-collector.gemfile + ruby_3-4-1__delayed_job_ubuntu-latest: + name: Ruby 3.4.1 - delayed_job + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -2149,7 +2145,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.3.4 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -2159,16 +2155,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &38 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/capistrano3.gemfile - ruby_3-3-4__code_ownership_ubuntu-latest: - name: Ruby 3.3.4 - code_ownership - needs: ruby_3-3-4_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/delayed_job.gemfile + ruby_3-4-1__delayed_job-collector_ubuntu-latest: + name: Ruby 3.4.1 - delayed_job-collector + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -2176,7 +2173,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.3.4 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -2186,16 +2183,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *38 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/code_ownership.gemfile - ruby_3-3-4__delayed_job_ubuntu-latest: - name: Ruby 3.3.4 - delayed_job - needs: ruby_3-3-4_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/delayed_job-collector.gemfile + ruby_3-4-1__dry-monitor_ubuntu-latest: + name: Ruby 3.4.1 - dry-monitor + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -2203,7 +2199,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.3.4 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -2213,16 +2209,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &39 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/delayed_job.gemfile - ruby_3-3-4__dry-monitor_ubuntu-latest: - name: Ruby 3.3.4 - dry-monitor - needs: ruby_3-3-4_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/dry-monitor.gemfile + ruby_3-4-1__dry-monitor-collector_ubuntu-latest: + name: Ruby 3.4.1 - dry-monitor-collector + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -2230,7 +2227,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.3.4 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -2240,16 +2237,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *39 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/dry-monitor.gemfile - ruby_3-3-4__excon_ubuntu-latest: - name: Ruby 3.3.4 - excon - needs: ruby_3-3-4_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/dry-monitor-collector.gemfile + ruby_3-4-1__excon_ubuntu-latest: + name: Ruby 3.4.1 - excon + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -2257,7 +2253,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.3.4 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -2267,16 +2263,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &40 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' BUNDLE_GEMFILE: gemfiles/excon.gemfile - ruby_3-3-4__faraday-1_ubuntu-latest: - name: Ruby 3.3.4 - faraday-1 - needs: ruby_3-3-4_ubuntu-latest + ruby_3-4-1__excon-collector_ubuntu-latest: + name: Ruby 3.4.1 - excon-collector + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -2284,7 +2281,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.3.4 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -2294,16 +2291,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *40 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/faraday-1.gemfile - ruby_3-3-4__faraday-2_ubuntu-latest: - name: Ruby 3.3.4 - faraday-2 - needs: ruby_3-3-4_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/excon-collector.gemfile + ruby_3-4-1__faraday-1_ubuntu-latest: + name: Ruby 3.4.1 - faraday-1 + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -2311,7 +2307,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.3.4 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -2321,16 +2317,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &41 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/faraday-2.gemfile - ruby_3-3-4__grape_ubuntu-latest: - name: Ruby 3.3.4 - grape - needs: ruby_3-3-4_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/faraday-1.gemfile + ruby_3-4-1__faraday-1-collector_ubuntu-latest: + name: Ruby 3.4.1 - faraday-1-collector + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -2338,7 +2335,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.3.4 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -2348,16 +2345,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *41 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/grape.gemfile - ruby_3-3-4__hanami-2-0_ubuntu-latest: - name: Ruby 3.3.4 - hanami-2.0 - needs: ruby_3-3-4_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/faraday-1-collector.gemfile + ruby_3-4-1__faraday-2_ubuntu-latest: + name: Ruby 3.4.1 - faraday-2 + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -2365,7 +2361,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.3.4 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -2375,16 +2371,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &42 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/hanami-2.0.gemfile - ruby_3-3-4__hanami-2-1_ubuntu-latest: - name: Ruby 3.3.4 - hanami-2.1 - needs: ruby_3-3-4_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/faraday-2.gemfile + ruby_3-4-1__faraday-2-collector_ubuntu-latest: + name: Ruby 3.4.1 - faraday-2-collector + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -2392,7 +2389,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.3.4 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -2402,16 +2399,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *42 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/hanami-2.1.gemfile - ruby_3-3-4__hanami-2-2_ubuntu-latest: - name: Ruby 3.3.4 - hanami-2.2 - needs: ruby_3-3-4_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/faraday-2-collector.gemfile + ruby_3-4-1__grape_ubuntu-latest: + name: Ruby 3.4.1 - grape + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -2419,7 +2415,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.3.4 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -2429,16 +2425,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &43 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/hanami-2.2.gemfile - ruby_3-3-4__http5_ubuntu-latest: - name: Ruby 3.3.4 - http5 - needs: ruby_3-3-4_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/grape.gemfile + ruby_3-4-1__grape-collector_ubuntu-latest: + name: Ruby 3.4.1 - grape-collector + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -2446,7 +2443,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.3.4 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -2456,16 +2453,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *43 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/http5.gemfile - ruby_3-3-4__http6_ubuntu-latest: - name: Ruby 3.3.4 - http6 - needs: ruby_3-3-4_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/grape-collector.gemfile + ruby_3-4-1__hanami-2-0_ubuntu-latest: + name: Ruby 3.4.1 - hanami-2.0 + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -2473,7 +2469,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.3.4 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -2483,16 +2479,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &44 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/http6.gemfile - ruby_3-3-4__mongo_ubuntu-latest: - name: Ruby 3.3.4 - mongo - needs: ruby_3-3-4_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/hanami-2.0.gemfile + ruby_3-4-1__hanami-2-0-collector_ubuntu-latest: + name: Ruby 3.4.1 - hanami-2.0-collector + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -2500,7 +2497,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.3.4 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -2510,16 +2507,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *44 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/mongo.gemfile - ruby_3-3-4__ownership_ubuntu-latest: - name: Ruby 3.3.4 - ownership - needs: ruby_3-3-4_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/hanami-2.0-collector.gemfile + ruby_3-4-1__hanami-2-1_ubuntu-latest: + name: Ruby 3.4.1 - hanami-2.1 + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -2527,7 +2523,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.3.4 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -2537,16 +2533,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &45 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/ownership.gemfile - ruby_3-3-4__padrino_ubuntu-latest: - name: Ruby 3.3.4 - padrino - needs: ruby_3-3-4_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/hanami-2.1.gemfile + ruby_3-4-1__hanami-2-1-collector_ubuntu-latest: + name: Ruby 3.4.1 - hanami-2.1-collector + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -2554,7 +2551,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.3.4 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -2564,16 +2561,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *45 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/padrino.gemfile - ruby_3-3-4__psych-3_ubuntu-latest: - name: Ruby 3.3.4 - psych-3 - needs: ruby_3-3-4_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/hanami-2.1-collector.gemfile + ruby_3-4-1__hanami-2-2_ubuntu-latest: + name: Ruby 3.4.1 - hanami-2.2 + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -2581,7 +2577,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.3.4 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -2591,16 +2587,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &46 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/psych-3.gemfile - ruby_3-3-4__psych-4_ubuntu-latest: - name: Ruby 3.3.4 - psych-4 - needs: ruby_3-3-4_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/hanami-2.2.gemfile + ruby_3-4-1__hanami-2-2-collector_ubuntu-latest: + name: Ruby 3.4.1 - hanami-2.2-collector + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -2608,7 +2605,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.3.4 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -2618,16 +2615,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *46 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/psych-4.gemfile - ruby_3-3-4__que-0-14_ubuntu-latest: - name: Ruby 3.3.4 - que-0.14 - needs: ruby_3-3-4_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/hanami-2.2-collector.gemfile + ruby_3-4-1__http5_ubuntu-latest: + name: Ruby 3.4.1 - http5 + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -2635,7 +2631,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.3.4 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -2645,16 +2641,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &47 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/que-0.14.gemfile - ruby_3-3-4__que-1_ubuntu-latest: - name: Ruby 3.3.4 - que-1 - needs: ruby_3-3-4_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/http5.gemfile + ruby_3-4-1__http5-collector_ubuntu-latest: + name: Ruby 3.4.1 - http5-collector + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -2662,7 +2659,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.3.4 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -2672,16 +2669,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *47 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/que-1.gemfile - ruby_3-3-4__que-2_ubuntu-latest: - name: Ruby 3.3.4 - que-2 - needs: ruby_3-3-4_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/http5-collector.gemfile + ruby_3-4-1__http6_ubuntu-latest: + name: Ruby 3.4.1 - http6 + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -2689,7 +2685,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.3.4 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -2699,16 +2695,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &48 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/que-2.gemfile - ruby_3-3-4__rails-6-1_ubuntu-latest: - name: Ruby 3.3.4 - rails-6.1 - needs: ruby_3-3-4_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/http6.gemfile + ruby_3-4-1__http6-collector_ubuntu-latest: + name: Ruby 3.4.1 - http6-collector + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -2716,7 +2713,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.3.4 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -2726,16 +2723,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *48 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/rails-6.1.gemfile - ruby_3-3-4__rails-7-0_ubuntu-latest: - name: Ruby 3.3.4 - rails-7.0 - needs: ruby_3-3-4_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/http6-collector.gemfile + ruby_3-4-1__mongo_ubuntu-latest: + name: Ruby 3.4.1 - mongo + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -2743,7 +2739,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.3.4 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -2753,16 +2749,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &49 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/rails-7.0.gemfile - ruby_3-3-4__rails-7-1_ubuntu-latest: - name: Ruby 3.3.4 - rails-7.1 - needs: ruby_3-3-4_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/mongo.gemfile + ruby_3-4-1__mongo-collector_ubuntu-latest: + name: Ruby 3.4.1 - mongo-collector + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -2770,7 +2767,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.3.4 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -2780,16 +2777,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *49 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/rails-7.1.gemfile - ruby_3-3-4__rails-7-2_ubuntu-latest: - name: Ruby 3.3.4 - rails-7.2 - needs: ruby_3-3-4_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/mongo-collector.gemfile + ruby_3-4-1__ownership_ubuntu-latest: + name: Ruby 3.4.1 - ownership + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -2797,7 +2793,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.3.4 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -2807,16 +2803,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &50 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/rails-7.2.gemfile - ruby_3-3-4__rails-8-0_ubuntu-latest: - name: Ruby 3.3.4 - rails-8.0 - needs: ruby_3-3-4_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/ownership.gemfile + ruby_3-4-1__ownership-collector_ubuntu-latest: + name: Ruby 3.4.1 - ownership-collector + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -2824,7 +2821,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.3.4 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -2834,16 +2831,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *50 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/rails-8.0.gemfile - ruby_3-3-4__rails-8-1_ubuntu-latest: - name: Ruby 3.3.4 - rails-8.1 - needs: ruby_3-3-4_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/ownership-collector.gemfile + ruby_3-4-1__padrino_ubuntu-latest: + name: Ruby 3.4.1 - padrino + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -2851,7 +2847,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.3.4 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -2861,16 +2857,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &51 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/rails-8.1.gemfile - ruby_3-3-4__resque-2_ubuntu-latest: - name: Ruby 3.3.4 - resque-2 - needs: ruby_3-3-4_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/padrino.gemfile + ruby_3-4-1__padrino-collector_ubuntu-latest: + name: Ruby 3.4.1 - padrino-collector + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -2878,7 +2875,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.3.4 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -2888,16 +2885,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *51 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/resque-2.gemfile - ruby_3-3-4__resque-3_ubuntu-latest: - name: Ruby 3.3.4 - resque-3 - needs: ruby_3-3-4_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/padrino-collector.gemfile + ruby_3-4-1__psych-3_ubuntu-latest: + name: Ruby 3.4.1 - psych-3 + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -2905,7 +2901,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.3.4 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -2915,16 +2911,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &52 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/resque-3.gemfile - ruby_3-3-4__sequel_ubuntu-latest: - name: Ruby 3.3.4 - sequel - needs: ruby_3-3-4_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/psych-3.gemfile + ruby_3-4-1__psych-3-collector_ubuntu-latest: + name: Ruby 3.4.1 - psych-3-collector + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -2932,7 +2929,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.3.4 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -2942,16 +2939,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *52 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/sequel.gemfile - ruby_3-3-4__shoryuken-7_ubuntu-latest: - name: Ruby 3.3.4 - shoryuken-7 - needs: ruby_3-3-4_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/psych-3-collector.gemfile + ruby_3-4-1__psych-4_ubuntu-latest: + name: Ruby 3.4.1 - psych-4 + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -2959,7 +2955,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.3.4 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -2969,16 +2965,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &53 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/shoryuken-7.gemfile - ruby_3-3-4__sinatra_ubuntu-latest: - name: Ruby 3.3.4 - sinatra - needs: ruby_3-3-4_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/psych-4.gemfile + ruby_3-4-1__psych-4-collector_ubuntu-latest: + name: Ruby 3.4.1 - psych-4-collector + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -2986,7 +2983,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.3.4 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -2996,16 +2993,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *53 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/sinatra.gemfile - ruby_3-3-4__webmachine2_ubuntu-latest: - name: Ruby 3.3.4 - webmachine2 - needs: ruby_3-3-4_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/psych-4-collector.gemfile + ruby_3-4-1__que-0-14_ubuntu-latest: + name: Ruby 3.4.1 - que-0.14 + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -3013,7 +3009,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.3.4 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -3023,16 +3019,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &54 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/webmachine2.gemfile - ruby_3-3-4__redis-4_ubuntu-latest: - name: Ruby 3.3.4 - redis-4 - needs: ruby_3-3-4_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/que-0.14.gemfile + ruby_3-4-1__que-0-14-collector_ubuntu-latest: + name: Ruby 3.4.1 - que-0.14-collector + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -3040,7 +3037,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.3.4 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -3050,16 +3047,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *54 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/redis-4.gemfile - ruby_3-3-4__redis-5_ubuntu-latest: - name: Ruby 3.3.4 - redis-5 - needs: ruby_3-3-4_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/que-0.14-collector.gemfile + ruby_3-4-1__que-1_ubuntu-latest: + name: Ruby 3.4.1 - que-1 + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -3067,7 +3063,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.3.4 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -3077,24 +3073,25 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &55 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/redis-5.gemfile - ruby_3-3-4_macos-14: - name: Ruby 3.3.4 (macos-14) - needs: validation - runs-on: macos-14 + BUNDLE_GEMFILE: gemfiles/que-1.gemfile + ruby_3-4-1__que-1-collector_ubuntu-latest: + name: Ruby 3.4.1 - que-1-collector + needs: ruby_3-4-1_ubuntu-latest + runs-on: ubuntu-latest steps: - name: Check out repository uses: actions/checkout@v4 - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.3.4 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -3104,18 +3101,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" - - name: Run tests without extension - run: "./script/bundler_wrapper exec rake test:failure" + - *55 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/no_dependencies.gemfile - ruby_3-2-5_ubuntu-latest: - name: Ruby 3.2.5 - needs: validation + BUNDLE_GEMFILE: gemfiles/que-1-collector.gemfile + ruby_3-4-1__que-2_ubuntu-latest: + name: Ruby 3.4.1 - que-2 + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -3123,7 +3117,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.2.5 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -3133,18 +3127,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &56 + name: Run tests run: "./script/bundler_wrapper exec rake test" - - name: Run tests without extension - run: "./script/bundler_wrapper exec rake test:failure" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/no_dependencies.gemfile - ruby_3-2-5__capistrano2_ubuntu-latest: - name: Ruby 3.2.5 - capistrano2 - needs: ruby_3-2-5_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/que-2.gemfile + ruby_3-4-1__que-2-collector_ubuntu-latest: + name: Ruby 3.4.1 - que-2-collector + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -3152,7 +3145,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.2.5 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -3162,16 +3155,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *56 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/capistrano2.gemfile - ruby_3-2-5__capistrano3_ubuntu-latest: - name: Ruby 3.2.5 - capistrano3 - needs: ruby_3-2-5_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/que-2-collector.gemfile + ruby_3-4-1__rails-7-0_ubuntu-latest: + name: Ruby 3.4.1 - rails-7.0 + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -3179,7 +3171,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.2.5 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -3189,16 +3181,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &57 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/capistrano3.gemfile - ruby_3-2-5__delayed_job_ubuntu-latest: - name: Ruby 3.2.5 - delayed_job - needs: ruby_3-2-5_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/rails-7.0.gemfile + ruby_3-4-1__rails-7-0-collector_ubuntu-latest: + name: Ruby 3.4.1 - rails-7.0-collector + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -3206,7 +3199,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.2.5 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -3216,16 +3209,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *57 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/delayed_job.gemfile - ruby_3-2-5__dry-monitor_ubuntu-latest: - name: Ruby 3.2.5 - dry-monitor - needs: ruby_3-2-5_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/rails-7.0-collector.gemfile + ruby_3-4-1__rails-7-1_ubuntu-latest: + name: Ruby 3.4.1 - rails-7.1 + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -3233,7 +3225,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.2.5 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -3243,16 +3235,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &58 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/dry-monitor.gemfile - ruby_3-2-5__excon_ubuntu-latest: - name: Ruby 3.2.5 - excon - needs: ruby_3-2-5_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/rails-7.1.gemfile + ruby_3-4-1__rails-7-1-collector_ubuntu-latest: + name: Ruby 3.4.1 - rails-7.1-collector + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -3260,7 +3253,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.2.5 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -3270,16 +3263,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *58 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/excon.gemfile - ruby_3-2-5__faraday-1_ubuntu-latest: - name: Ruby 3.2.5 - faraday-1 - needs: ruby_3-2-5_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/rails-7.1-collector.gemfile + ruby_3-4-1__rails-7-2_ubuntu-latest: + name: Ruby 3.4.1 - rails-7.2 + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -3287,7 +3279,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.2.5 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -3297,16 +3289,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &59 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/faraday-1.gemfile - ruby_3-2-5__faraday-2_ubuntu-latest: - name: Ruby 3.2.5 - faraday-2 - needs: ruby_3-2-5_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/rails-7.2.gemfile + ruby_3-4-1__rails-7-2-collector_ubuntu-latest: + name: Ruby 3.4.1 - rails-7.2-collector + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -3314,7 +3307,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.2.5 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -3324,16 +3317,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *59 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/faraday-2.gemfile - ruby_3-2-5__grape_ubuntu-latest: - name: Ruby 3.2.5 - grape - needs: ruby_3-2-5_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/rails-7.2-collector.gemfile + ruby_3-4-1__rails-8-0_ubuntu-latest: + name: Ruby 3.4.1 - rails-8.0 + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -3341,7 +3333,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.2.5 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -3351,16 +3343,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &60 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/grape.gemfile - ruby_3-2-5__hanami-2-0_ubuntu-latest: - name: Ruby 3.2.5 - hanami-2.0 - needs: ruby_3-2-5_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/rails-8.0.gemfile + ruby_3-4-1__rails-8-0-collector_ubuntu-latest: + name: Ruby 3.4.1 - rails-8.0-collector + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -3368,7 +3361,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.2.5 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -3378,16 +3371,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *60 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/hanami-2.0.gemfile - ruby_3-2-5__hanami-2-1_ubuntu-latest: - name: Ruby 3.2.5 - hanami-2.1 - needs: ruby_3-2-5_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/rails-8.0-collector.gemfile + ruby_3-4-1__rails-8-1_ubuntu-latest: + name: Ruby 3.4.1 - rails-8.1 + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -3395,7 +3387,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.2.5 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -3405,16 +3397,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &61 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/hanami-2.1.gemfile - ruby_3-2-5__hanami-2-2_ubuntu-latest: - name: Ruby 3.2.5 - hanami-2.2 - needs: ruby_3-2-5_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/rails-8.1.gemfile + ruby_3-4-1__rails-8-1-collector_ubuntu-latest: + name: Ruby 3.4.1 - rails-8.1-collector + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -3422,7 +3415,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.2.5 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -3432,16 +3425,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *61 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/hanami-2.2.gemfile - ruby_3-2-5__http5_ubuntu-latest: - name: Ruby 3.2.5 - http5 - needs: ruby_3-2-5_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/rails-8.1-collector.gemfile + ruby_3-4-1__resque-2_ubuntu-latest: + name: Ruby 3.4.1 - resque-2 + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -3449,7 +3441,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.2.5 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -3459,16 +3451,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &62 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/http5.gemfile - ruby_3-2-5__http6_ubuntu-latest: - name: Ruby 3.2.5 - http6 - needs: ruby_3-2-5_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/resque-2.gemfile + ruby_3-4-1__resque-2-collector_ubuntu-latest: + name: Ruby 3.4.1 - resque-2-collector + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -3476,7 +3469,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.2.5 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -3486,16 +3479,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *62 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/http6.gemfile - ruby_3-2-5__mongo_ubuntu-latest: - name: Ruby 3.2.5 - mongo - needs: ruby_3-2-5_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/resque-2-collector.gemfile + ruby_3-4-1__resque-3_ubuntu-latest: + name: Ruby 3.4.1 - resque-3 + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -3503,7 +3495,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.2.5 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -3513,16 +3505,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &63 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/mongo.gemfile - ruby_3-2-5__ownership_ubuntu-latest: - name: Ruby 3.2.5 - ownership - needs: ruby_3-2-5_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/resque-3.gemfile + ruby_3-4-1__resque-3-collector_ubuntu-latest: + name: Ruby 3.4.1 - resque-3-collector + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -3530,7 +3523,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.2.5 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -3540,16 +3533,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *63 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/ownership.gemfile - ruby_3-2-5__padrino_ubuntu-latest: - name: Ruby 3.2.5 - padrino - needs: ruby_3-2-5_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/resque-3-collector.gemfile + ruby_3-4-1__sequel_ubuntu-latest: + name: Ruby 3.4.1 - sequel + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -3557,7 +3549,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.2.5 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -3567,16 +3559,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &64 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/padrino.gemfile - ruby_3-2-5__psych-3_ubuntu-latest: - name: Ruby 3.2.5 - psych-3 - needs: ruby_3-2-5_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/sequel.gemfile + ruby_3-4-1__sequel-collector_ubuntu-latest: + name: Ruby 3.4.1 - sequel-collector + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -3584,7 +3577,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.2.5 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -3594,16 +3587,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *64 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/psych-3.gemfile - ruby_3-2-5__psych-4_ubuntu-latest: - name: Ruby 3.2.5 - psych-4 - needs: ruby_3-2-5_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/sequel-collector.gemfile + ruby_3-4-1__shoryuken-7_ubuntu-latest: + name: Ruby 3.4.1 - shoryuken-7 + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -3611,7 +3603,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.2.5 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -3621,16 +3613,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &65 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/psych-4.gemfile - ruby_3-2-5__que-0-14_ubuntu-latest: - name: Ruby 3.2.5 - que-0.14 - needs: ruby_3-2-5_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/shoryuken-7.gemfile + ruby_3-4-1__shoryuken-7-collector_ubuntu-latest: + name: Ruby 3.4.1 - shoryuken-7-collector + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -3638,7 +3631,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.2.5 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -3648,16 +3641,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *65 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/que-0.14.gemfile - ruby_3-2-5__que-1_ubuntu-latest: - name: Ruby 3.2.5 - que-1 - needs: ruby_3-2-5_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/shoryuken-7-collector.gemfile + ruby_3-4-1__sinatra_ubuntu-latest: + name: Ruby 3.4.1 - sinatra + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -3665,7 +3657,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.2.5 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -3675,16 +3667,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &66 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/que-1.gemfile - ruby_3-2-5__que-2_ubuntu-latest: - name: Ruby 3.2.5 - que-2 - needs: ruby_3-2-5_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/sinatra.gemfile + ruby_3-4-1__sinatra-collector_ubuntu-latest: + name: Ruby 3.4.1 - sinatra-collector + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -3692,7 +3685,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.2.5 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -3702,16 +3695,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *66 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/que-2.gemfile - ruby_3-2-5__rails-6-1_ubuntu-latest: - name: Ruby 3.2.5 - rails-6.1 - needs: ruby_3-2-5_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/sinatra-collector.gemfile + ruby_3-4-1__webmachine2_ubuntu-latest: + name: Ruby 3.4.1 - webmachine2 + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -3719,7 +3711,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.2.5 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -3729,16 +3721,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &67 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/rails-6.1.gemfile - ruby_3-2-5__rails-7-0_ubuntu-latest: - name: Ruby 3.2.5 - rails-7.0 - needs: ruby_3-2-5_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/webmachine2.gemfile + ruby_3-4-1__webmachine2-collector_ubuntu-latest: + name: Ruby 3.4.1 - webmachine2-collector + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -3746,7 +3739,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.2.5 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -3756,16 +3749,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *67 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/rails-7.0.gemfile - ruby_3-2-5__rails-7-1_ubuntu-latest: - name: Ruby 3.2.5 - rails-7.1 - needs: ruby_3-2-5_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/webmachine2-collector.gemfile + ruby_3-4-1__redis-4_ubuntu-latest: + name: Ruby 3.4.1 - redis-4 + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -3773,7 +3765,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.2.5 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -3783,16 +3775,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &68 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/rails-7.1.gemfile - ruby_3-2-5__rails-7-2_ubuntu-latest: - name: Ruby 3.2.5 - rails-7.2 - needs: ruby_3-2-5_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/redis-4.gemfile + ruby_3-4-1__redis-4-collector_ubuntu-latest: + name: Ruby 3.4.1 - redis-4-collector + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -3800,7 +3793,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.2.5 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -3810,16 +3803,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *68 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/rails-7.2.gemfile - ruby_3-2-5__rails-8-0_ubuntu-latest: - name: Ruby 3.2.5 - rails-8.0 - needs: ruby_3-2-5_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/redis-4-collector.gemfile + ruby_3-4-1__redis-5_ubuntu-latest: + name: Ruby 3.4.1 - redis-5 + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -3827,7 +3819,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.2.5 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -3837,16 +3829,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &69 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/rails-8.0.gemfile - ruby_3-2-5__rails-8-1_ubuntu-latest: - name: Ruby 3.2.5 - rails-8.1 - needs: ruby_3-2-5_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/redis-5.gemfile + ruby_3-4-1__redis-5-collector_ubuntu-latest: + name: Ruby 3.4.1 - redis-5-collector + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -3854,7 +3847,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.2.5 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -3864,16 +3857,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *69 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/rails-8.1.gemfile - ruby_3-2-5__resque-2_ubuntu-latest: - name: Ruby 3.2.5 - resque-2 - needs: ruby_3-2-5_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/redis-5-collector.gemfile + ruby_3-4-1__sidekiq-7_ubuntu-latest: + name: Ruby 3.4.1 - sidekiq-7 + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -3881,7 +3873,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.2.5 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -3891,16 +3883,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &70 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/resque-2.gemfile - ruby_3-2-5__resque-3_ubuntu-latest: - name: Ruby 3.2.5 - resque-3 - needs: ruby_3-2-5_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/sidekiq-7.gemfile + ruby_3-4-1__sidekiq-7-collector_ubuntu-latest: + name: Ruby 3.4.1 - sidekiq-7-collector + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -3908,7 +3901,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.2.5 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -3918,16 +3911,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *70 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/resque-3.gemfile - ruby_3-2-5__sequel_ubuntu-latest: - name: Ruby 3.2.5 - sequel - needs: ruby_3-2-5_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/sidekiq-7-collector.gemfile + ruby_3-4-1__sidekiq-8_ubuntu-latest: + name: Ruby 3.4.1 - sidekiq-8 + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -3935,7 +3927,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.2.5 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -3945,16 +3937,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &71 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/sequel.gemfile - ruby_3-2-5__shoryuken-7_ubuntu-latest: - name: Ruby 3.2.5 - shoryuken-7 - needs: ruby_3-2-5_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/sidekiq-8.gemfile + ruby_3-4-1__sidekiq-8-collector_ubuntu-latest: + name: Ruby 3.4.1 - sidekiq-8-collector + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -3962,7 +3955,33 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.2.5 + ruby-version: 3.4.1 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *71 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/sidekiq-8-collector.gemfile + ruby_3-4-1_macos-14: + name: Ruby 3.4.1 (macos-14) + needs: validation + runs-on: macos-14 + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -3974,14 +3993,4778 @@ jobs: found'" - name: Run tests run: "./script/bundler_wrapper exec rake test" + - name: Run tests without extension + run: "./script/bundler_wrapper exec rake test:failure" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/shoryuken-7.gemfile - ruby_3-2-5__sinatra_ubuntu-latest: - name: Ruby 3.2.5 - sinatra - needs: ruby_3-2-5_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/no_dependencies.gemfile + ruby_3-3-4_ubuntu-latest: + name: Ruby 3.3.4 + needs: validation + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &72 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + - name: Run tests without extension + run: "./script/bundler_wrapper exec rake test:failure" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/no_dependencies.gemfile + ruby_3-3-4__no_dependencies-collector_ubuntu-latest: + name: Ruby 3.3.4 - no_dependencies-collector + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *72 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/no_dependencies-collector.gemfile + ruby_3-3-4__capistrano2_ubuntu-latest: + name: Ruby 3.3.4 - capistrano2 + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &73 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/capistrano2.gemfile + ruby_3-3-4__capistrano2-collector_ubuntu-latest: + name: Ruby 3.3.4 - capistrano2-collector + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *73 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/capistrano2-collector.gemfile + ruby_3-3-4__capistrano3_ubuntu-latest: + name: Ruby 3.3.4 - capistrano3 + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &74 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/capistrano3.gemfile + ruby_3-3-4__capistrano3-collector_ubuntu-latest: + name: Ruby 3.3.4 - capistrano3-collector + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *74 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/capistrano3-collector.gemfile + ruby_3-3-4__code_ownership_ubuntu-latest: + name: Ruby 3.3.4 - code_ownership + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &75 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/code_ownership.gemfile + ruby_3-3-4__code_ownership-collector_ubuntu-latest: + name: Ruby 3.3.4 - code_ownership-collector + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *75 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/code_ownership-collector.gemfile + ruby_3-3-4__delayed_job_ubuntu-latest: + name: Ruby 3.3.4 - delayed_job + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &76 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/delayed_job.gemfile + ruby_3-3-4__delayed_job-collector_ubuntu-latest: + name: Ruby 3.3.4 - delayed_job-collector + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *76 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/delayed_job-collector.gemfile + ruby_3-3-4__dry-monitor_ubuntu-latest: + name: Ruby 3.3.4 - dry-monitor + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &77 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/dry-monitor.gemfile + ruby_3-3-4__dry-monitor-collector_ubuntu-latest: + name: Ruby 3.3.4 - dry-monitor-collector + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *77 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/dry-monitor-collector.gemfile + ruby_3-3-4__excon_ubuntu-latest: + name: Ruby 3.3.4 - excon + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &78 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/excon.gemfile + ruby_3-3-4__excon-collector_ubuntu-latest: + name: Ruby 3.3.4 - excon-collector + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *78 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/excon-collector.gemfile + ruby_3-3-4__faraday-1_ubuntu-latest: + name: Ruby 3.3.4 - faraday-1 + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &79 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/faraday-1.gemfile + ruby_3-3-4__faraday-1-collector_ubuntu-latest: + name: Ruby 3.3.4 - faraday-1-collector + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *79 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/faraday-1-collector.gemfile + ruby_3-3-4__faraday-2_ubuntu-latest: + name: Ruby 3.3.4 - faraday-2 + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &80 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/faraday-2.gemfile + ruby_3-3-4__faraday-2-collector_ubuntu-latest: + name: Ruby 3.3.4 - faraday-2-collector + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *80 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/faraday-2-collector.gemfile + ruby_3-3-4__grape_ubuntu-latest: + name: Ruby 3.3.4 - grape + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &81 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/grape.gemfile + ruby_3-3-4__grape-collector_ubuntu-latest: + name: Ruby 3.3.4 - grape-collector + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *81 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/grape-collector.gemfile + ruby_3-3-4__hanami-2-0_ubuntu-latest: + name: Ruby 3.3.4 - hanami-2.0 + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &82 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/hanami-2.0.gemfile + ruby_3-3-4__hanami-2-0-collector_ubuntu-latest: + name: Ruby 3.3.4 - hanami-2.0-collector + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *82 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/hanami-2.0-collector.gemfile + ruby_3-3-4__hanami-2-1_ubuntu-latest: + name: Ruby 3.3.4 - hanami-2.1 + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &83 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/hanami-2.1.gemfile + ruby_3-3-4__hanami-2-1-collector_ubuntu-latest: + name: Ruby 3.3.4 - hanami-2.1-collector + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *83 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/hanami-2.1-collector.gemfile + ruby_3-3-4__hanami-2-2_ubuntu-latest: + name: Ruby 3.3.4 - hanami-2.2 + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &84 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/hanami-2.2.gemfile + ruby_3-3-4__hanami-2-2-collector_ubuntu-latest: + name: Ruby 3.3.4 - hanami-2.2-collector + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *84 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/hanami-2.2-collector.gemfile + ruby_3-3-4__http5_ubuntu-latest: + name: Ruby 3.3.4 - http5 + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &85 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/http5.gemfile + ruby_3-3-4__http5-collector_ubuntu-latest: + name: Ruby 3.3.4 - http5-collector + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *85 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/http5-collector.gemfile + ruby_3-3-4__http6_ubuntu-latest: + name: Ruby 3.3.4 - http6 + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &86 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/http6.gemfile + ruby_3-3-4__http6-collector_ubuntu-latest: + name: Ruby 3.3.4 - http6-collector + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *86 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/http6-collector.gemfile + ruby_3-3-4__mongo_ubuntu-latest: + name: Ruby 3.3.4 - mongo + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &87 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/mongo.gemfile + ruby_3-3-4__mongo-collector_ubuntu-latest: + name: Ruby 3.3.4 - mongo-collector + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *87 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/mongo-collector.gemfile + ruby_3-3-4__ownership_ubuntu-latest: + name: Ruby 3.3.4 - ownership + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &88 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/ownership.gemfile + ruby_3-3-4__ownership-collector_ubuntu-latest: + name: Ruby 3.3.4 - ownership-collector + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *88 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/ownership-collector.gemfile + ruby_3-3-4__padrino_ubuntu-latest: + name: Ruby 3.3.4 - padrino + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &89 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/padrino.gemfile + ruby_3-3-4__padrino-collector_ubuntu-latest: + name: Ruby 3.3.4 - padrino-collector + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *89 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/padrino-collector.gemfile + ruby_3-3-4__psych-3_ubuntu-latest: + name: Ruby 3.3.4 - psych-3 + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &90 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/psych-3.gemfile + ruby_3-3-4__psych-3-collector_ubuntu-latest: + name: Ruby 3.3.4 - psych-3-collector + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *90 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/psych-3-collector.gemfile + ruby_3-3-4__psych-4_ubuntu-latest: + name: Ruby 3.3.4 - psych-4 + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &91 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/psych-4.gemfile + ruby_3-3-4__psych-4-collector_ubuntu-latest: + name: Ruby 3.3.4 - psych-4-collector + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *91 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/psych-4-collector.gemfile + ruby_3-3-4__que-0-14_ubuntu-latest: + name: Ruby 3.3.4 - que-0.14 + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &92 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/que-0.14.gemfile + ruby_3-3-4__que-0-14-collector_ubuntu-latest: + name: Ruby 3.3.4 - que-0.14-collector + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *92 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/que-0.14-collector.gemfile + ruby_3-3-4__que-1_ubuntu-latest: + name: Ruby 3.3.4 - que-1 + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &93 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/que-1.gemfile + ruby_3-3-4__que-1-collector_ubuntu-latest: + name: Ruby 3.3.4 - que-1-collector + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *93 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/que-1-collector.gemfile + ruby_3-3-4__que-2_ubuntu-latest: + name: Ruby 3.3.4 - que-2 + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &94 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/que-2.gemfile + ruby_3-3-4__que-2-collector_ubuntu-latest: + name: Ruby 3.3.4 - que-2-collector + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *94 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/que-2-collector.gemfile + ruby_3-3-4__rails-6-1_ubuntu-latest: + name: Ruby 3.3.4 - rails-6.1 + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &95 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/rails-6.1.gemfile + ruby_3-3-4__rails-6-1-collector_ubuntu-latest: + name: Ruby 3.3.4 - rails-6.1-collector + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *95 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/rails-6.1-collector.gemfile + ruby_3-3-4__rails-7-0_ubuntu-latest: + name: Ruby 3.3.4 - rails-7.0 + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &96 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/rails-7.0.gemfile + ruby_3-3-4__rails-7-0-collector_ubuntu-latest: + name: Ruby 3.3.4 - rails-7.0-collector + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *96 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/rails-7.0-collector.gemfile + ruby_3-3-4__rails-7-1_ubuntu-latest: + name: Ruby 3.3.4 - rails-7.1 + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &97 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/rails-7.1.gemfile + ruby_3-3-4__rails-7-1-collector_ubuntu-latest: + name: Ruby 3.3.4 - rails-7.1-collector + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *97 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/rails-7.1-collector.gemfile + ruby_3-3-4__rails-7-2_ubuntu-latest: + name: Ruby 3.3.4 - rails-7.2 + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &98 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/rails-7.2.gemfile + ruby_3-3-4__rails-7-2-collector_ubuntu-latest: + name: Ruby 3.3.4 - rails-7.2-collector + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *98 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/rails-7.2-collector.gemfile + ruby_3-3-4__rails-8-0_ubuntu-latest: + name: Ruby 3.3.4 - rails-8.0 + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &99 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/rails-8.0.gemfile + ruby_3-3-4__rails-8-0-collector_ubuntu-latest: + name: Ruby 3.3.4 - rails-8.0-collector + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *99 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/rails-8.0-collector.gemfile + ruby_3-3-4__rails-8-1_ubuntu-latest: + name: Ruby 3.3.4 - rails-8.1 + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &100 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/rails-8.1.gemfile + ruby_3-3-4__rails-8-1-collector_ubuntu-latest: + name: Ruby 3.3.4 - rails-8.1-collector + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *100 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/rails-8.1-collector.gemfile + ruby_3-3-4__resque-2_ubuntu-latest: + name: Ruby 3.3.4 - resque-2 + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &101 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/resque-2.gemfile + ruby_3-3-4__resque-2-collector_ubuntu-latest: + name: Ruby 3.3.4 - resque-2-collector + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *101 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/resque-2-collector.gemfile + ruby_3-3-4__resque-3_ubuntu-latest: + name: Ruby 3.3.4 - resque-3 + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &102 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/resque-3.gemfile + ruby_3-3-4__resque-3-collector_ubuntu-latest: + name: Ruby 3.3.4 - resque-3-collector + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *102 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/resque-3-collector.gemfile + ruby_3-3-4__sequel_ubuntu-latest: + name: Ruby 3.3.4 - sequel + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &103 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/sequel.gemfile + ruby_3-3-4__sequel-collector_ubuntu-latest: + name: Ruby 3.3.4 - sequel-collector + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *103 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/sequel-collector.gemfile + ruby_3-3-4__shoryuken-7_ubuntu-latest: + name: Ruby 3.3.4 - shoryuken-7 + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &104 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/shoryuken-7.gemfile + ruby_3-3-4__shoryuken-7-collector_ubuntu-latest: + name: Ruby 3.3.4 - shoryuken-7-collector + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *104 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/shoryuken-7-collector.gemfile + ruby_3-3-4__sinatra_ubuntu-latest: + name: Ruby 3.3.4 - sinatra + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &105 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/sinatra.gemfile + ruby_3-3-4__sinatra-collector_ubuntu-latest: + name: Ruby 3.3.4 - sinatra-collector + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *105 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/sinatra-collector.gemfile + ruby_3-3-4__webmachine2_ubuntu-latest: + name: Ruby 3.3.4 - webmachine2 + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &106 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/webmachine2.gemfile + ruby_3-3-4__webmachine2-collector_ubuntu-latest: + name: Ruby 3.3.4 - webmachine2-collector + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *106 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/webmachine2-collector.gemfile + ruby_3-3-4__redis-4_ubuntu-latest: + name: Ruby 3.3.4 - redis-4 + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &107 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/redis-4.gemfile + ruby_3-3-4__redis-4-collector_ubuntu-latest: + name: Ruby 3.3.4 - redis-4-collector + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *107 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/redis-4-collector.gemfile + ruby_3-3-4__redis-5_ubuntu-latest: + name: Ruby 3.3.4 - redis-5 + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &108 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/redis-5.gemfile + ruby_3-3-4__redis-5-collector_ubuntu-latest: + name: Ruby 3.3.4 - redis-5-collector + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *108 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/redis-5-collector.gemfile + ruby_3-3-4_macos-14: + name: Ruby 3.3.4 (macos-14) + needs: validation + runs-on: macos-14 + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - name: Run tests + run: "./script/bundler_wrapper exec rake test" + - name: Run tests without extension + run: "./script/bundler_wrapper exec rake test:failure" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/no_dependencies.gemfile + ruby_3-2-5_ubuntu-latest: + name: Ruby 3.2.5 + needs: validation + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &109 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + - name: Run tests without extension + run: "./script/bundler_wrapper exec rake test:failure" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/no_dependencies.gemfile + ruby_3-2-5__no_dependencies-collector_ubuntu-latest: + name: Ruby 3.2.5 - no_dependencies-collector + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *109 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/no_dependencies-collector.gemfile + ruby_3-2-5__capistrano2_ubuntu-latest: + name: Ruby 3.2.5 - capistrano2 + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &110 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/capistrano2.gemfile + ruby_3-2-5__capistrano2-collector_ubuntu-latest: + name: Ruby 3.2.5 - capistrano2-collector + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *110 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/capistrano2-collector.gemfile + ruby_3-2-5__capistrano3_ubuntu-latest: + name: Ruby 3.2.5 - capistrano3 + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &111 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/capistrano3.gemfile + ruby_3-2-5__capistrano3-collector_ubuntu-latest: + name: Ruby 3.2.5 - capistrano3-collector + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *111 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/capistrano3-collector.gemfile + ruby_3-2-5__delayed_job_ubuntu-latest: + name: Ruby 3.2.5 - delayed_job + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &112 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/delayed_job.gemfile + ruby_3-2-5__delayed_job-collector_ubuntu-latest: + name: Ruby 3.2.5 - delayed_job-collector + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *112 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/delayed_job-collector.gemfile + ruby_3-2-5__dry-monitor_ubuntu-latest: + name: Ruby 3.2.5 - dry-monitor + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &113 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/dry-monitor.gemfile + ruby_3-2-5__dry-monitor-collector_ubuntu-latest: + name: Ruby 3.2.5 - dry-monitor-collector + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *113 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/dry-monitor-collector.gemfile + ruby_3-2-5__excon_ubuntu-latest: + name: Ruby 3.2.5 - excon + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &114 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/excon.gemfile + ruby_3-2-5__excon-collector_ubuntu-latest: + name: Ruby 3.2.5 - excon-collector + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *114 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/excon-collector.gemfile + ruby_3-2-5__faraday-1_ubuntu-latest: + name: Ruby 3.2.5 - faraday-1 + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &115 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/faraday-1.gemfile + ruby_3-2-5__faraday-1-collector_ubuntu-latest: + name: Ruby 3.2.5 - faraday-1-collector + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *115 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/faraday-1-collector.gemfile + ruby_3-2-5__faraday-2_ubuntu-latest: + name: Ruby 3.2.5 - faraday-2 + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &116 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/faraday-2.gemfile + ruby_3-2-5__faraday-2-collector_ubuntu-latest: + name: Ruby 3.2.5 - faraday-2-collector + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *116 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/faraday-2-collector.gemfile + ruby_3-2-5__grape_ubuntu-latest: + name: Ruby 3.2.5 - grape + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &117 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/grape.gemfile + ruby_3-2-5__grape-collector_ubuntu-latest: + name: Ruby 3.2.5 - grape-collector + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *117 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/grape-collector.gemfile + ruby_3-2-5__hanami-2-0_ubuntu-latest: + name: Ruby 3.2.5 - hanami-2.0 + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &118 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/hanami-2.0.gemfile + ruby_3-2-5__hanami-2-0-collector_ubuntu-latest: + name: Ruby 3.2.5 - hanami-2.0-collector + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *118 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/hanami-2.0-collector.gemfile + ruby_3-2-5__hanami-2-1_ubuntu-latest: + name: Ruby 3.2.5 - hanami-2.1 + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &119 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/hanami-2.1.gemfile + ruby_3-2-5__hanami-2-1-collector_ubuntu-latest: + name: Ruby 3.2.5 - hanami-2.1-collector + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *119 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/hanami-2.1-collector.gemfile + ruby_3-2-5__hanami-2-2_ubuntu-latest: + name: Ruby 3.2.5 - hanami-2.2 + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &120 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/hanami-2.2.gemfile + ruby_3-2-5__hanami-2-2-collector_ubuntu-latest: + name: Ruby 3.2.5 - hanami-2.2-collector + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *120 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/hanami-2.2-collector.gemfile + ruby_3-2-5__http5_ubuntu-latest: + name: Ruby 3.2.5 - http5 + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &121 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/http5.gemfile + ruby_3-2-5__http5-collector_ubuntu-latest: + name: Ruby 3.2.5 - http5-collector + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *121 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/http5-collector.gemfile + ruby_3-2-5__http6_ubuntu-latest: + name: Ruby 3.2.5 - http6 + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &122 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/http6.gemfile + ruby_3-2-5__http6-collector_ubuntu-latest: + name: Ruby 3.2.5 - http6-collector + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *122 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/http6-collector.gemfile + ruby_3-2-5__mongo_ubuntu-latest: + name: Ruby 3.2.5 - mongo + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &123 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/mongo.gemfile + ruby_3-2-5__mongo-collector_ubuntu-latest: + name: Ruby 3.2.5 - mongo-collector + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *123 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/mongo-collector.gemfile + ruby_3-2-5__ownership_ubuntu-latest: + name: Ruby 3.2.5 - ownership + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &124 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/ownership.gemfile + ruby_3-2-5__ownership-collector_ubuntu-latest: + name: Ruby 3.2.5 - ownership-collector + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *124 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/ownership-collector.gemfile + ruby_3-2-5__padrino_ubuntu-latest: + name: Ruby 3.2.5 - padrino + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &125 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/padrino.gemfile + ruby_3-2-5__padrino-collector_ubuntu-latest: + name: Ruby 3.2.5 - padrino-collector + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *125 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/padrino-collector.gemfile + ruby_3-2-5__psych-3_ubuntu-latest: + name: Ruby 3.2.5 - psych-3 + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &126 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/psych-3.gemfile + ruby_3-2-5__psych-3-collector_ubuntu-latest: + name: Ruby 3.2.5 - psych-3-collector + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *126 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/psych-3-collector.gemfile + ruby_3-2-5__psych-4_ubuntu-latest: + name: Ruby 3.2.5 - psych-4 + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &127 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/psych-4.gemfile + ruby_3-2-5__psych-4-collector_ubuntu-latest: + name: Ruby 3.2.5 - psych-4-collector + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *127 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/psych-4-collector.gemfile + ruby_3-2-5__que-0-14_ubuntu-latest: + name: Ruby 3.2.5 - que-0.14 + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &128 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/que-0.14.gemfile + ruby_3-2-5__que-0-14-collector_ubuntu-latest: + name: Ruby 3.2.5 - que-0.14-collector + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *128 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/que-0.14-collector.gemfile + ruby_3-2-5__que-1_ubuntu-latest: + name: Ruby 3.2.5 - que-1 + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &129 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/que-1.gemfile + ruby_3-2-5__que-1-collector_ubuntu-latest: + name: Ruby 3.2.5 - que-1-collector + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *129 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/que-1-collector.gemfile + ruby_3-2-5__que-2_ubuntu-latest: + name: Ruby 3.2.5 - que-2 + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &130 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/que-2.gemfile + ruby_3-2-5__que-2-collector_ubuntu-latest: + name: Ruby 3.2.5 - que-2-collector + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *130 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/que-2-collector.gemfile + ruby_3-2-5__rails-6-1_ubuntu-latest: + name: Ruby 3.2.5 - rails-6.1 + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &131 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/rails-6.1.gemfile + ruby_3-2-5__rails-6-1-collector_ubuntu-latest: + name: Ruby 3.2.5 - rails-6.1-collector + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *131 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/rails-6.1-collector.gemfile + ruby_3-2-5__rails-7-0_ubuntu-latest: + name: Ruby 3.2.5 - rails-7.0 + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &132 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/rails-7.0.gemfile + ruby_3-2-5__rails-7-0-collector_ubuntu-latest: + name: Ruby 3.2.5 - rails-7.0-collector + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *132 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/rails-7.0-collector.gemfile + ruby_3-2-5__rails-7-1_ubuntu-latest: + name: Ruby 3.2.5 - rails-7.1 + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &133 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/rails-7.1.gemfile + ruby_3-2-5__rails-7-1-collector_ubuntu-latest: + name: Ruby 3.2.5 - rails-7.1-collector + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *133 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/rails-7.1-collector.gemfile + ruby_3-2-5__rails-7-2_ubuntu-latest: + name: Ruby 3.2.5 - rails-7.2 + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &134 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/rails-7.2.gemfile + ruby_3-2-5__rails-7-2-collector_ubuntu-latest: + name: Ruby 3.2.5 - rails-7.2-collector + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *134 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/rails-7.2-collector.gemfile + ruby_3-2-5__rails-8-0_ubuntu-latest: + name: Ruby 3.2.5 - rails-8.0 + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &135 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/rails-8.0.gemfile + ruby_3-2-5__rails-8-0-collector_ubuntu-latest: + name: Ruby 3.2.5 - rails-8.0-collector + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *135 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/rails-8.0-collector.gemfile + ruby_3-2-5__rails-8-1_ubuntu-latest: + name: Ruby 3.2.5 - rails-8.1 + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &136 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/rails-8.1.gemfile + ruby_3-2-5__rails-8-1-collector_ubuntu-latest: + name: Ruby 3.2.5 - rails-8.1-collector + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *136 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/rails-8.1-collector.gemfile + ruby_3-2-5__resque-2_ubuntu-latest: + name: Ruby 3.2.5 - resque-2 + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &137 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/resque-2.gemfile + ruby_3-2-5__resque-2-collector_ubuntu-latest: + name: Ruby 3.2.5 - resque-2-collector + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *137 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/resque-2-collector.gemfile + ruby_3-2-5__resque-3_ubuntu-latest: + name: Ruby 3.2.5 - resque-3 + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &138 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/resque-3.gemfile + ruby_3-2-5__resque-3-collector_ubuntu-latest: + name: Ruby 3.2.5 - resque-3-collector + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *138 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/resque-3-collector.gemfile + ruby_3-2-5__sequel_ubuntu-latest: + name: Ruby 3.2.5 - sequel + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &139 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/sequel.gemfile + ruby_3-2-5__sequel-collector_ubuntu-latest: + name: Ruby 3.2.5 - sequel-collector + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *139 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/sequel-collector.gemfile + ruby_3-2-5__shoryuken-7_ubuntu-latest: + name: Ruby 3.2.5 - shoryuken-7 + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &140 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/shoryuken-7.gemfile + ruby_3-2-5__shoryuken-7-collector_ubuntu-latest: + name: Ruby 3.2.5 - shoryuken-7-collector + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *140 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/shoryuken-7-collector.gemfile + ruby_3-2-5__sinatra_ubuntu-latest: + name: Ruby 3.2.5 - sinatra + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &141 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/sinatra.gemfile + ruby_3-2-5__sinatra-collector_ubuntu-latest: + name: Ruby 3.2.5 - sinatra-collector + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *141 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/sinatra-collector.gemfile + ruby_3-2-5__webmachine2_ubuntu-latest: + name: Ruby 3.2.5 - webmachine2 + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &142 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/webmachine2.gemfile + ruby_3-2-5__webmachine2-collector_ubuntu-latest: + name: Ruby 3.2.5 - webmachine2-collector + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *142 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/webmachine2-collector.gemfile + ruby_3-2-5__redis-4_ubuntu-latest: + name: Ruby 3.2.5 - redis-4 + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &143 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/redis-4.gemfile + ruby_3-2-5__redis-4-collector_ubuntu-latest: + name: Ruby 3.2.5 - redis-4-collector + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *143 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/redis-4-collector.gemfile + ruby_3-2-5__redis-5_ubuntu-latest: + name: Ruby 3.2.5 - redis-5 + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &144 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/redis-5.gemfile + ruby_3-2-5__redis-5-collector_ubuntu-latest: + name: Ruby 3.2.5 - redis-5-collector + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *144 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/redis-5-collector.gemfile + ruby_3-2-5_macos-14: + name: Ruby 3.2.5 (macos-14) + needs: validation + runs-on: macos-14 + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - name: Run tests + run: "./script/bundler_wrapper exec rake test" + - name: Run tests without extension + run: "./script/bundler_wrapper exec rake test:failure" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/no_dependencies.gemfile + ruby_3-1-6_ubuntu-latest: + name: Ruby 3.1.6 + needs: validation + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.1.6 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &145 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + - name: Run tests without extension + run: "./script/bundler_wrapper exec rake test:failure" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/no_dependencies.gemfile + ruby_3-1-6__no_dependencies-collector_ubuntu-latest: + name: Ruby 3.1.6 - no_dependencies-collector + needs: ruby_3-1-6_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.1.6 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *145 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/no_dependencies-collector.gemfile + ruby_3-1-6__capistrano2_ubuntu-latest: + name: Ruby 3.1.6 - capistrano2 + needs: ruby_3-1-6_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.1.6 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &146 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/capistrano2.gemfile + ruby_3-1-6__capistrano2-collector_ubuntu-latest: + name: Ruby 3.1.6 - capistrano2-collector + needs: ruby_3-1-6_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.1.6 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *146 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/capistrano2-collector.gemfile + ruby_3-1-6__capistrano3_ubuntu-latest: + name: Ruby 3.1.6 - capistrano3 + needs: ruby_3-1-6_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.1.6 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &147 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/capistrano3.gemfile + ruby_3-1-6__capistrano3-collector_ubuntu-latest: + name: Ruby 3.1.6 - capistrano3-collector + needs: ruby_3-1-6_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.1.6 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *147 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/capistrano3-collector.gemfile + ruby_3-1-6__delayed_job_ubuntu-latest: + name: Ruby 3.1.6 - delayed_job + needs: ruby_3-1-6_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.1.6 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &148 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/delayed_job.gemfile + ruby_3-1-6__delayed_job-collector_ubuntu-latest: + name: Ruby 3.1.6 - delayed_job-collector + needs: ruby_3-1-6_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.1.6 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *148 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/delayed_job-collector.gemfile + ruby_3-1-6__dry-monitor_ubuntu-latest: + name: Ruby 3.1.6 - dry-monitor + needs: ruby_3-1-6_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.1.6 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &149 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/dry-monitor.gemfile + ruby_3-1-6__dry-monitor-collector_ubuntu-latest: + name: Ruby 3.1.6 - dry-monitor-collector + needs: ruby_3-1-6_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.1.6 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *149 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/dry-monitor-collector.gemfile + ruby_3-1-6__excon_ubuntu-latest: + name: Ruby 3.1.6 - excon + needs: ruby_3-1-6_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.1.6 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &150 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/excon.gemfile + ruby_3-1-6__excon-collector_ubuntu-latest: + name: Ruby 3.1.6 - excon-collector + needs: ruby_3-1-6_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.1.6 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *150 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/excon-collector.gemfile + ruby_3-1-6__faraday-1_ubuntu-latest: + name: Ruby 3.1.6 - faraday-1 + needs: ruby_3-1-6_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.1.6 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &151 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/faraday-1.gemfile + ruby_3-1-6__faraday-1-collector_ubuntu-latest: + name: Ruby 3.1.6 - faraday-1-collector + needs: ruby_3-1-6_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.1.6 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *151 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/faraday-1-collector.gemfile + ruby_3-1-6__faraday-2_ubuntu-latest: + name: Ruby 3.1.6 - faraday-2 + needs: ruby_3-1-6_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.1.6 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &152 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/faraday-2.gemfile + ruby_3-1-6__faraday-2-collector_ubuntu-latest: + name: Ruby 3.1.6 - faraday-2-collector + needs: ruby_3-1-6_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.1.6 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *152 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/faraday-2-collector.gemfile + ruby_3-1-6__grape_ubuntu-latest: + name: Ruby 3.1.6 - grape + needs: ruby_3-1-6_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.1.6 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &153 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/grape.gemfile + ruby_3-1-6__grape-collector_ubuntu-latest: + name: Ruby 3.1.6 - grape-collector + needs: ruby_3-1-6_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.1.6 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *153 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/grape-collector.gemfile + ruby_3-1-6__hanami-2-0_ubuntu-latest: + name: Ruby 3.1.6 - hanami-2.0 + needs: ruby_3-1-6_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.1.6 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &154 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/hanami-2.0.gemfile + ruby_3-1-6__hanami-2-0-collector_ubuntu-latest: + name: Ruby 3.1.6 - hanami-2.0-collector + needs: ruby_3-1-6_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.1.6 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *154 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/hanami-2.0-collector.gemfile + ruby_3-1-6__hanami-2-1_ubuntu-latest: + name: Ruby 3.1.6 - hanami-2.1 + needs: ruby_3-1-6_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.1.6 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &155 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/hanami-2.1.gemfile + ruby_3-1-6__hanami-2-1-collector_ubuntu-latest: + name: Ruby 3.1.6 - hanami-2.1-collector + needs: ruby_3-1-6_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.1.6 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *155 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/hanami-2.1-collector.gemfile + ruby_3-1-6__hanami-2-2_ubuntu-latest: + name: Ruby 3.1.6 - hanami-2.2 + needs: ruby_3-1-6_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.1.6 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &156 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/hanami-2.2.gemfile + ruby_3-1-6__hanami-2-2-collector_ubuntu-latest: + name: Ruby 3.1.6 - hanami-2.2-collector + needs: ruby_3-1-6_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.1.6 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *156 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/hanami-2.2-collector.gemfile + ruby_3-1-6__http5_ubuntu-latest: + name: Ruby 3.1.6 - http5 + needs: ruby_3-1-6_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.1.6 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &157 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/http5.gemfile + ruby_3-1-6__http5-collector_ubuntu-latest: + name: Ruby 3.1.6 - http5-collector + needs: ruby_3-1-6_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.1.6 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *157 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/http5-collector.gemfile + ruby_3-1-6__mongo_ubuntu-latest: + name: Ruby 3.1.6 - mongo + needs: ruby_3-1-6_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.1.6 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &158 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/mongo.gemfile + ruby_3-1-6__mongo-collector_ubuntu-latest: + name: Ruby 3.1.6 - mongo-collector + needs: ruby_3-1-6_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.1.6 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *158 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/mongo-collector.gemfile + ruby_3-1-6__ownership_ubuntu-latest: + name: Ruby 3.1.6 - ownership + needs: ruby_3-1-6_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -3989,7 +8772,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.2.5 + ruby-version: 3.1.6 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -3999,16 +8782,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &159 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/sinatra.gemfile - ruby_3-2-5__webmachine2_ubuntu-latest: - name: Ruby 3.2.5 - webmachine2 - needs: ruby_3-2-5_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/ownership.gemfile + ruby_3-1-6__ownership-collector_ubuntu-latest: + name: Ruby 3.1.6 - ownership-collector + needs: ruby_3-1-6_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -4016,7 +8800,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.2.5 + ruby-version: 3.1.6 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -4026,16 +8810,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *159 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/webmachine2.gemfile - ruby_3-2-5__redis-4_ubuntu-latest: - name: Ruby 3.2.5 - redis-4 - needs: ruby_3-2-5_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/ownership-collector.gemfile + ruby_3-1-6__padrino_ubuntu-latest: + name: Ruby 3.1.6 - padrino + needs: ruby_3-1-6_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -4043,7 +8826,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.2.5 + ruby-version: 3.1.6 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -4053,16 +8836,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &160 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/redis-4.gemfile - ruby_3-2-5__redis-5_ubuntu-latest: - name: Ruby 3.2.5 - redis-5 - needs: ruby_3-2-5_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/padrino.gemfile + ruby_3-1-6__padrino-collector_ubuntu-latest: + name: Ruby 3.1.6 - padrino-collector + needs: ruby_3-1-6_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -4070,7 +8854,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.2.5 + ruby-version: 3.1.6 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -4080,24 +8864,23 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *160 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/redis-5.gemfile - ruby_3-2-5_macos-14: - name: Ruby 3.2.5 (macos-14) - needs: validation - runs-on: macos-14 + BUNDLE_GEMFILE: gemfiles/padrino-collector.gemfile + ruby_3-1-6__psych-3_ubuntu-latest: + name: Ruby 3.1.6 - psych-3 + needs: ruby_3-1-6_ubuntu-latest + runs-on: ubuntu-latest steps: - name: Check out repository uses: actions/checkout@v4 - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.2.5 + ruby-version: 3.1.6 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -4107,18 +8890,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &161 + name: Run tests run: "./script/bundler_wrapper exec rake test" - - name: Run tests without extension - run: "./script/bundler_wrapper exec rake test:failure" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/no_dependencies.gemfile - ruby_3-1-6_ubuntu-latest: - name: Ruby 3.1.6 - needs: validation + BUNDLE_GEMFILE: gemfiles/psych-3.gemfile + ruby_3-1-6__psych-3-collector_ubuntu-latest: + name: Ruby 3.1.6 - psych-3-collector + needs: ruby_3-1-6_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -4136,17 +8918,14 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" - - name: Run tests without extension - run: "./script/bundler_wrapper exec rake test:failure" + - *161 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/no_dependencies.gemfile - ruby_3-1-6__capistrano2_ubuntu-latest: - name: Ruby 3.1.6 - capistrano2 + BUNDLE_GEMFILE: gemfiles/psych-3-collector.gemfile + ruby_3-1-6__psych-4_ubuntu-latest: + name: Ruby 3.1.6 - psych-4 needs: ruby_3-1-6_ubuntu-latest runs-on: ubuntu-latest steps: @@ -4165,15 +8944,16 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &162 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/capistrano2.gemfile - ruby_3-1-6__capistrano3_ubuntu-latest: - name: Ruby 3.1.6 - capistrano3 + BUNDLE_GEMFILE: gemfiles/psych-4.gemfile + ruby_3-1-6__psych-4-collector_ubuntu-latest: + name: Ruby 3.1.6 - psych-4-collector needs: ruby_3-1-6_ubuntu-latest runs-on: ubuntu-latest steps: @@ -4192,15 +8972,14 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *162 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/capistrano3.gemfile - ruby_3-1-6__delayed_job_ubuntu-latest: - name: Ruby 3.1.6 - delayed_job + BUNDLE_GEMFILE: gemfiles/psych-4-collector.gemfile + ruby_3-1-6__que-0-14_ubuntu-latest: + name: Ruby 3.1.6 - que-0.14 needs: ruby_3-1-6_ubuntu-latest runs-on: ubuntu-latest steps: @@ -4219,15 +8998,16 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &163 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/delayed_job.gemfile - ruby_3-1-6__dry-monitor_ubuntu-latest: - name: Ruby 3.1.6 - dry-monitor + BUNDLE_GEMFILE: gemfiles/que-0.14.gemfile + ruby_3-1-6__que-0-14-collector_ubuntu-latest: + name: Ruby 3.1.6 - que-0.14-collector needs: ruby_3-1-6_ubuntu-latest runs-on: ubuntu-latest steps: @@ -4246,15 +9026,14 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *163 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/dry-monitor.gemfile - ruby_3-1-6__excon_ubuntu-latest: - name: Ruby 3.1.6 - excon + BUNDLE_GEMFILE: gemfiles/que-0.14-collector.gemfile + ruby_3-1-6__que-1_ubuntu-latest: + name: Ruby 3.1.6 - que-1 needs: ruby_3-1-6_ubuntu-latest runs-on: ubuntu-latest steps: @@ -4273,15 +9052,16 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &164 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/excon.gemfile - ruby_3-1-6__faraday-1_ubuntu-latest: - name: Ruby 3.1.6 - faraday-1 + BUNDLE_GEMFILE: gemfiles/que-1.gemfile + ruby_3-1-6__que-1-collector_ubuntu-latest: + name: Ruby 3.1.6 - que-1-collector needs: ruby_3-1-6_ubuntu-latest runs-on: ubuntu-latest steps: @@ -4300,15 +9080,14 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *164 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/faraday-1.gemfile - ruby_3-1-6__faraday-2_ubuntu-latest: - name: Ruby 3.1.6 - faraday-2 + BUNDLE_GEMFILE: gemfiles/que-1-collector.gemfile + ruby_3-1-6__que-2_ubuntu-latest: + name: Ruby 3.1.6 - que-2 needs: ruby_3-1-6_ubuntu-latest runs-on: ubuntu-latest steps: @@ -4327,15 +9106,16 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &165 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/faraday-2.gemfile - ruby_3-1-6__grape_ubuntu-latest: - name: Ruby 3.1.6 - grape + BUNDLE_GEMFILE: gemfiles/que-2.gemfile + ruby_3-1-6__que-2-collector_ubuntu-latest: + name: Ruby 3.1.6 - que-2-collector needs: ruby_3-1-6_ubuntu-latest runs-on: ubuntu-latest steps: @@ -4354,15 +9134,14 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *165 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/grape.gemfile - ruby_3-1-6__hanami-2-0_ubuntu-latest: - name: Ruby 3.1.6 - hanami-2.0 + BUNDLE_GEMFILE: gemfiles/que-2-collector.gemfile + ruby_3-1-6__rails-6-1_ubuntu-latest: + name: Ruby 3.1.6 - rails-6.1 needs: ruby_3-1-6_ubuntu-latest runs-on: ubuntu-latest steps: @@ -4381,15 +9160,16 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &166 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/hanami-2.0.gemfile - ruby_3-1-6__hanami-2-1_ubuntu-latest: - name: Ruby 3.1.6 - hanami-2.1 + BUNDLE_GEMFILE: gemfiles/rails-6.1.gemfile + ruby_3-1-6__rails-6-1-collector_ubuntu-latest: + name: Ruby 3.1.6 - rails-6.1-collector needs: ruby_3-1-6_ubuntu-latest runs-on: ubuntu-latest steps: @@ -4408,15 +9188,14 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *166 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/hanami-2.1.gemfile - ruby_3-1-6__hanami-2-2_ubuntu-latest: - name: Ruby 3.1.6 - hanami-2.2 + BUNDLE_GEMFILE: gemfiles/rails-6.1-collector.gemfile + ruby_3-1-6__rails-7-0_ubuntu-latest: + name: Ruby 3.1.6 - rails-7.0 needs: ruby_3-1-6_ubuntu-latest runs-on: ubuntu-latest steps: @@ -4435,15 +9214,16 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &167 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/hanami-2.2.gemfile - ruby_3-1-6__http5_ubuntu-latest: - name: Ruby 3.1.6 - http5 + BUNDLE_GEMFILE: gemfiles/rails-7.0.gemfile + ruby_3-1-6__rails-7-0-collector_ubuntu-latest: + name: Ruby 3.1.6 - rails-7.0-collector needs: ruby_3-1-6_ubuntu-latest runs-on: ubuntu-latest steps: @@ -4462,15 +9242,14 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *167 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/http5.gemfile - ruby_3-1-6__mongo_ubuntu-latest: - name: Ruby 3.1.6 - mongo + BUNDLE_GEMFILE: gemfiles/rails-7.0-collector.gemfile + ruby_3-1-6__rails-7-1_ubuntu-latest: + name: Ruby 3.1.6 - rails-7.1 needs: ruby_3-1-6_ubuntu-latest runs-on: ubuntu-latest steps: @@ -4489,15 +9268,16 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &168 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/mongo.gemfile - ruby_3-1-6__ownership_ubuntu-latest: - name: Ruby 3.1.6 - ownership + BUNDLE_GEMFILE: gemfiles/rails-7.1.gemfile + ruby_3-1-6__rails-7-1-collector_ubuntu-latest: + name: Ruby 3.1.6 - rails-7.1-collector needs: ruby_3-1-6_ubuntu-latest runs-on: ubuntu-latest steps: @@ -4516,15 +9296,14 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *168 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/ownership.gemfile - ruby_3-1-6__padrino_ubuntu-latest: - name: Ruby 3.1.6 - padrino + BUNDLE_GEMFILE: gemfiles/rails-7.1-collector.gemfile + ruby_3-1-6__rails-7-2_ubuntu-latest: + name: Ruby 3.1.6 - rails-7.2 needs: ruby_3-1-6_ubuntu-latest runs-on: ubuntu-latest steps: @@ -4543,15 +9322,16 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &169 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/padrino.gemfile - ruby_3-1-6__psych-3_ubuntu-latest: - name: Ruby 3.1.6 - psych-3 + BUNDLE_GEMFILE: gemfiles/rails-7.2.gemfile + ruby_3-1-6__rails-7-2-collector_ubuntu-latest: + name: Ruby 3.1.6 - rails-7.2-collector needs: ruby_3-1-6_ubuntu-latest runs-on: ubuntu-latest steps: @@ -4570,15 +9350,14 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *169 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/psych-3.gemfile - ruby_3-1-6__psych-4_ubuntu-latest: - name: Ruby 3.1.6 - psych-4 + BUNDLE_GEMFILE: gemfiles/rails-7.2-collector.gemfile + ruby_3-1-6__resque-2_ubuntu-latest: + name: Ruby 3.1.6 - resque-2 needs: ruby_3-1-6_ubuntu-latest runs-on: ubuntu-latest steps: @@ -4597,15 +9376,16 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &170 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/psych-4.gemfile - ruby_3-1-6__que-0-14_ubuntu-latest: - name: Ruby 3.1.6 - que-0.14 + BUNDLE_GEMFILE: gemfiles/resque-2.gemfile + ruby_3-1-6__resque-2-collector_ubuntu-latest: + name: Ruby 3.1.6 - resque-2-collector needs: ruby_3-1-6_ubuntu-latest runs-on: ubuntu-latest steps: @@ -4624,15 +9404,14 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *170 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/que-0.14.gemfile - ruby_3-1-6__que-1_ubuntu-latest: - name: Ruby 3.1.6 - que-1 + BUNDLE_GEMFILE: gemfiles/resque-2-collector.gemfile + ruby_3-1-6__resque-3_ubuntu-latest: + name: Ruby 3.1.6 - resque-3 needs: ruby_3-1-6_ubuntu-latest runs-on: ubuntu-latest steps: @@ -4651,15 +9430,16 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &171 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/que-1.gemfile - ruby_3-1-6__que-2_ubuntu-latest: - name: Ruby 3.1.6 - que-2 + BUNDLE_GEMFILE: gemfiles/resque-3.gemfile + ruby_3-1-6__resque-3-collector_ubuntu-latest: + name: Ruby 3.1.6 - resque-3-collector needs: ruby_3-1-6_ubuntu-latest runs-on: ubuntu-latest steps: @@ -4678,15 +9458,14 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *171 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/que-2.gemfile - ruby_3-1-6__rails-6-1_ubuntu-latest: - name: Ruby 3.1.6 - rails-6.1 + BUNDLE_GEMFILE: gemfiles/resque-3-collector.gemfile + ruby_3-1-6__sequel_ubuntu-latest: + name: Ruby 3.1.6 - sequel needs: ruby_3-1-6_ubuntu-latest runs-on: ubuntu-latest steps: @@ -4705,15 +9484,16 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &172 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/rails-6.1.gemfile - ruby_3-1-6__rails-7-0_ubuntu-latest: - name: Ruby 3.1.6 - rails-7.0 + BUNDLE_GEMFILE: gemfiles/sequel.gemfile + ruby_3-1-6__sequel-collector_ubuntu-latest: + name: Ruby 3.1.6 - sequel-collector needs: ruby_3-1-6_ubuntu-latest runs-on: ubuntu-latest steps: @@ -4732,15 +9512,14 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *172 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/rails-7.0.gemfile - ruby_3-1-6__rails-7-1_ubuntu-latest: - name: Ruby 3.1.6 - rails-7.1 + BUNDLE_GEMFILE: gemfiles/sequel-collector.gemfile + ruby_3-1-6__shoryuken-6_ubuntu-latest: + name: Ruby 3.1.6 - shoryuken-6 needs: ruby_3-1-6_ubuntu-latest runs-on: ubuntu-latest steps: @@ -4759,15 +9538,16 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &173 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/rails-7.1.gemfile - ruby_3-1-6__rails-7-2_ubuntu-latest: - name: Ruby 3.1.6 - rails-7.2 + BUNDLE_GEMFILE: gemfiles/shoryuken-6.gemfile + ruby_3-1-6__shoryuken-6-collector_ubuntu-latest: + name: Ruby 3.1.6 - shoryuken-6-collector needs: ruby_3-1-6_ubuntu-latest runs-on: ubuntu-latest steps: @@ -4786,15 +9566,14 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *173 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/rails-7.2.gemfile - ruby_3-1-6__resque-2_ubuntu-latest: - name: Ruby 3.1.6 - resque-2 + BUNDLE_GEMFILE: gemfiles/shoryuken-6-collector.gemfile + ruby_3-1-6__sinatra_ubuntu-latest: + name: Ruby 3.1.6 - sinatra needs: ruby_3-1-6_ubuntu-latest runs-on: ubuntu-latest steps: @@ -4813,15 +9592,16 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &174 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/resque-2.gemfile - ruby_3-1-6__resque-3_ubuntu-latest: - name: Ruby 3.1.6 - resque-3 + BUNDLE_GEMFILE: gemfiles/sinatra.gemfile + ruby_3-1-6__sinatra-collector_ubuntu-latest: + name: Ruby 3.1.6 - sinatra-collector needs: ruby_3-1-6_ubuntu-latest runs-on: ubuntu-latest steps: @@ -4840,15 +9620,14 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *174 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/resque-3.gemfile - ruby_3-1-6__sequel_ubuntu-latest: - name: Ruby 3.1.6 - sequel + BUNDLE_GEMFILE: gemfiles/sinatra-collector.gemfile + ruby_3-1-6__webmachine2_ubuntu-latest: + name: Ruby 3.1.6 - webmachine2 needs: ruby_3-1-6_ubuntu-latest runs-on: ubuntu-latest steps: @@ -4867,15 +9646,16 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &175 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/sequel.gemfile - ruby_3-1-6__shoryuken-6_ubuntu-latest: - name: Ruby 3.1.6 - shoryuken-6 + BUNDLE_GEMFILE: gemfiles/webmachine2.gemfile + ruby_3-1-6__webmachine2-collector_ubuntu-latest: + name: Ruby 3.1.6 - webmachine2-collector needs: ruby_3-1-6_ubuntu-latest runs-on: ubuntu-latest steps: @@ -4894,15 +9674,14 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *175 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/shoryuken-6.gemfile - ruby_3-1-6__sinatra_ubuntu-latest: - name: Ruby 3.1.6 - sinatra + BUNDLE_GEMFILE: gemfiles/webmachine2-collector.gemfile + ruby_3-1-6__redis-4_ubuntu-latest: + name: Ruby 3.1.6 - redis-4 needs: ruby_3-1-6_ubuntu-latest runs-on: ubuntu-latest steps: @@ -4921,15 +9700,16 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &176 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/sinatra.gemfile - ruby_3-1-6__webmachine2_ubuntu-latest: - name: Ruby 3.1.6 - webmachine2 + BUNDLE_GEMFILE: gemfiles/redis-4.gemfile + ruby_3-1-6__redis-4-collector_ubuntu-latest: + name: Ruby 3.1.6 - redis-4-collector needs: ruby_3-1-6_ubuntu-latest runs-on: ubuntu-latest steps: @@ -4948,15 +9728,14 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *176 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/webmachine2.gemfile - ruby_3-1-6__redis-4_ubuntu-latest: - name: Ruby 3.1.6 - redis-4 + BUNDLE_GEMFILE: gemfiles/redis-4-collector.gemfile + ruby_3-1-6__redis-5_ubuntu-latest: + name: Ruby 3.1.6 - redis-5 needs: ruby_3-1-6_ubuntu-latest runs-on: ubuntu-latest steps: @@ -4975,15 +9754,16 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &177 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/redis-4.gemfile - ruby_3-1-6__redis-5_ubuntu-latest: - name: Ruby 3.1.6 - redis-5 + BUNDLE_GEMFILE: gemfiles/redis-5.gemfile + ruby_3-1-6__redis-5-collector_ubuntu-latest: + name: Ruby 3.1.6 - redis-5-collector needs: ruby_3-1-6_ubuntu-latest runs-on: ubuntu-latest steps: @@ -5002,13 +9782,12 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *177 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/redis-5.gemfile + BUNDLE_GEMFILE: gemfiles/redis-5-collector.gemfile ruby_3-1-6_macos-14: name: Ruby 3.1.6 (macos-14) needs: validation diff --git a/Rakefile b/Rakefile index 021e7cf76..0fd72468b 100644 --- a/Rakefile +++ b/Rakefile @@ -72,7 +72,50 @@ GITHUB_ACTION_WORKFLOW_FILE = ".github/workflows/ci.yml" PRIMARY_JOB_GEMSET = "no_dependencies" DEFAULT_RUNS_ON = "ubuntu-latest" +COLLECTOR_GEMFILE_PARTIAL = "collector.rb" + namespace :build_matrix do + namespace :gemfiles do + # Generates a `-collector.gemfile` next to each base gemfile. The + # variant layers the optional OpenTelemetry gems (`gemfiles/collector.rb`) + # on top of the base, so collector-mode test runs resolve them while the + # base gemfiles (and the gemspec) stay OpenTelemetry-free. Regenerate + # whenever a base gemfile is added or removed. + task :generate do + base_gemfiles = + Dir["gemfiles/*.gemfile"] + .map { |path| File.basename(path) } + .reject { |name| name.end_with?("-collector.gemfile") } + .sort + + base_gemfiles.each do |base| + name = base.sub(/\.gemfile\z/, "") + contents = + "# DO NOT EDIT\n" \ + "# This is a generated file by the " \ + "`rake build_matrix:gemfiles:generate` task.\n" \ + "# It layers the optional OpenTelemetry gems (gemfiles/" \ + "#{COLLECTOR_GEMFILE_PARTIAL}) on top of #{base}.\n" \ + "\n" \ + "eval_gemfile File.expand_path(#{base.inspect}, __dir__)\n" \ + "eval_gemfile File.expand_path(" \ + "#{COLLECTOR_GEMFILE_PARTIAL.inspect}, __dir__)\n" + File.write("gemfiles/#{name}-collector.gemfile", contents) + end + + puts "Generated #{base_gemfiles.count} `-collector` gemfiles." + end + + task :validate => :generate do + output = `git status --porcelain gemfiles` + if output.include?("-collector.gemfile") + puts "The `-collector` gemfiles are out of date. The changes were not committed." + puts "Please run `rake build_matrix:gemfiles:generate` and commit the changes." + exit 1 + end + end + end + namespace :github do task :generate do yaml = YAML.load_file("build_matrix.yml") @@ -113,6 +156,21 @@ namespace :build_matrix do job["steps"] << test_step builds[build_matrix_key(ruby["ruby"], :ruby_gem => ruby_gem["gem"])] = job end + + # On collector-capable Rubies, additionally run the gem's + # `-collector` gemfile (base gems + optional OpenTelemetry gems) so + # collector-mode specs are exercised. These always depend on the + # primary job for the Ruby version and run on Ubuntu only. + next unless collector_ruby?(matrix, ruby_version) + + collector_gem = "#{ruby_gem["gem"]}-collector" + collector_job = build_job(ruby_version, :ruby_gem => collector_gem) + collector_job["env"] = matrix["env"] + .merge("BUNDLE_GEMFILE" => "gemfiles/#{collector_gem}.gemfile") + collector_job["needs"] = build_matrix_key(ruby["ruby"]) + collector_job["steps"] << test_step + builds[build_matrix_key(ruby["ruby"], :ruby_gem => collector_gem)] = + collector_job end # Add build for macOS @@ -191,6 +249,14 @@ namespace :build_matrix do out << "#{bundler_version} #{gemfile_env} ./script/bundler_wrapper install --quiet || { echo 'Bundling failed'; exit 1; }" out << "echo 'Running #{gemfile} in #{ruby_version}'" out << "#{bundler_version} #{gemfile_env} ./script/bundler_wrapper exec rspec || { echo 'Running specs failed'; exit 1; }" + + next unless collector_ruby?(matrix, ruby_version) + + collector_env = "env BUNDLE_GEMFILE=gemfiles/#{gemfile}-collector.gemfile" + out << "echo 'Bundling #{gemfile}-collector in #{ruby_version}'" + out << "#{bundler_version} #{collector_env} ./script/bundler_wrapper install --quiet || { echo 'Bundling failed'; exit 1; }" + out << "echo 'Running #{gemfile}-collector in #{ruby_version}'" + out << "#{bundler_version} #{collector_env} ./script/bundler_wrapper exec rspec || { echo 'Running specs failed'; exit 1; }" end # rubocop:enable Layout/LineLength out << "" @@ -207,6 +273,10 @@ namespace :build_matrix do end end + def collector_ruby?(matrix, ruby_version) + Array(matrix.dig("collector", "ruby")).include?(ruby_version) + end + def gemset_for_ruby(ruby, matrix) gems = matrix["gems"] if ruby["gems"] @@ -288,6 +358,23 @@ namespace :build do desc "Build all gem versions" task :all => ["ruby:gem", "jruby:gem"] + desc "Build every package in this repository" + task :packages => :all do + # Build the companion gem by delegating to its own Rakefile rather than + # duplicating its build logic here. mono builds each package directly, so + # this task is only a convenience for developers building from the root. + # + # Clear the Bundler environment first so the sub-build resolves against the + # package's own Gemfile instead of inheriting this repository's + # BUNDLE_GEMFILE. Install its dependencies first, then build it. + Bundler.with_unbundled_env do + Dir.chdir("packages/opentelemetry") do + sh "bundle install" + sh "bundle exec rake build:all" + end + end + end + desc "Clean up all gem build artifacts" task :clean do FileUtils.rm_rf File.expand_path("pkg", __dir__) diff --git a/appsignal.gemspec b/appsignal.gemspec index 087b13c7d..5711bc360 100644 --- a/appsignal.gemspec +++ b/appsignal.gemspec @@ -7,6 +7,7 @@ IGNORED_PATHS = [ ".changesets/", ".github/", "gemfiles/", + "packages/", "script/", "spec/", @@ -60,6 +61,13 @@ Gem::Specification.new do |gem| # Needs 2.0+ because we rely on Rack::Events gem.add_dependency "rack", ">= 2.0.0" + # The OpenTelemetry SDK and OTLP exporters are *optional* and intentionally + # not declared here: they're only needed in collector mode (Ruby 3.1+), so + # bundling them would break Ruby 2.7 and burden non-collector users. Apps + # that enable collector mode add them to their own Gemfile; the minimum + # versions live in `lib/appsignal/opentelemetry/dependencies.rb` and are + # enforced at boot by `Appsignal::OpenTelemetry.configure`. + gem.add_development_dependency "pry" gem.add_development_dependency "rake", ">= 12" gem.add_development_dependency "rspec", "~> 3.8" diff --git a/build_matrix.yml b/build_matrix.yml index f84f04458..db976afa9 100644 --- a/build_matrix.yml +++ b/build_matrix.yml @@ -107,6 +107,18 @@ matrix: JRUBY_OPTS: "" COV: "1" + # Ruby versions that additionally run each gem's `-collector` gemfile (the + # optional OpenTelemetry gems layered on top). Collector mode requires Ruby + # 3.1+ (the OTel metrics SDK's fork hooks rely on `Process._fork`) and does + # not support JRuby's forking model, so only these versions are listed. + collector: + ruby: + - "4.0.0" + - "3.4.1" + - "3.3.4" + - "3.2.5" + - "3.1.6" + gemsets: # By default all gems are tested none: - "no_dependencies" @@ -281,6 +293,7 @@ matrix: - "3.4.1" - "3.3.4" - "3.2.5" + - gem: "mongo" - gem: "resque-2" - gem: "resque-3" only: diff --git a/ext/appsignal_extension.c b/ext/appsignal_extension.c index 97e0c84a7..12bc81e34 100644 --- a/ext/appsignal_extension.c +++ b/ext/appsignal_extension.c @@ -834,10 +834,23 @@ static VALUE add_distribution_value(VALUE self, VALUE key, VALUE value, VALUE ta return Qnil; } +// Per-thread running total of object allocations, incremented on every Ruby +// NEWOBJ event. Thread-local because MRI maps each Ruby thread to its own OS +// thread, so this attributes allocations to the thread doing the work, which is +// the thread the transaction runs on. Collector mode reads it through +// Appsignal::Extension.allocation_count and diffs two snapshots to get the +// allocations made during a transaction or an event. +static __thread unsigned long long appsignal_thread_allocation_count = 0; + static void track_allocation(rb_event_flag_t flag, VALUE arg1, VALUE arg2, ID arg3, VALUE arg4) { + appsignal_thread_allocation_count++; appsignal_track_allocation(); } +static VALUE allocation_count(VALUE self) { + return ULL2NUM(appsignal_thread_allocation_count); +} + static VALUE install_allocation_event_hook(VALUE self) { // This event hook is only available on Ruby 2.1 and 2.2 #if defined(RUBY_INTERNAL_EVENT_NEWOBJ) @@ -956,6 +969,7 @@ void Init_appsignal_extension(void) { // Other helper methods rb_define_singleton_method(Extension, "install_allocation_event_hook", install_allocation_event_hook, 0); + rb_define_singleton_method(Extension, "allocation_count", allocation_count, 0); rb_define_singleton_method(Extension, "running_in_container?", running_in_container, 0); rb_define_singleton_method(Extension, "set_environment_metadata", set_environment_metadata, 2); diff --git a/gemfiles/capistrano2-collector.gemfile b/gemfiles/capistrano2-collector.gemfile new file mode 100644 index 000000000..6c4960552 --- /dev/null +++ b/gemfiles/capistrano2-collector.gemfile @@ -0,0 +1,6 @@ +# DO NOT EDIT +# This is a generated file by the `rake build_matrix:gemfiles:generate` task. +# It layers the optional OpenTelemetry gems (gemfiles/collector.rb) on top of capistrano2.gemfile. + +eval_gemfile File.expand_path("capistrano2.gemfile", __dir__) +eval_gemfile File.expand_path("collector.rb", __dir__) diff --git a/gemfiles/capistrano3-collector.gemfile b/gemfiles/capistrano3-collector.gemfile new file mode 100644 index 000000000..3c87f37ec --- /dev/null +++ b/gemfiles/capistrano3-collector.gemfile @@ -0,0 +1,6 @@ +# DO NOT EDIT +# This is a generated file by the `rake build_matrix:gemfiles:generate` task. +# It layers the optional OpenTelemetry gems (gemfiles/collector.rb) on top of capistrano3.gemfile. + +eval_gemfile File.expand_path("capistrano3.gemfile", __dir__) +eval_gemfile File.expand_path("collector.rb", __dir__) diff --git a/gemfiles/code_ownership-collector.gemfile b/gemfiles/code_ownership-collector.gemfile new file mode 100644 index 000000000..e3e4add7f --- /dev/null +++ b/gemfiles/code_ownership-collector.gemfile @@ -0,0 +1,6 @@ +# DO NOT EDIT +# This is a generated file by the `rake build_matrix:gemfiles:generate` task. +# It layers the optional OpenTelemetry gems (gemfiles/collector.rb) on top of code_ownership.gemfile. + +eval_gemfile File.expand_path("code_ownership.gemfile", __dir__) +eval_gemfile File.expand_path("collector.rb", __dir__) diff --git a/gemfiles/collector.rb b/gemfiles/collector.rb new file mode 100644 index 000000000..383581571 --- /dev/null +++ b/gemfiles/collector.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +# Gemfile fragment that adds the optional OpenTelemetry gems collector mode +# needs. `eval_gemfile`'d by the `*-collector.gemfile` variants on top of their +# base gemfile. The versions come from the single source of truth shared with +# the runtime version gate. +require_relative "../lib/appsignal/opentelemetry/dependencies" + +Appsignal::OpenTelemetry::REQUIRED_GEMS.each do |name, constraints| + gem name, *constraints +end diff --git a/gemfiles/delayed_job-collector.gemfile b/gemfiles/delayed_job-collector.gemfile new file mode 100644 index 000000000..0905ec09d --- /dev/null +++ b/gemfiles/delayed_job-collector.gemfile @@ -0,0 +1,6 @@ +# DO NOT EDIT +# This is a generated file by the `rake build_matrix:gemfiles:generate` task. +# It layers the optional OpenTelemetry gems (gemfiles/collector.rb) on top of delayed_job.gemfile. + +eval_gemfile File.expand_path("delayed_job.gemfile", __dir__) +eval_gemfile File.expand_path("collector.rb", __dir__) diff --git a/gemfiles/dry-monitor-collector.gemfile b/gemfiles/dry-monitor-collector.gemfile new file mode 100644 index 000000000..9bd8b9410 --- /dev/null +++ b/gemfiles/dry-monitor-collector.gemfile @@ -0,0 +1,6 @@ +# DO NOT EDIT +# This is a generated file by the `rake build_matrix:gemfiles:generate` task. +# It layers the optional OpenTelemetry gems (gemfiles/collector.rb) on top of dry-monitor.gemfile. + +eval_gemfile File.expand_path("dry-monitor.gemfile", __dir__) +eval_gemfile File.expand_path("collector.rb", __dir__) diff --git a/gemfiles/excon-collector.gemfile b/gemfiles/excon-collector.gemfile new file mode 100644 index 000000000..d8567df01 --- /dev/null +++ b/gemfiles/excon-collector.gemfile @@ -0,0 +1,6 @@ +# DO NOT EDIT +# This is a generated file by the `rake build_matrix:gemfiles:generate` task. +# It layers the optional OpenTelemetry gems (gemfiles/collector.rb) on top of excon.gemfile. + +eval_gemfile File.expand_path("excon.gemfile", __dir__) +eval_gemfile File.expand_path("collector.rb", __dir__) diff --git a/gemfiles/faraday-1-collector.gemfile b/gemfiles/faraday-1-collector.gemfile new file mode 100644 index 000000000..252188545 --- /dev/null +++ b/gemfiles/faraday-1-collector.gemfile @@ -0,0 +1,6 @@ +# DO NOT EDIT +# This is a generated file by the `rake build_matrix:gemfiles:generate` task. +# It layers the optional OpenTelemetry gems (gemfiles/collector.rb) on top of faraday-1.gemfile. + +eval_gemfile File.expand_path("faraday-1.gemfile", __dir__) +eval_gemfile File.expand_path("collector.rb", __dir__) diff --git a/gemfiles/faraday-2-collector.gemfile b/gemfiles/faraday-2-collector.gemfile new file mode 100644 index 000000000..4af7281ed --- /dev/null +++ b/gemfiles/faraday-2-collector.gemfile @@ -0,0 +1,6 @@ +# DO NOT EDIT +# This is a generated file by the `rake build_matrix:gemfiles:generate` task. +# It layers the optional OpenTelemetry gems (gemfiles/collector.rb) on top of faraday-2.gemfile. + +eval_gemfile File.expand_path("faraday-2.gemfile", __dir__) +eval_gemfile File.expand_path("collector.rb", __dir__) diff --git a/gemfiles/grape-collector.gemfile b/gemfiles/grape-collector.gemfile new file mode 100644 index 000000000..84298ef55 --- /dev/null +++ b/gemfiles/grape-collector.gemfile @@ -0,0 +1,6 @@ +# DO NOT EDIT +# This is a generated file by the `rake build_matrix:gemfiles:generate` task. +# It layers the optional OpenTelemetry gems (gemfiles/collector.rb) on top of grape.gemfile. + +eval_gemfile File.expand_path("grape.gemfile", __dir__) +eval_gemfile File.expand_path("collector.rb", __dir__) diff --git a/gemfiles/hanami-2.0-collector.gemfile b/gemfiles/hanami-2.0-collector.gemfile new file mode 100644 index 000000000..61d138330 --- /dev/null +++ b/gemfiles/hanami-2.0-collector.gemfile @@ -0,0 +1,6 @@ +# DO NOT EDIT +# This is a generated file by the `rake build_matrix:gemfiles:generate` task. +# It layers the optional OpenTelemetry gems (gemfiles/collector.rb) on top of hanami-2.0.gemfile. + +eval_gemfile File.expand_path("hanami-2.0.gemfile", __dir__) +eval_gemfile File.expand_path("collector.rb", __dir__) diff --git a/gemfiles/hanami-2.1-collector.gemfile b/gemfiles/hanami-2.1-collector.gemfile new file mode 100644 index 000000000..eae8eadfe --- /dev/null +++ b/gemfiles/hanami-2.1-collector.gemfile @@ -0,0 +1,6 @@ +# DO NOT EDIT +# This is a generated file by the `rake build_matrix:gemfiles:generate` task. +# It layers the optional OpenTelemetry gems (gemfiles/collector.rb) on top of hanami-2.1.gemfile. + +eval_gemfile File.expand_path("hanami-2.1.gemfile", __dir__) +eval_gemfile File.expand_path("collector.rb", __dir__) diff --git a/gemfiles/hanami-2.2-collector.gemfile b/gemfiles/hanami-2.2-collector.gemfile new file mode 100644 index 000000000..2dbec63ea --- /dev/null +++ b/gemfiles/hanami-2.2-collector.gemfile @@ -0,0 +1,6 @@ +# DO NOT EDIT +# This is a generated file by the `rake build_matrix:gemfiles:generate` task. +# It layers the optional OpenTelemetry gems (gemfiles/collector.rb) on top of hanami-2.2.gemfile. + +eval_gemfile File.expand_path("hanami-2.2.gemfile", __dir__) +eval_gemfile File.expand_path("collector.rb", __dir__) diff --git a/gemfiles/http5-collector.gemfile b/gemfiles/http5-collector.gemfile new file mode 100644 index 000000000..5e72919bf --- /dev/null +++ b/gemfiles/http5-collector.gemfile @@ -0,0 +1,6 @@ +# DO NOT EDIT +# This is a generated file by the `rake build_matrix:gemfiles:generate` task. +# It layers the optional OpenTelemetry gems (gemfiles/collector.rb) on top of http5.gemfile. + +eval_gemfile File.expand_path("http5.gemfile", __dir__) +eval_gemfile File.expand_path("collector.rb", __dir__) diff --git a/gemfiles/http6-collector.gemfile b/gemfiles/http6-collector.gemfile new file mode 100644 index 000000000..eda44c063 --- /dev/null +++ b/gemfiles/http6-collector.gemfile @@ -0,0 +1,6 @@ +# DO NOT EDIT +# This is a generated file by the `rake build_matrix:gemfiles:generate` task. +# It layers the optional OpenTelemetry gems (gemfiles/collector.rb) on top of http6.gemfile. + +eval_gemfile File.expand_path("http6.gemfile", __dir__) +eval_gemfile File.expand_path("collector.rb", __dir__) diff --git a/gemfiles/mongo-collector.gemfile b/gemfiles/mongo-collector.gemfile new file mode 100644 index 000000000..b1cbafcf6 --- /dev/null +++ b/gemfiles/mongo-collector.gemfile @@ -0,0 +1,6 @@ +# DO NOT EDIT +# This is a generated file by the `rake build_matrix:gemfiles:generate` task. +# It layers the optional OpenTelemetry gems (gemfiles/collector.rb) on top of mongo.gemfile. + +eval_gemfile File.expand_path("mongo.gemfile", __dir__) +eval_gemfile File.expand_path("collector.rb", __dir__) diff --git a/gemfiles/no_dependencies-collector.gemfile b/gemfiles/no_dependencies-collector.gemfile new file mode 100644 index 000000000..04f8ff2ba --- /dev/null +++ b/gemfiles/no_dependencies-collector.gemfile @@ -0,0 +1,6 @@ +# DO NOT EDIT +# This is a generated file by the `rake build_matrix:gemfiles:generate` task. +# It layers the optional OpenTelemetry gems (gemfiles/collector.rb) on top of no_dependencies.gemfile. + +eval_gemfile File.expand_path("no_dependencies.gemfile", __dir__) +eval_gemfile File.expand_path("collector.rb", __dir__) diff --git a/gemfiles/ownership-collector.gemfile b/gemfiles/ownership-collector.gemfile new file mode 100644 index 000000000..964401c18 --- /dev/null +++ b/gemfiles/ownership-collector.gemfile @@ -0,0 +1,6 @@ +# DO NOT EDIT +# This is a generated file by the `rake build_matrix:gemfiles:generate` task. +# It layers the optional OpenTelemetry gems (gemfiles/collector.rb) on top of ownership.gemfile. + +eval_gemfile File.expand_path("ownership.gemfile", __dir__) +eval_gemfile File.expand_path("collector.rb", __dir__) diff --git a/gemfiles/padrino-collector.gemfile b/gemfiles/padrino-collector.gemfile new file mode 100644 index 000000000..8c04046d8 --- /dev/null +++ b/gemfiles/padrino-collector.gemfile @@ -0,0 +1,6 @@ +# DO NOT EDIT +# This is a generated file by the `rake build_matrix:gemfiles:generate` task. +# It layers the optional OpenTelemetry gems (gemfiles/collector.rb) on top of padrino.gemfile. + +eval_gemfile File.expand_path("padrino.gemfile", __dir__) +eval_gemfile File.expand_path("collector.rb", __dir__) diff --git a/gemfiles/psych-3-collector.gemfile b/gemfiles/psych-3-collector.gemfile new file mode 100644 index 000000000..570c8704c --- /dev/null +++ b/gemfiles/psych-3-collector.gemfile @@ -0,0 +1,6 @@ +# DO NOT EDIT +# This is a generated file by the `rake build_matrix:gemfiles:generate` task. +# It layers the optional OpenTelemetry gems (gemfiles/collector.rb) on top of psych-3.gemfile. + +eval_gemfile File.expand_path("psych-3.gemfile", __dir__) +eval_gemfile File.expand_path("collector.rb", __dir__) diff --git a/gemfiles/psych-4-collector.gemfile b/gemfiles/psych-4-collector.gemfile new file mode 100644 index 000000000..55546c9e4 --- /dev/null +++ b/gemfiles/psych-4-collector.gemfile @@ -0,0 +1,6 @@ +# DO NOT EDIT +# This is a generated file by the `rake build_matrix:gemfiles:generate` task. +# It layers the optional OpenTelemetry gems (gemfiles/collector.rb) on top of psych-4.gemfile. + +eval_gemfile File.expand_path("psych-4.gemfile", __dir__) +eval_gemfile File.expand_path("collector.rb", __dir__) diff --git a/gemfiles/que-0.14-collector.gemfile b/gemfiles/que-0.14-collector.gemfile new file mode 100644 index 000000000..825bceab2 --- /dev/null +++ b/gemfiles/que-0.14-collector.gemfile @@ -0,0 +1,6 @@ +# DO NOT EDIT +# This is a generated file by the `rake build_matrix:gemfiles:generate` task. +# It layers the optional OpenTelemetry gems (gemfiles/collector.rb) on top of que-0.14.gemfile. + +eval_gemfile File.expand_path("que-0.14.gemfile", __dir__) +eval_gemfile File.expand_path("collector.rb", __dir__) diff --git a/gemfiles/que-1-collector.gemfile b/gemfiles/que-1-collector.gemfile new file mode 100644 index 000000000..df75ac571 --- /dev/null +++ b/gemfiles/que-1-collector.gemfile @@ -0,0 +1,6 @@ +# DO NOT EDIT +# This is a generated file by the `rake build_matrix:gemfiles:generate` task. +# It layers the optional OpenTelemetry gems (gemfiles/collector.rb) on top of que-1.gemfile. + +eval_gemfile File.expand_path("que-1.gemfile", __dir__) +eval_gemfile File.expand_path("collector.rb", __dir__) diff --git a/gemfiles/que-2-collector.gemfile b/gemfiles/que-2-collector.gemfile new file mode 100644 index 000000000..ed1b038b1 --- /dev/null +++ b/gemfiles/que-2-collector.gemfile @@ -0,0 +1,6 @@ +# DO NOT EDIT +# This is a generated file by the `rake build_matrix:gemfiles:generate` task. +# It layers the optional OpenTelemetry gems (gemfiles/collector.rb) on top of que-2.gemfile. + +eval_gemfile File.expand_path("que-2.gemfile", __dir__) +eval_gemfile File.expand_path("collector.rb", __dir__) diff --git a/gemfiles/rails-6.0-collector.gemfile b/gemfiles/rails-6.0-collector.gemfile new file mode 100644 index 000000000..5e4a313e9 --- /dev/null +++ b/gemfiles/rails-6.0-collector.gemfile @@ -0,0 +1,6 @@ +# DO NOT EDIT +# This is a generated file by the `rake build_matrix:gemfiles:generate` task. +# It layers the optional OpenTelemetry gems (gemfiles/collector.rb) on top of rails-6.0.gemfile. + +eval_gemfile File.expand_path("rails-6.0.gemfile", __dir__) +eval_gemfile File.expand_path("collector.rb", __dir__) diff --git a/gemfiles/rails-6.1-collector.gemfile b/gemfiles/rails-6.1-collector.gemfile new file mode 100644 index 000000000..b690ee546 --- /dev/null +++ b/gemfiles/rails-6.1-collector.gemfile @@ -0,0 +1,6 @@ +# DO NOT EDIT +# This is a generated file by the `rake build_matrix:gemfiles:generate` task. +# It layers the optional OpenTelemetry gems (gemfiles/collector.rb) on top of rails-6.1.gemfile. + +eval_gemfile File.expand_path("rails-6.1.gemfile", __dir__) +eval_gemfile File.expand_path("collector.rb", __dir__) diff --git a/gemfiles/rails-7.0-collector.gemfile b/gemfiles/rails-7.0-collector.gemfile new file mode 100644 index 000000000..63b692381 --- /dev/null +++ b/gemfiles/rails-7.0-collector.gemfile @@ -0,0 +1,6 @@ +# DO NOT EDIT +# This is a generated file by the `rake build_matrix:gemfiles:generate` task. +# It layers the optional OpenTelemetry gems (gemfiles/collector.rb) on top of rails-7.0.gemfile. + +eval_gemfile File.expand_path("rails-7.0.gemfile", __dir__) +eval_gemfile File.expand_path("collector.rb", __dir__) diff --git a/gemfiles/rails-7.1-collector.gemfile b/gemfiles/rails-7.1-collector.gemfile new file mode 100644 index 000000000..733c2dba5 --- /dev/null +++ b/gemfiles/rails-7.1-collector.gemfile @@ -0,0 +1,6 @@ +# DO NOT EDIT +# This is a generated file by the `rake build_matrix:gemfiles:generate` task. +# It layers the optional OpenTelemetry gems (gemfiles/collector.rb) on top of rails-7.1.gemfile. + +eval_gemfile File.expand_path("rails-7.1.gemfile", __dir__) +eval_gemfile File.expand_path("collector.rb", __dir__) diff --git a/gemfiles/rails-7.2-collector.gemfile b/gemfiles/rails-7.2-collector.gemfile new file mode 100644 index 000000000..458c31cf4 --- /dev/null +++ b/gemfiles/rails-7.2-collector.gemfile @@ -0,0 +1,6 @@ +# DO NOT EDIT +# This is a generated file by the `rake build_matrix:gemfiles:generate` task. +# It layers the optional OpenTelemetry gems (gemfiles/collector.rb) on top of rails-7.2.gemfile. + +eval_gemfile File.expand_path("rails-7.2.gemfile", __dir__) +eval_gemfile File.expand_path("collector.rb", __dir__) diff --git a/gemfiles/rails-8.0-collector.gemfile b/gemfiles/rails-8.0-collector.gemfile new file mode 100644 index 000000000..7ea545dc3 --- /dev/null +++ b/gemfiles/rails-8.0-collector.gemfile @@ -0,0 +1,6 @@ +# DO NOT EDIT +# This is a generated file by the `rake build_matrix:gemfiles:generate` task. +# It layers the optional OpenTelemetry gems (gemfiles/collector.rb) on top of rails-8.0.gemfile. + +eval_gemfile File.expand_path("rails-8.0.gemfile", __dir__) +eval_gemfile File.expand_path("collector.rb", __dir__) diff --git a/gemfiles/rails-8.1-collector.gemfile b/gemfiles/rails-8.1-collector.gemfile new file mode 100644 index 000000000..2d67888a2 --- /dev/null +++ b/gemfiles/rails-8.1-collector.gemfile @@ -0,0 +1,6 @@ +# DO NOT EDIT +# This is a generated file by the `rake build_matrix:gemfiles:generate` task. +# It layers the optional OpenTelemetry gems (gemfiles/collector.rb) on top of rails-8.1.gemfile. + +eval_gemfile File.expand_path("rails-8.1.gemfile", __dir__) +eval_gemfile File.expand_path("collector.rb", __dir__) diff --git a/gemfiles/redis-4-collector.gemfile b/gemfiles/redis-4-collector.gemfile new file mode 100644 index 000000000..2e5d42742 --- /dev/null +++ b/gemfiles/redis-4-collector.gemfile @@ -0,0 +1,6 @@ +# DO NOT EDIT +# This is a generated file by the `rake build_matrix:gemfiles:generate` task. +# It layers the optional OpenTelemetry gems (gemfiles/collector.rb) on top of redis-4.gemfile. + +eval_gemfile File.expand_path("redis-4.gemfile", __dir__) +eval_gemfile File.expand_path("collector.rb", __dir__) diff --git a/gemfiles/redis-5-collector.gemfile b/gemfiles/redis-5-collector.gemfile new file mode 100644 index 000000000..dfeb0015a --- /dev/null +++ b/gemfiles/redis-5-collector.gemfile @@ -0,0 +1,6 @@ +# DO NOT EDIT +# This is a generated file by the `rake build_matrix:gemfiles:generate` task. +# It layers the optional OpenTelemetry gems (gemfiles/collector.rb) on top of redis-5.gemfile. + +eval_gemfile File.expand_path("redis-5.gemfile", __dir__) +eval_gemfile File.expand_path("collector.rb", __dir__) diff --git a/gemfiles/resque-2-collector.gemfile b/gemfiles/resque-2-collector.gemfile new file mode 100644 index 000000000..49486175a --- /dev/null +++ b/gemfiles/resque-2-collector.gemfile @@ -0,0 +1,6 @@ +# DO NOT EDIT +# This is a generated file by the `rake build_matrix:gemfiles:generate` task. +# It layers the optional OpenTelemetry gems (gemfiles/collector.rb) on top of resque-2.gemfile. + +eval_gemfile File.expand_path("resque-2.gemfile", __dir__) +eval_gemfile File.expand_path("collector.rb", __dir__) diff --git a/gemfiles/resque-3-collector.gemfile b/gemfiles/resque-3-collector.gemfile new file mode 100644 index 000000000..f88745316 --- /dev/null +++ b/gemfiles/resque-3-collector.gemfile @@ -0,0 +1,6 @@ +# DO NOT EDIT +# This is a generated file by the `rake build_matrix:gemfiles:generate` task. +# It layers the optional OpenTelemetry gems (gemfiles/collector.rb) on top of resque-3.gemfile. + +eval_gemfile File.expand_path("resque-3.gemfile", __dir__) +eval_gemfile File.expand_path("collector.rb", __dir__) diff --git a/gemfiles/sequel-collector.gemfile b/gemfiles/sequel-collector.gemfile new file mode 100644 index 000000000..884a85b96 --- /dev/null +++ b/gemfiles/sequel-collector.gemfile @@ -0,0 +1,6 @@ +# DO NOT EDIT +# This is a generated file by the `rake build_matrix:gemfiles:generate` task. +# It layers the optional OpenTelemetry gems (gemfiles/collector.rb) on top of sequel.gemfile. + +eval_gemfile File.expand_path("sequel.gemfile", __dir__) +eval_gemfile File.expand_path("collector.rb", __dir__) diff --git a/gemfiles/shoryuken-6-collector.gemfile b/gemfiles/shoryuken-6-collector.gemfile new file mode 100644 index 000000000..cfb5bb853 --- /dev/null +++ b/gemfiles/shoryuken-6-collector.gemfile @@ -0,0 +1,6 @@ +# DO NOT EDIT +# This is a generated file by the `rake build_matrix:gemfiles:generate` task. +# It layers the optional OpenTelemetry gems (gemfiles/collector.rb) on top of shoryuken-6.gemfile. + +eval_gemfile File.expand_path("shoryuken-6.gemfile", __dir__) +eval_gemfile File.expand_path("collector.rb", __dir__) diff --git a/gemfiles/shoryuken-7-collector.gemfile b/gemfiles/shoryuken-7-collector.gemfile new file mode 100644 index 000000000..92a40d573 --- /dev/null +++ b/gemfiles/shoryuken-7-collector.gemfile @@ -0,0 +1,6 @@ +# DO NOT EDIT +# This is a generated file by the `rake build_matrix:gemfiles:generate` task. +# It layers the optional OpenTelemetry gems (gemfiles/collector.rb) on top of shoryuken-7.gemfile. + +eval_gemfile File.expand_path("shoryuken-7.gemfile", __dir__) +eval_gemfile File.expand_path("collector.rb", __dir__) diff --git a/gemfiles/sidekiq-7-collector.gemfile b/gemfiles/sidekiq-7-collector.gemfile new file mode 100644 index 000000000..b8971bbcd --- /dev/null +++ b/gemfiles/sidekiq-7-collector.gemfile @@ -0,0 +1,6 @@ +# DO NOT EDIT +# This is a generated file by the `rake build_matrix:gemfiles:generate` task. +# It layers the optional OpenTelemetry gems (gemfiles/collector.rb) on top of sidekiq-7.gemfile. + +eval_gemfile File.expand_path("sidekiq-7.gemfile", __dir__) +eval_gemfile File.expand_path("collector.rb", __dir__) diff --git a/gemfiles/sidekiq-8-collector.gemfile b/gemfiles/sidekiq-8-collector.gemfile new file mode 100644 index 000000000..26781c4e3 --- /dev/null +++ b/gemfiles/sidekiq-8-collector.gemfile @@ -0,0 +1,6 @@ +# DO NOT EDIT +# This is a generated file by the `rake build_matrix:gemfiles:generate` task. +# It layers the optional OpenTelemetry gems (gemfiles/collector.rb) on top of sidekiq-8.gemfile. + +eval_gemfile File.expand_path("sidekiq-8.gemfile", __dir__) +eval_gemfile File.expand_path("collector.rb", __dir__) diff --git a/gemfiles/sinatra-collector.gemfile b/gemfiles/sinatra-collector.gemfile new file mode 100644 index 000000000..921768d1e --- /dev/null +++ b/gemfiles/sinatra-collector.gemfile @@ -0,0 +1,6 @@ +# DO NOT EDIT +# This is a generated file by the `rake build_matrix:gemfiles:generate` task. +# It layers the optional OpenTelemetry gems (gemfiles/collector.rb) on top of sinatra.gemfile. + +eval_gemfile File.expand_path("sinatra.gemfile", __dir__) +eval_gemfile File.expand_path("collector.rb", __dir__) diff --git a/gemfiles/webmachine2-collector.gemfile b/gemfiles/webmachine2-collector.gemfile new file mode 100644 index 000000000..6f62919d2 --- /dev/null +++ b/gemfiles/webmachine2-collector.gemfile @@ -0,0 +1,6 @@ +# DO NOT EDIT +# This is a generated file by the `rake build_matrix:gemfiles:generate` task. +# It layers the optional OpenTelemetry gems (gemfiles/collector.rb) on top of webmachine2.gemfile. + +eval_gemfile File.expand_path("webmachine2.gemfile", __dir__) +eval_gemfile File.expand_path("collector.rb", __dir__) diff --git a/lib/appsignal.rb b/lib/appsignal.rb index 754182108..75c6ef40a 100644 --- a/lib/appsignal.rb +++ b/lib/appsignal.rb @@ -8,6 +8,8 @@ require "appsignal/utils/stdout_and_logger_message" require "appsignal/helpers/instrumentation" require "appsignal/helpers/metrics" +require "appsignal/backends" +require "appsignal/opentelemetry" # AppSignal for Ruby gem's main module. # @@ -143,6 +145,8 @@ def start Appsignal::Hooks.load_hooks Appsignal::Loaders.start + Appsignal::OpenTelemetry.configure(config) if config.collector_mode_configured? + if config[:enable_allocation_tracking] && !Appsignal::System.jruby? Appsignal::Extension.install_allocation_event_hook Appsignal::Environment.report_enabled("allocation_tracking") @@ -241,6 +245,10 @@ def _load_config!(env_param = nil, &block) # @return [void] # @since 1.0.0 def stop(called_by = nil) + # Wrapped in `Thread.new ... .join` so this is safe to call from a + # `Signal.trap` block: `Mutex#synchronize` (used by + # `CheckIn::Scheduler`) is unsafe in trap handlers, and running on a + # separate thread sidesteps that restriction. See PR #1295. Thread.new do if called_by internal_logger.info("Stopping AppSignal (#{called_by})") @@ -250,6 +258,7 @@ def stop(called_by = nil) Appsignal::Extension.stop Appsignal::Probes.stop Appsignal::CheckIn.stop + Appsignal::OpenTelemetry.shutdown end.join nil end diff --git a/lib/appsignal/backends.rb b/lib/appsignal/backends.rb new file mode 100644 index 000000000..e0d50425c --- /dev/null +++ b/lib/appsignal/backends.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +require "appsignal/metrics/extension_backend" +require "appsignal/metrics/opentelemetry_backend" +require "appsignal/logger/extension_backend" +require "appsignal/logger/opentelemetry_backend" +require "appsignal/transaction/base_backend" +require "appsignal/transaction/extension_backend" +require "appsignal/transaction/opentelemetry_backend" + +module Appsignal + # @!visibility private + # + # Looks up the active backend for each AppSignal subsystem. In normal + # operation, subsystems route through the C-extension (and its agent). + # When collector mode is configured and the OpenTelemetry SDK has booted + # successfully, supported subsystems route through OTel instead. + # + # Centralizes the mode-check so per-subsystem call sites don't repeat the + # "if collector? then OTel else Extension" branch. Future subsystems plug + # in by adding one more lookup method here. + module Backends + class << self + def metrics + if collector? + Appsignal::Metrics::OpenTelemetryBackend + else + Appsignal::Metrics::ExtensionBackend + end + end + + def logger + if collector? + Appsignal::Logger::OpenTelemetryBackend + else + Appsignal::Logger::ExtensionBackend + end + end + + def transaction + if collector? + Appsignal::Transaction::OpenTelemetryBackend + else + Appsignal::Transaction::ExtensionBackend + end + end + + private + + def collector? + Appsignal.config&.collector_mode? || false + end + end + end +end diff --git a/lib/appsignal/config.rb b/lib/appsignal/config.rb index f688a3f13..5b5f78328 100644 --- a/lib/appsignal/config.rb +++ b/lib/appsignal/config.rb @@ -91,6 +91,7 @@ def dsl_config_file? DEFAULT_CONFIG = { :activejob_report_errors => "all", :ca_file_path => File.expand_path(File.join("../../../resources/cacert.pem"), __FILE__), + :collector_endpoint => nil, :dns_servers => [], :enable_allocation_tracking => true, :enable_at_exit_hook => "on_error", @@ -107,8 +108,12 @@ def dsl_config_file? :enable_rake_performance_instrumentation => false, :endpoint => "https://push.appsignal.com", :files_world_accessible => true, + :filter_attributes => [], + :filter_function_parameters => [], :filter_metadata => [], :filter_parameters => [], + :filter_request_payload => [], + :filter_request_query_parameters => [], :filter_session_data => [], :ignore_actions => [], :ignore_errors => [], @@ -139,9 +144,14 @@ def dsl_config_file? REQUEST_METHOD REQUEST_PATH SERVER_NAME SERVER_PORT SERVER_PROTOCOL ], + :response_headers => [], :send_environment_metadata => true, + :send_function_parameters => nil, :send_params => true, + :send_request_payload => nil, + :send_request_query_parameters => nil, :send_session_data => true, + :service_name => nil, :sidekiq_report_errors => "all", :default_tags => {} }.freeze @@ -167,6 +177,7 @@ def dsl_config_file? :name => "APPSIGNAL_APP_NAME", :bind_address => "APPSIGNAL_BIND_ADDRESS", :ca_file_path => "APPSIGNAL_CA_FILE_PATH", + :collector_endpoint => "APPSIGNAL_COLLECTOR_ENDPOINT", :enable_at_exit_hook => "APPSIGNAL_ENABLE_AT_EXIT_HOOK", :hostname => "APPSIGNAL_HOSTNAME", :host_role => "APPSIGNAL_HOST_ROLE", @@ -177,6 +188,7 @@ def dsl_config_file? :logging_endpoint => "APPSIGNAL_LOGGING_ENDPOINT", :endpoint => "APPSIGNAL_PUSH_API_ENDPOINT", :push_api_key => "APPSIGNAL_PUSH_API_KEY", + :service_name => "APPSIGNAL_SERVICE_NAME", :sidekiq_report_errors => "APPSIGNAL_SIDEKIQ_REPORT_ERRORS", :statsd_port => "APPSIGNAL_STATSD_PORT", :nginx_port => "APPSIGNAL_NGINX_PORT", @@ -221,21 +233,29 @@ def dsl_config_file? :ownership_set_namespace => "APPSIGNAL_OWNERSHIP_SET_NAMESPACE", :running_in_container => "APPSIGNAL_RUNNING_IN_CONTAINER", :send_environment_metadata => "APPSIGNAL_SEND_ENVIRONMENT_METADATA", + :send_function_parameters => "APPSIGNAL_SEND_FUNCTION_PARAMETERS", :send_params => "APPSIGNAL_SEND_PARAMS", + :send_request_payload => "APPSIGNAL_SEND_REQUEST_PAYLOAD", + :send_request_query_parameters => "APPSIGNAL_SEND_REQUEST_QUERY_PARAMETERS", :send_session_data => "APPSIGNAL_SEND_SESSION_DATA" }.freeze # @!visibility private ARRAY_OPTIONS = { :dns_servers => "APPSIGNAL_DNS_SERVERS", + :filter_attributes => "APPSIGNAL_FILTER_ATTRIBUTES", + :filter_function_parameters => "APPSIGNAL_FILTER_FUNCTION_PARAMETERS", :filter_metadata => "APPSIGNAL_FILTER_METADATA", :filter_parameters => "APPSIGNAL_FILTER_PARAMETERS", + :filter_request_payload => "APPSIGNAL_FILTER_REQUEST_PAYLOAD", + :filter_request_query_parameters => "APPSIGNAL_FILTER_REQUEST_QUERY_PARAMETERS", :filter_session_data => "APPSIGNAL_FILTER_SESSION_DATA", :ignore_actions => "APPSIGNAL_IGNORE_ACTIONS", :ignore_errors => "APPSIGNAL_IGNORE_ERRORS", :ignore_logs => "APPSIGNAL_IGNORE_LOGS", :ignore_namespaces => "APPSIGNAL_IGNORE_NAMESPACES", - :request_headers => "APPSIGNAL_REQUEST_HEADERS" + :request_headers => "APPSIGNAL_REQUEST_HEADERS", + :response_headers => "APPSIGNAL_RESPONSE_HEADERS" }.freeze # @!visibility private @@ -248,6 +268,39 @@ def dsl_config_file? :default_tags => "APPSIGNAL_DEFAULT_TAGS" }.freeze + # Collector mode requires Ruby 3.1+. The OpenTelemetry Ruby SDK relies on + # `Process._fork` (introduced in Ruby 3.1) for its fork hooks, without + # which background reader threads don't restart in child processes and + # buffered telemetry is lost after a fork. + # @!visibility private + MIN_RUBY_VERSION_FOR_COLLECTOR_MODE = "3.1" + + # Configuration options that only have an effect when the integration is + # in collector mode. When the agent is in use, setting any of these emits + # a warning at startup. + # @!visibility private + COLLECTOR_ONLY_OPTIONS = [ + :filter_attributes, + :filter_function_parameters, + :filter_request_payload, + :filter_request_query_parameters, + :response_headers, + :send_function_parameters, + :send_request_payload, + :send_request_query_parameters, + :service_name + ].freeze + + # Existing AppSignal options that only affect the agent's handling of + # trace data. In collector mode these don't filter anything; users need + # their collector-mode equivalents (see COLLECTOR_ONLY_OPTIONS). + # @!visibility private + AGENT_ONLY_TRACE_OPTIONS = [ + :filter_metadata, + :filter_parameters, + :send_params + ].freeze + # @!visibility private attr_reader :root_path, :env, :config_hash @@ -452,6 +505,57 @@ def active? valid? && active_for_env? end + # Check if collector mode is configured. + # + # Returns true when a non-empty `collector_endpoint` is set and the + # running Ruby version is at least {MIN_RUBY_VERSION_FOR_COLLECTOR_MODE}. + # On older Rubies, `collector_endpoint` is ignored (with a warning) and + # the AppSignal agent is used instead. + # + # This is the *intent* check — it answers "did the user ask for + # collector mode, and could we honor it?". It does not say whether the + # OpenTelemetry SDK actually booted. See {#collector_mode?} for that. + # + # Memoised: the result is cached on first call so hot paths avoid + # re-running the string-strip predicate, and so the unsupported-Ruby + # warning is emitted at most once per `Config` instance. + # + # @return [Boolean] True if collector mode is configured. + def collector_mode_configured? + return @collector_mode_configured if defined?(@collector_mode_configured) + + endpoint = config_hash[:collector_endpoint] + configured = !endpoint.nil? && !endpoint.to_s.strip.empty? + + if configured && Gem::Version.new(RUBY_VERSION) < + Gem::Version.new(MIN_RUBY_VERSION_FOR_COLLECTOR_MODE) + Appsignal::Utils::StdoutAndLoggerMessage.warning( + "Collector mode requires Ruby #{MIN_RUBY_VERSION_FOR_COLLECTOR_MODE} or higher " \ + "(running Ruby #{RUBY_VERSION}). The `collector_endpoint` option will be " \ + "ignored and the AppSignal agent will be used instead." + ) + @collector_mode_configured = false + else + @collector_mode_configured = configured + end + end + + # Check if AppSignal is actively running in collector mode. + # + # True only if collector mode is {#collector_mode_configured? configured} + # *and* `Appsignal::OpenTelemetry.configure` has successfully booted the + # SDK in this process. Use this for backend dispatch on hot paths + # (metric and log emits): if the OTel boot failed, callers fall back to + # the agent backend rather than silently dropping data into no-op + # providers. + # + # @return [Boolean] True if collector mode is configured and started. + def collector_mode? + collector_mode_configured? && + defined?(Appsignal::OpenTelemetry) && + Appsignal::OpenTelemetry.started? + end + # @!visibility private def write_to_environment ENV["_APPSIGNAL_ACTIVE"] = active?.to_s @@ -528,6 +632,30 @@ def validate else @valid = true end + + warn_for_mode_mismatch + end + + # Emit warnings when a configuration option is set that has no effect in + # the current mode (collector vs. agent). + # + # Uses {#collector_mode_configured?} (intent) rather than + # {#collector_mode?} so the warnings fire based on what the user asked + # for, independent of whether the OpenTelemetry SDK successfully booted. + # @!visibility private + def warn_for_mode_mismatch + if collector_mode_configured? + warn_user_modified(AGENT_ONLY_TRACE_OPTIONS) do |option| + "The collector is in use. The '#{option}' configuration option is " \ + "only used by the agent for trace data and will be ignored." + end + else + warn_user_modified(COLLECTOR_ONLY_OPTIONS) do |option| + "The agent is in use. The '#{option}' configuration option is " \ + "only used by the collector and will be ignored. Set " \ + "'collector_endpoint' to use the collector." + end + end end # Deep freeze the config object so it cannot be modified during the runtime @@ -551,6 +679,17 @@ def yml_config_file? private + # Yield a warning for each option in `options` whose effective value + # differs from the default. Setting an option to its default value is + # a no-op, so we don't warn about it. + def warn_user_modified(options) + options.each do |option| + next if config_hash[option] == DEFAULT_CONFIG[option] + + logger.warn(yield(option)) + end + end + def logger Appsignal.internal_logger end diff --git a/lib/appsignal/demo.rb b/lib/appsignal/demo.rb index 09d93474b..4921f68b1 100644 --- a/lib/appsignal/demo.rb +++ b/lib/appsignal/demo.rb @@ -70,7 +70,7 @@ def add_demo_metadata_to(transaction) end def add_params_to(transaction) - transaction.add_params( + transaction.add_request_payload( "controller" => "demo", "action" => "hello" ) diff --git a/lib/appsignal/event_formatter.rb b/lib/appsignal/event_formatter.rb index 2f9167965..024d0bb6c 100644 --- a/lib/appsignal/event_formatter.rb +++ b/lib/appsignal/event_formatter.rb @@ -102,8 +102,77 @@ def format(name, payload) formatter&.format(payload) end + # The OpenTelemetry span kind for an event, which its formatter can + # declare. An event with no formatter, or whose formatter declares + # nothing, has no kind of its own and falls back to the default. + # + # A formatter written against the documented interface only implements + # `format`, so ask whether this one answers to the method at all rather + # than assuming every formatter does. + # + # @!visibility private + def opentelemetry_kind(name) + formatter = formatter_for(name) + return unless formatter.respond_to?(:opentelemetry_kind) + + formatter.opentelemetry_kind + end + + # The OpenTelemetry attributes describing an event, which its formatter + # can build from the event's payload. An event with no formatter, or + # whose formatter describes nothing, gets no attributes of its own. + # + # @!visibility private + def opentelemetry_attributes(name, payload) + formatter = formatter_for(name) + return unless formatter.respond_to?(:opentelemetry_attributes) + + formatter.opentelemetry_attributes(payload) + end + + # The OpenTelemetry instrumentation scope for an event, which its + # formatter can declare. It names the library the instrumentation is for, + # which is not always the library the event arrived through. + # + # An event with no formatter, or whose formatter declares nothing, is + # left to the scope the recording path derives for it. + # + # @!visibility private + def opentelemetry_scope(name) + formatter = formatter_for(name) + return unless formatter.respond_to?(:opentelemetry_scope) + + formatter.opentelemetry_scope + end + + # Whether the generic instrumentation paths should record an event, which + # its formatter can answer. An event a dedicated integration already + # records says no, so that it is not recorded a second time. An event + # with no formatter, or whose formatter says nothing, is recorded. + # + # @!visibility private + def record?(name) + formatter = formatter_for(name) + return true unless formatter.respond_to?(:record?) + + formatter.record? + end + private + # The formatter registered for an event name. + # + # A formatter is registered under whatever key was given to `register`, + # which is a String for every formatter in this gem. An event can be + # instrumented under a Symbol name, so fall back to the String form of it. + # + # `format` does not do this, on purpose. It has always looked a name up + # exactly as given, so making it match a Symbol name would start giving a + # title to events that have never had one. + def formatter_for(name) + formatters[name] || formatters[name.to_s] + end + def initialize_formatter(name, formatter) format_method = formatter.instance_method(:format) if !format_method || format_method.arity != 1 @@ -123,6 +192,15 @@ def logger end end + # The title and the body to show for an event, as an array. A formatter + # that is registered to describe an event in some other way, rather than to + # name it, does not have to implement this. + # + # @!visibility private + def format(_payload) + nil + end + # @return [Integer] # @api public DEFAULT = 0 diff --git a/lib/appsignal/event_formatter/action_view/render_formatter.rb b/lib/appsignal/event_formatter/action_view/render_formatter.rb index 94dc29b9b..871512c27 100644 --- a/lib/appsignal/event_formatter/action_view/render_formatter.rb +++ b/lib/appsignal/event_formatter/action_view/render_formatter.rb @@ -7,13 +7,27 @@ module ActionView class RenderFormatter < Appsignal::EventFormatter BLANK = "" + def opentelemetry_attributes(_payload) + Appsignal::OpenTelemetry::Rendering.attributes + end + def format(payload) - return nil unless payload[:identifier] + # The title is the template's path made relative to the application's + # root, so a template rendered outside an application gets no title. + return unless payload[:identifier] && root_path [payload[:identifier].sub(root_path, BLANK), nil] end + # The application's root, which a template's path is made relative to. + # + # Whether there is an application is decided here, when the event is + # formatted, rather than when this file is loaded. AppSignal can be + # required before Rails is, and deciding it at load time would leave + # every template render in the application without a title. def root_path + return unless defined?(Rails) + @root_path ||= "#{Rails.root}/" end end @@ -21,24 +35,22 @@ def root_path end end -if defined?(Rails) - Appsignal::EventFormatter.register( - "render_partial.action_view", - Appsignal::EventFormatter::ActionView::RenderFormatter - ) - Appsignal::EventFormatter.register( - "render_template.action_view", - Appsignal::EventFormatter::ActionView::RenderFormatter - ) - # Action View reports the template's path for these two as well, so they are - # titled the same way. A collection reports the partial it rendered for each - # item, and a layout reports itself. - Appsignal::EventFormatter.register( - "render_collection.action_view", - Appsignal::EventFormatter::ActionView::RenderFormatter - ) - Appsignal::EventFormatter.register( - "render_layout.action_view", - Appsignal::EventFormatter::ActionView::RenderFormatter - ) -end +Appsignal::EventFormatter.register( + "render_partial.action_view", + Appsignal::EventFormatter::ActionView::RenderFormatter +) +Appsignal::EventFormatter.register( + "render_template.action_view", + Appsignal::EventFormatter::ActionView::RenderFormatter +) +# Action View reports the template's path for these two as well, so they are +# titled the same way. A collection reports the partial it rendered for each +# item, and a layout reports itself. +Appsignal::EventFormatter.register( + "render_collection.action_view", + Appsignal::EventFormatter::ActionView::RenderFormatter +) +Appsignal::EventFormatter.register( + "render_layout.action_view", + Appsignal::EventFormatter::ActionView::RenderFormatter +) diff --git a/lib/appsignal/event_formatter/active_job/perform_formatter.rb b/lib/appsignal/event_formatter/active_job/perform_formatter.rb new file mode 100644 index 000000000..125967b03 --- /dev/null +++ b/lib/appsignal/event_formatter/active_job/perform_formatter.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module Appsignal + class EventFormatter + # @!visibility private + module ActiveJob + # Active Job reports the job it is running as a `perform.active_job` + # notification. The job's own transaction already carries its title, so + # this formatter only describes the event as the work of performing a + # job. + class PerformFormatter < Appsignal::EventFormatter + PERFORM_ATTRIBUTES = + Appsignal::OpenTelemetry::Messaging.perform_attributes("active_job").freeze + + def opentelemetry_attributes(payload) + PERFORM_ATTRIBUTES.merge( + { "messaging.destination.name" => queue_name(payload) }.compact + ) + end + + # The queue the job being performed is on, which the notification + # carries as the job itself. + def queue_name(payload) + job = payload[:job] + job.queue_name if job.respond_to?(:queue_name) + end + end + end + end +end + +Appsignal::EventFormatter.register( + "perform.active_job", + Appsignal::EventFormatter::ActiveJob::PerformFormatter +) diff --git a/lib/appsignal/event_formatter/active_record/sql_formatter.rb b/lib/appsignal/event_formatter/active_record/sql_formatter.rb index 9601b314e..1204f28df 100644 --- a/lib/appsignal/event_formatter/active_record/sql_formatter.rb +++ b/lib/appsignal/event_formatter/active_record/sql_formatter.rb @@ -5,6 +5,25 @@ class EventFormatter # @!visibility private module ActiveRecord class SqlFormatter < Appsignal::EventFormatter + # A query is an outgoing call to a datastore. + def opentelemetry_kind + :client + end + + # The payload carries the connection the query ran on (Rails 6.0+), + # whose `adapter_name` is each adapter's own name for itself, such as + # `"PostgreSQL"` or `"Mysql2"`. A name the mapping does not recognise + # is left to the SQL sentinel, same as an adapter this gem has never + # heard of. + def opentelemetry_attributes(payload) + name = Appsignal::OpenTelemetry::SqlDbSystem.name_for_active_record( + payload[:connection]&.adapter_name + ) + return unless name + + { "db.system.name" => name } + end + def format(payload) [payload[:name], payload[:sql], SQL_BODY_FORMAT] end diff --git a/lib/appsignal/event_formatter/elastic_search/search_formatter.rb b/lib/appsignal/event_formatter/elastic_search/search_formatter.rb index 68c2b5efc..b89200ddd 100644 --- a/lib/appsignal/event_formatter/elastic_search/search_formatter.rb +++ b/lib/appsignal/event_formatter/elastic_search/search_formatter.rb @@ -5,6 +5,21 @@ class EventFormatter # @!visibility private module ElasticSearch class SearchFormatter < Appsignal::EventFormatter + # A search is an outgoing call to an Elasticsearch cluster. + def opentelemetry_kind + :client + end + + def opentelemetry_attributes(payload) + { + "db.system.name" => "elasticsearch", + # This notification is only emitted for a search, so that is the + # operation every one of these spans describes. + "db.operation.name" => "search", + "db.collection.name" => search_index(payload) + }.compact + end + def format(payload) [ "#{payload[:name]}: #{payload[:klass]}", @@ -12,6 +27,18 @@ def format(payload) ] end + # The index a search ran against, which the notification carries in the + # search it describes. A search that names more than one index, or none + # at all, is left without this attribute rather than described with a + # value that is not an index name. + def search_index(payload) + search = payload[:search] + return unless search.respond_to?(:[]) + + index = search[:index] + index if index.is_a?(String) + end + def sanitized_search(search) return unless search.is_a?(Hash) diff --git a/lib/appsignal/event_formatter/recorded_elsewhere.rb b/lib/appsignal/event_formatter/recorded_elsewhere.rb new file mode 100644 index 000000000..fcf191f85 --- /dev/null +++ b/lib/appsignal/event_formatter/recorded_elsewhere.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Appsignal + class EventFormatter + # Registered for an event that a dedicated AppSignal integration already + # records with richer semantics. The generic instrumentation paths ask the + # registry whether to record an event, so registering this for an event + # name is how that integration claims it. + # + # @!visibility private + class RecordedElsewhere < Appsignal::EventFormatter + def record? + false + end + end + end +end diff --git a/lib/appsignal/event_formatter/rom/sql_formatter.rb b/lib/appsignal/event_formatter/rom/sql_formatter.rb index 537aa2ea6..84a5dd69e 100644 --- a/lib/appsignal/event_formatter/rom/sql_formatter.rb +++ b/lib/appsignal/event_formatter/rom/sql_formatter.rb @@ -5,6 +5,18 @@ class EventFormatter # @!visibility private module Rom class SqlFormatter < Appsignal::EventFormatter + # These events arrive over dry-monitor, which is a notification bus + # rather than a library that queries a database. ROM is what emits + # them, so that is what the scope names. + def opentelemetry_scope + ["appsignal-ruby/rom", Appsignal::VERSION] + end + + # A query is an outgoing call to a datastore. + def opentelemetry_kind + :client + end + # dry-monitor reports an event under an id rather than a name, so the # first value here names the event. Naming it after ROM keeps every ROM # query in one group. @@ -17,6 +29,18 @@ class SqlFormatter < Appsignal::EventFormatter def format(payload) ["query.rom", payload[:query], SQL_BODY_FORMAT] end + + # The payload's `name` is Sequel's `database_type` symbol for the + # database ROM is talking to, so this uses Sequel's own lookup + # rather than a coincidentally similar one. A symbol the mapping does + # not recognise is left to the SQL sentinel, same as an engine this + # gem has never heard of. + def opentelemetry_attributes(payload) + name = Appsignal::OpenTelemetry::SqlDbSystem.name_for_sequel(payload[:name]) + return unless name + + { "db.system.name" => name } + end end end end diff --git a/lib/appsignal/event_formatter/sequel/sql_formatter.rb b/lib/appsignal/event_formatter/sequel/sql_formatter.rb index 8f06341d8..3f1c71b6f 100644 --- a/lib/appsignal/event_formatter/sequel/sql_formatter.rb +++ b/lib/appsignal/event_formatter/sequel/sql_formatter.rb @@ -10,6 +10,11 @@ module Sequel # formatter the sequel-rails events are recorded without the SQL query # that's being executed. class SqlFormatter < Appsignal::EventFormatter + # A query is an outgoing call to a datastore. + def opentelemetry_kind + :client + end + def format(payload) [payload[:name].to_s, payload[:sql], SQL_BODY_FORMAT] end diff --git a/lib/appsignal/event_formatter/view_component/render_formatter.rb b/lib/appsignal/event_formatter/view_component/render_formatter.rb index 0fbb02b35..89d53253d 100644 --- a/lib/appsignal/event_formatter/view_component/render_formatter.rb +++ b/lib/appsignal/event_formatter/view_component/render_formatter.rb @@ -7,11 +7,28 @@ module ViewComponent class RenderFormatter < Appsignal::EventFormatter BLANK = "" + def opentelemetry_attributes(_payload) + Appsignal::OpenTelemetry::Rendering.attributes + end + def format(payload) + # The body is the component's path made relative to the application's + # root, so a component rendered outside an application gets no title + # and no body. + return unless root_path + [payload[:name], payload[:identifier].sub(root_path, BLANK)] end + # The application's root, which a component's path is made relative to. + # + # Whether there is an application is decided here, when the event is + # formatted, rather than when this file is loaded. AppSignal can be + # required before Rails is, and deciding it at load time would leave + # every component render in the application without a title. def root_path + return unless defined?(Rails) + @root_path ||= "#{Rails.root}/" end end @@ -19,13 +36,7 @@ def root_path end end -if defined?(Rails) - Appsignal::EventFormatter.register( - "render.view_component", - Appsignal::EventFormatter::ViewComponent::RenderFormatter - ) - Appsignal::EventFormatter.register( - "!render.view_component", - Appsignal::EventFormatter::ViewComponent::RenderFormatter - ) -end +Appsignal::EventFormatter.register( + "render.view_component", + Appsignal::EventFormatter::ViewComponent::RenderFormatter +) diff --git a/lib/appsignal/extension.rb b/lib/appsignal/extension.rb index d8da1fa5b..498199c2d 100644 --- a/lib/appsignal/extension.rb +++ b/lib/appsignal/extension.rb @@ -46,6 +46,10 @@ def data_map_new def data_array_new Appsignal::Extension::MockData.new end + + def allocation_count + 0 + end end end diff --git a/lib/appsignal/helpers/instrumentation.rb b/lib/appsignal/helpers/instrumentation.rb index 7c1acbd35..46e9e84b9 100644 --- a/lib/appsignal/helpers/instrumentation.rb +++ b/lib/appsignal/helpers/instrumentation.rb @@ -102,6 +102,18 @@ module Instrumentation # within the block with {#set_action}. # This will not update the active transaction's action if # {.monitor} is called when another transaction is already active. + # @param opentelemetry_kind [Symbol] In collector mode, the OpenTelemetry + # span kind: one of `:server`, `:consumer`, `:producer` or `:internal`. + # Defaults to `:server`. + # @param opentelemetry_relationship [Symbol] In collector mode, how an + # incoming `opentelemetry_context` relates to this transaction's span: + # one of `:parent`, `:link`, `:both` or `:none`. Defaults to `:parent`. + # @param opentelemetry_context In collector mode, an incoming OpenTelemetry + # trace context to relate this transaction's span to. + # @param opentelemetry_scope [Array(String, String)] In collector mode, the + # OpenTelemetry instrumentation scope to record this transaction's spans + # under, given as a `[name, version]` pair. Defaults to the AppSignal + # scope. # @yield [] The block to monitor. # @yieldreturn [Object] The return value of the block # @raise [Exception] Any exception that occurs within the given block is @@ -112,7 +124,14 @@ module Instrumentation # # @see https://docs.appsignal.com/ruby/instrumentation/background-jobs.html # Monitor guide - def monitor(action:, namespace: nil) + def monitor( # rubocop:disable Metrics/ParameterLists + action:, + namespace: nil, + opentelemetry_context: nil, + opentelemetry_scope: nil, + opentelemetry_kind: nil, + opentelemetry_relationship: nil + ) return yield unless Appsignal.active? has_parent_transaction = Appsignal::Transaction.current? @@ -133,7 +152,13 @@ def monitor(action:, namespace: nil) if has_parent_transaction Appsignal::Transaction.current else - Appsignal::Transaction.create(namespace || Appsignal::Transaction::HTTP_REQUEST) + Appsignal::Transaction.create( + namespace || Appsignal::Transaction::HTTP_REQUEST, + :opentelemetry_context => opentelemetry_context, + :opentelemetry_scope => opentelemetry_scope, + :opentelemetry_kind => opentelemetry_kind, + :opentelemetry_relationship => opentelemetry_relationship + ) end begin @@ -168,6 +193,18 @@ def monitor(action:, namespace: nil) # within the block with {#set_action}. # This will not update the active transaction's action if # {.monitor} is called when another transaction is already active. + # @param opentelemetry_kind [Symbol] In collector mode, the OpenTelemetry + # span kind: one of `:server`, `:consumer`, `:producer` or `:internal`. + # Defaults to `:server`. + # @param opentelemetry_relationship [Symbol] In collector mode, how an + # incoming `opentelemetry_context` relates to this transaction's span: + # one of `:parent`, `:link`, `:both` or `:none`. Defaults to `:parent`. + # @param opentelemetry_context In collector mode, an incoming OpenTelemetry + # trace context to relate this transaction's span to. + # @param opentelemetry_scope [Array(String, String)] In collector mode, the + # OpenTelemetry instrumentation scope to record this transaction's spans + # under, given as a `[name, version]` pair. Defaults to the AppSignal + # scope. # @yield [] The block to monitor. # @yieldreturn [Object] The return value of the block # @raise [Exception] Any exception that occurs within the given block is @@ -175,13 +212,29 @@ def monitor(action:, namespace: nil) # @return [Object, nil] The value of the given block is returned. # # @see monitor - def monitor_and_stop(action:, namespace: nil, &block) + def monitor_and_stop( # rubocop:disable Metrics/ParameterLists + action:, + namespace: nil, + opentelemetry_context: nil, + opentelemetry_scope: nil, + opentelemetry_kind: nil, + opentelemetry_relationship: nil, + &block + ) Appsignal::Utils::StdoutAndLoggerMessage.warning \ "The `Appsignal.monitor_and_stop` helper is deprecated. " \ "Use the `Appsignal.monitor` along with our `enable_at_exit_hook` " \ "option instead." - monitor(:namespace => namespace, :action => action, &block) + monitor( + :namespace => namespace, + :action => action, + :opentelemetry_context => opentelemetry_context, + :opentelemetry_scope => opentelemetry_scope, + :opentelemetry_kind => opentelemetry_kind, + :opentelemetry_relationship => opentelemetry_relationship, + &block + ) ensure Appsignal.stop("monitor_and_stop") end @@ -218,6 +271,18 @@ def monitor_and_stop(action:, namespace: nil, &block) # # @since 0.6.0 # @param error [Exception] The error to send to AppSignal. + # @param opentelemetry_kind [Symbol] In collector mode, the OpenTelemetry + # span kind: one of `:server`, `:consumer`, `:producer` or `:internal`. + # Defaults to `:server`. + # @param opentelemetry_relationship [Symbol] In collector mode, how an + # incoming `opentelemetry_context` relates to this transaction's span: + # one of `:parent`, `:link`, `:both` or `:none`. Defaults to `:parent`. + # @param opentelemetry_context In collector mode, an incoming OpenTelemetry + # trace context to relate this transaction's span to. + # @param opentelemetry_scope [Array(String, String)] In collector mode, the + # OpenTelemetry instrumentation scope to record this transaction's spans + # under, given as a `[name, version]` pair. Defaults to the AppSignal + # scope. # @yield [transaction] yields block to allow modification of the # transaction before it's send. # @yieldparam transaction [Transaction] yields the AppSignal transaction @@ -226,7 +291,14 @@ def monitor_and_stop(action:, namespace: nil, &block) # # @see https://docs.appsignal.com/ruby/instrumentation/exception-handling.html # Exception handling guide - def send_error(error, &block) + def send_error( + error, + opentelemetry_context: nil, + opentelemetry_scope: nil, + opentelemetry_kind: nil, + opentelemetry_relationship: nil, + &block + ) return unless Appsignal.active? unless error.is_a?(Exception) @@ -237,8 +309,14 @@ def send_error(error, &block) end transaction = - Appsignal::Transaction.new(Appsignal::Transaction::HTTP_REQUEST) - transaction.set_error(error, &block) + Appsignal::Transaction.new( + Appsignal::Transaction::HTTP_REQUEST, + :opentelemetry_context => opentelemetry_context, + :opentelemetry_scope => opentelemetry_scope, + :opentelemetry_kind => opentelemetry_kind, + :opentelemetry_relationship => opentelemetry_relationship + ) + transaction.set_error(error, :source => "Appsignal.send_error", &block) transaction.complete end @@ -348,6 +426,20 @@ def set_error(exception) # @since 4.0.0 # @param exception [Exception] The error to add to the current # transaction. + # @param opentelemetry_kind [Symbol] In collector mode, the OpenTelemetry + # span kind: one of `:server`, `:consumer`, `:producer` or `:internal`. + # Defaults to `:server`. Only used when a new transaction is created. + # @param opentelemetry_relationship [Symbol] In collector mode, how an + # incoming `opentelemetry_context` relates to this transaction's span: + # one of `:parent`, `:link`, `:both` or `:none`. Defaults to `:parent`. + # Only used when a new transaction is created. + # @param opentelemetry_context In collector mode, an incoming OpenTelemetry + # trace context to relate this transaction's span to. Only used when a + # new transaction is created. + # @param opentelemetry_scope [Array(String, String)] In collector mode, the + # OpenTelemetry instrumentation scope to record this transaction's spans + # under, given as a `[name, version]` pair. Defaults to the AppSignal + # scope. # @yield [transaction] yields block to allow modification of the # transaction. # @yieldparam transaction [Transaction] yields the AppSignal transaction @@ -356,7 +448,14 @@ def set_error(exception) # # @see https://docs.appsignal.com/ruby/instrumentation/exception-handling.html # Exception handling guide - def report_error(exception, &block) + def report_error( + exception, + opentelemetry_context: nil, + opentelemetry_scope: nil, + opentelemetry_kind: nil, + opentelemetry_relationship: nil, + &block + ) unless exception.is_a?(Exception) Appsignal.internal_logger.error "Appsignal.report_error: " \ "Cannot add error. " \ @@ -370,10 +469,16 @@ def report_error(exception, &block) if has_parent_transaction Appsignal::Transaction.current else - Appsignal::Transaction.new(Appsignal::Transaction::HTTP_REQUEST) + Appsignal::Transaction.new( + Appsignal::Transaction::HTTP_REQUEST, + :opentelemetry_context => opentelemetry_context, + :opentelemetry_scope => opentelemetry_scope, + :opentelemetry_kind => opentelemetry_kind, + :opentelemetry_relationship => opentelemetry_relationship + ) end - transaction.add_error(exception, &block) + transaction.add_error(exception, :source => "Appsignal.report_error", &block) transaction.complete unless has_parent_transaction end @@ -602,16 +707,95 @@ def add_params(params = nil, &block) end alias set_params add_params + # Add the request payload to the current transaction. + # + # The request payload is the parameters of an incoming request, such as + # the query string and the request body. In collector mode it maps to its + # own attribute, separate from the function parameters. + # + # Behaves like {#add_params}: merges when called multiple times, and a + # block takes precedence over the argument. + # + # @param params [Hash, Array] The request payload to add to the + # transaction. + # @yield This block is called when the transaction is sampled. The block's + # return value will become the new request payload. + # @yieldreturn [Hash, Array] + # @return [void] + # + # @see #add_function_parameters + def add_request_payload(params = nil, &block) + return unless Appsignal.active? + return unless Appsignal::Transaction.current? + + transaction = Appsignal::Transaction.current + transaction.add_request_payload(params, &block) + end + + # Add the function parameters to the current transaction. + # + # The function parameters are the arguments a background job or function + # was called with. In collector mode they map to their own attribute, + # separate from the request payload. + # + # Behaves like {#add_params}: merges when called multiple times, and a + # block takes precedence over the argument. + # + # @param params [Hash, Array] The function parameters to add to + # the transaction. + # @yield This block is called when the transaction is sampled. The block's + # return value will become the new function parameters. + # @yieldreturn [Hash, Array] + # @return [void] + # + # @see #add_request_payload + def add_function_parameters(params = nil, &block) + return unless Appsignal.active? + return unless Appsignal::Transaction.current? + + transaction = Appsignal::Transaction.current + transaction.add_function_parameters(params, &block) + end + + # Add the query parameters to the current transaction. + # + # The query parameters are the parameters parsed from an incoming + # request's query string. In collector mode they map to their own + # attribute, separate from the request payload and the function + # parameters. + # + # Behaves like {#add_params}: merges when called multiple times, and a + # block takes precedence over the argument. + # + # @param params [Hash, Array] The query parameters to add to the + # transaction. + # @yield This block is called when the transaction is sampled. The block's + # return value will become the new query parameters. + # @yieldreturn [Hash, Array] + # @return [void] + # + # @see #add_request_payload + def add_query_parameters(params = nil, &block) + return unless Appsignal.active? + return unless Appsignal::Transaction.current? + + transaction = Appsignal::Transaction.current + transaction.add_query_parameters(params, &block) + end + # Mark the parameters sample data to be set as an empty value. # - # Use this helper to unset request parameters / background job arguments - # and not report any for this transaction. + # Use this helper to report no parameters for this transaction, whatever + # their source. # - # If parameters would normally be added by AppSignal instrumentations of - # libraries, these parameters will not be added to the Transaction. + # This suppresses every params channel. In collector mode, where the + # request payload and the function parameters (a background job's + # arguments) are tracked as separate attributes, it suppresses both, not + # only the request payload. Parameters that an AppSignal integration would + # otherwise add are not added. # - # Calling {#add_params} after this helper will add new parameters to the - # transaction. + # Calling {#add_params}, {#add_request_payload} or + # {#add_function_parameters} after this helper adds parameters again. # # @since 4.2.0 # @return [void] @@ -715,7 +899,7 @@ def add_headers(headers = nil, &block) # Breadcrumbs can be used to trace what path a user has taken # before encountering an error. # - # Only the last 20 added breadcrumbs will be saved. + # At most 20 of the added breadcrumbs will be saved. # # @example # Appsignal.add_breadcrumb( @@ -786,6 +970,12 @@ def add_breadcrumb(category, action, message = "", metadata = {}, time = Time.no # instrumented. Accepted values are {EventFormatter::DEFAULT} and # {EventFormatter::SQL_BODY_FORMAT}, but we recommend you use # {.instrument_sql} instead of {EventFormatter::SQL_BODY_FORMAT}. + # @param opentelemetry_kind [Symbol] In collector mode, the OpenTelemetry + # span kind for the event's span, such as `:client` for an outgoing HTTP + # request. Defaults to the OpenTelemetry default of `:internal`. + # @param opentelemetry_scope [Array(String, String)] In collector mode, the + # OpenTelemetry instrumentation scope to record the event's span under, + # given as a `[name, version]` pair. Defaults to the AppSignal scope. # @yield [] yields the given block of code instrumented in an AppSignal # event. # @return [Object] Returns the block's return value. @@ -795,15 +985,25 @@ def add_breadcrumb(category, action, message = "", metadata = {}, time = Time.no # AppSignal custom instrumentation guide # @see https://docs.appsignal.com/api/event-names.html # AppSignal event naming guide - def instrument( + def instrument( # rubocop:disable Metrics/ParameterLists name, title = nil, body = nil, body_format = Appsignal::EventFormatter::DEFAULT, + opentelemetry_kind: nil, + opentelemetry_scope: nil, &block ) Appsignal::Transaction.current - .instrument(name, title, body, body_format, &block) + .instrument( + name, + title, + body, + body_format, + :opentelemetry_kind => opentelemetry_kind, + :opentelemetry_scope => opentelemetry_scope, + &block + ) end # Instrumentation helper for SQL queries. @@ -827,6 +1027,13 @@ def instrument( # naming guide listed under "See also". # @param title [String, nil] Human readable name of the event. # @param body [String, nil] SQL query that's being executed. + # @param opentelemetry_kind [Symbol] In collector mode, the OpenTelemetry + # span kind for the event's span. Defaults to `:client`, because a query + # is an outgoing call to a datastore. Pass `:internal` for a query that + # is not an outgoing call. + # @param opentelemetry_scope [Array(String, String)] In collector mode, the + # OpenTelemetry instrumentation scope to record the event's span under, + # given as a `[name, version]` pair. Defaults to the AppSignal scope. # @yield [] yields the given block of code instrumented in an AppSignal # event. # @return [Object] Returns the block's return value. @@ -836,12 +1043,21 @@ def instrument( # AppSignal custom instrumentation guide # @see https://docs.appsignal.com/api/event-names.html # AppSignal event naming guide - def instrument_sql(name, title = nil, body = nil, &block) + def instrument_sql( + name, + title = nil, + body = nil, + opentelemetry_kind: :client, + opentelemetry_scope: nil, + &block + ) instrument( name, title, body, Appsignal::EventFormatter::SQL_BODY_FORMAT, + :opentelemetry_kind => opentelemetry_kind, + :opentelemetry_scope => opentelemetry_scope, &block ) end diff --git a/lib/appsignal/helpers/metrics.rb b/lib/appsignal/helpers/metrics.rb index 5932b8a1f..ded4d0ec7 100644 --- a/lib/appsignal/helpers/metrics.rb +++ b/lib/appsignal/helpers/metrics.rb @@ -16,14 +16,7 @@ module Metrics # @see https://docs.appsignal.com/metrics/custom.html # Metrics documentation def set_gauge(name, value, tags = {}) - Appsignal::Extension.set_gauge( - name.to_s, - value.to_f, - Appsignal::Utils::Data.generate(tags) - ) - rescue RangeError - Appsignal.internal_logger - .warn("The gauge value '#{value}' for metric '#{name}' is too big") + Appsignal::Backends.metrics.set_gauge(name, value, tags) end # Report a counter metric. @@ -39,14 +32,7 @@ def set_gauge(name, value, tags = {}) # @see https://docs.appsignal.com/metrics/custom.html # Metrics documentation def increment_counter(name, value = 1.0, tags = {}) - Appsignal::Extension.increment_counter( - name.to_s, - value.to_f, - Appsignal::Utils::Data.generate(tags) - ) - rescue RangeError - Appsignal.internal_logger - .warn("The counter value '#{value}' for metric '#{name}' is too big") + Appsignal::Backends.metrics.increment_counter(name, value, tags) end # Report a distribution metric. @@ -62,14 +48,7 @@ def increment_counter(name, value = 1.0, tags = {}) # @see https://docs.appsignal.com/metrics/custom.html # Metrics documentation def add_distribution_value(name, value, tags = {}) - Appsignal::Extension.add_distribution_value( - name.to_s, - value.to_f, - Appsignal::Utils::Data.generate(tags) - ) - rescue RangeError - Appsignal.internal_logger - .warn("The distribution value '#{value}' for metric '#{name}' is too big") + Appsignal::Backends.metrics.add_distribution_value(name, value, tags) end end end diff --git a/lib/appsignal/hooks/action_cable.rb b/lib/appsignal/hooks/action_cable.rb index eba19f49f..b6aae106c 100644 --- a/lib/appsignal/hooks/action_cable.rb +++ b/lib/appsignal/hooks/action_cable.rb @@ -37,10 +37,16 @@ def install_subscribe_callback env[Appsignal::Hooks::ActionCableHook::REQUEST_ID] ||= request_id transaction = - Appsignal::Transaction.create(Appsignal::Transaction::ACTION_CABLE) + Appsignal::Transaction.create( + Appsignal::Transaction::ACTION_CABLE, + :opentelemetry_scope => ["appsignal-ruby/action_cable", Appsignal::VERSION] + ) begin - Appsignal.instrument "subscribed.action_cable" do + Appsignal.instrument( + "subscribed.action_cable", + :opentelemetry_scope => ["appsignal-ruby/action_cable", Appsignal::VERSION] + ) do inner.call end rescue Exception => exception @@ -50,7 +56,7 @@ def install_subscribe_callback transaction.set_action_if_nil("#{channel.class}#subscribed") transaction.set_metadata("path", request.path) transaction.set_metadata("method", "websocket") - transaction.add_params_if_nil { request.params } + transaction.add_request_payload_if_nil { request.params } transaction.add_headers_if_nil { request.env } transaction.add_session_data { request.session.to_h if request.respond_to? :session } transaction.add_tags(:request_id => request_id) if request_id @@ -73,10 +79,16 @@ def install_unsubscribe_callback env[Appsignal::Hooks::ActionCableHook::REQUEST_ID] ||= request_id transaction = - Appsignal::Transaction.create(Appsignal::Transaction::ACTION_CABLE) + Appsignal::Transaction.create( + Appsignal::Transaction::ACTION_CABLE, + :opentelemetry_scope => ["appsignal-ruby/action_cable", Appsignal::VERSION] + ) begin - Appsignal.instrument "unsubscribed.action_cable" do + Appsignal.instrument( + "unsubscribed.action_cable", + :opentelemetry_scope => ["appsignal-ruby/action_cable", Appsignal::VERSION] + ) do inner.call end rescue Exception => exception @@ -86,7 +98,7 @@ def install_unsubscribe_callback transaction.set_action_if_nil("#{channel.class}#unsubscribed") transaction.set_metadata("path", request.path) transaction.set_metadata("method", "websocket") - transaction.add_params_if_nil { request.params } + transaction.add_request_payload_if_nil { request.params } transaction.add_headers_if_nil { request.env } transaction.add_session_data { request.session.to_h if request.respond_to? :session } transaction.add_tags(:request_id => request_id) if request_id diff --git a/lib/appsignal/hooks/active_job.rb b/lib/appsignal/hooks/active_job.rb index b49c8e18d..eda298b4c 100644 --- a/lib/appsignal/hooks/active_job.rb +++ b/lib/appsignal/hooks/active_job.rb @@ -6,6 +6,22 @@ class Hooks class ActiveJobHook < Appsignal::Hooks::Hook register :active_job + # This integration records the enqueue itself, as a producer event that + # also injects trace context, and Active Job's own `enqueue.active_job` + # notification fires nested inside it. Claim the event so that the + # generic notification paths leave it alone. + # + # Claimed here, when this file is required, rather than in `install`. + # `install` only runs when Active Job instrumentation is turned on, but + # a customer who turns it off does not want to see the native + # notification reported instead. This call does not touch any + # `ActiveJob` constant, so it is safe to run even when the library is + # not present. + Appsignal::EventFormatter.register( + "enqueue.active_job", + Appsignal::EventFormatter::RecordedElsewhere + ) + def self.version_7_1_or_higher? @version_7_1_or_higher ||= if dependencies_present? @@ -30,8 +46,11 @@ def install ActiveSupport.on_load(:active_job) do ::ActiveJob::Base .extend ::Appsignal::Hooks::ActiveJobHook::ActiveJobClassInstrumentation + # Carry W3C trace context across the enqueue/perform boundary in + # collector mode (no-ops otherwise). The patches are cheap and + # mode-gated inside their method bodies, so install them unconditionally. ::ActiveJob::Base - .prepend ::Appsignal::Hooks::ActiveJobHook::ActiveJobEnqueueInstrumentation + .prepend ::Appsignal::Hooks::ActiveJobHook::ActiveJobTraceContext next unless Appsignal::Hooks::ActiveJobHook.version_7_1_or_higher? @@ -44,42 +63,6 @@ def install end end - # Records an `enqueue.active_job` event when a job is enqueued, so the - # enqueue shows up on the active transaction's timeline (e.g. when - # enqueuing from within a web request or another job). - # - # Wrapping `enqueue` ourselves -- rather than relying on Rails' native - # `enqueue.active_job` notification, which the AppSignal notifications - # path now suppresses -- gives us a single event we own. Like all - # AppSignal events, this only records when there's an active transaction; - # an enqueue with no transaction is a transparent pass-through. - # - # @!visibility private - module ActiveJobEnqueueInstrumentation - def enqueue(*, **) - # Skip recording the event when enqueue events are suppressed. That is - # the case when enqueue instrumentation is disabled, and it keeps this - # integration consistent with the standalone adapters (Sidekiq, ...), - # which already gate their own enqueue event on this check. - if Appsignal::Transaction.current? && - Appsignal::Transaction.current.job_enqueue_events_suppressed? - return super - end - - Appsignal.instrument("enqueue.active_job", "enqueue #{self.class.name} job") do - # Active Job enqueues through an adapter (Sidekiq, Resque, ...) that - # has its own enqueue instrumentation. Suppress it so the enqueue is - # recorded once, as this event, rather than as nested Active Job + - # adapter events. - if Appsignal::Transaction.current? - Appsignal::Transaction.current.suppress_job_enqueue_events { super } - else - super - end - end - end - end - module ActiveJobClassInstrumentation def execute(job) enqueued_at = job["enqueued_at"] @@ -101,12 +84,39 @@ def execute(job) # We don't have a separate integration for this QueueAdapter like # we do for Sidekiq. # + # Read the trace context off the job so the transaction links back + # to the enqueuer (no-op outside collector mode). Only here, in the + # standalone branch: when a wrapper integration (e.g. Sidekiq) + # created the transaction, it already extracted, so we must not + # extract a second time. + # # Prefer job_id from provider, instead of ActiveJob's internal ID. - Appsignal::Transaction.create(Appsignal::Transaction::BACKGROUND_JOB) + Appsignal::Transaction.create( + Appsignal::Transaction::BACKGROUND_JOB, + :opentelemetry_context => Appsignal::OpenTelemetry.extract_job_context(job), + :opentelemetry_scope => ["appsignal-ruby/active_job", Appsignal::VERSION], + :opentelemetry_kind => :consumer, + :opentelemetry_relationship => :both + ) end + unless has_wrapper_transaction + # Describes this span as a job being performed. The messaging + # system is what the trace timeline reads to recognize background + # job work, and `active_job` is the value OpenTelemetry's own Active + # Job instrumentation uses. + # + # Only set when this hook created the transaction. When an adapter + # integration created it, that adapter already named itself, and its + # answer is the more specific one. + transaction.add_opentelemetry_attributes( + Appsignal::OpenTelemetry::Messaging + .perform_attributes("active_job", :destination => job["queue_name"]) + ) + end + begin - transaction.add_params_if_nil(job["arguments"]) + transaction.add_function_parameters_if_nil(job["arguments"]) transaction_tags = ActiveJobHelpers.transaction_tags_for(job) transaction.add_tags(transaction_tags) @@ -162,6 +172,85 @@ def transaction_set_error(transaction, exception) end end + # Reads and writes W3C trace context on the ActiveJob enqueue/perform + # boundary, wire-compatible with OpenTelemetry's ActiveJob instrumentation. + # All of this no-ops outside collector mode. + # + # Context rides on the job under `__otel_headers`, the same carrier OTel + # uses. Stock `serialize`/`deserialize` only carry a fixed key set, so -- + # like OTel -- we patch both plus an accessor to round-trip it. The on-wire + # value is run through ActiveJob's argument serializer (an array of + # `[key, value]` pairs), matching OTel byte-for-byte so an AppSignal- and an + # OTel-instrumented service read each other's jobs. + module ActiveJobTraceContext + # Inject on enqueue from inside a producer event, so the job carries this + # transaction's context and the perform later links back. Mirrors the + # Sidekiq client middleware: an AppSignal event (a producer span in + # collector mode), not a direct SDK span. `Appsignal.instrument` is a + # transparent pass-through when there's no active transaction, and + # `inject_context` no-ops outside collector mode. + def enqueue(*, **) + # When enqueue instrumentation is disabled, drop the trace context + # along with the event. Without an enqueue event there is no producer + # span, so the context we would write is that of whatever span is + # current, such as the surrounding web request. The job that performs + # later would then link back to a span that is not a producer. + return super if Appsignal.config && !Appsignal.config[:enable_job_enqueue_instrumentation] + + # Another enqueue integration is already recording this enqueue, so + # don't record it a second time. + if Appsignal::Transaction.current? && + Appsignal::Transaction.current.job_enqueue_events_suppressed? + return super + end + + Appsignal.instrument( + "enqueue.active_job", + "enqueue #{self.class.name} job", + :opentelemetry_kind => :producer, + :opentelemetry_scope => ["appsignal-ruby/active_job", Appsignal::VERSION] + ) do + Appsignal::Transaction.current.add_opentelemetry_attributes( + Appsignal::OpenTelemetry::Messaging + .enqueue_attributes("active_job", :destination => queue_name) + ) + Appsignal::OpenTelemetry.inject_context(__otel_headers) + # Active Job enqueues through an adapter (Sidekiq, Resque, ...) that + # has its own enqueue instrumentation. Suppress it so the enqueue is + # recorded once, as this event, rather than as nested Active Job + + # adapter events. + if Appsignal::Transaction.current? + Appsignal::Transaction.current.suppress_job_enqueue_events { super } + else + super + end + end + end + + def serialize + super.tap do |data| + Appsignal::OpenTelemetry.if_started do + next if __otel_headers.empty? + + data["__otel_headers"] = ::ActiveJob::Arguments.serialize(__otel_headers) + end + end + end + + def deserialize(job_data) + super + serialized = job_data["__otel_headers"] + @__otel_headers = + serialized ? ::ActiveJob::Arguments.deserialize(serialized).to_h : {} + end + + def __otel_headers + @__otel_headers ||= {} + end + + attr_writer :__otel_headers + end + module ActiveJobHelpers ACTION_MAILER_CLASSES = [ "ActionMailer::DeliveryJob", diff --git a/lib/appsignal/hooks/at_exit.rb b/lib/appsignal/hooks/at_exit.rb index df7610c08..810abfcbb 100644 --- a/lib/appsignal/hooks/at_exit.rb +++ b/lib/appsignal/hooks/at_exit.rb @@ -38,7 +38,10 @@ def self.call report_error = true - Appsignal.report_error(error) do |transaction| + Appsignal.report_error( + error, + :opentelemetry_scope => ["appsignal-ruby/at_exit", Appsignal::VERSION] + ) do |transaction| transaction.set_namespace("unhandled") end ensure diff --git a/lib/appsignal/hooks/excon.rb b/lib/appsignal/hooks/excon.rb index f3af85f05..57a0d1b88 100644 --- a/lib/appsignal/hooks/excon.rb +++ b/lib/appsignal/hooks/excon.rb @@ -12,15 +12,35 @@ def dependencies_present? def install require "appsignal/integrations/excon" + require "appsignal/integrations/excon/appsignal_middleware" # Instrument the request at the connection, rather than by registering # AppSignal as Excon's instrumentor. An instrumentor is told about a # request in pieces, none of which covers the wait for the response, and # there is only room for one of them, so registering ours would replace # any the application set up itself. ::Excon::Connection.prepend Appsignal::Integrations::ExconIntegration + install_middleware Appsignal::Environment.report_enabled("excon") end + + private + + # Trace context is written onto the outgoing request by a middleware, + # because that is where Excon exposes the request's headers. + # + # Insert it just before the Mock middleware, the innermost one, where the + # response is produced. That way it runs before the request is sent. + # Appending to the end would place it after Mock, which short-circuits the + # chain before reaching it. + def install_middleware + middlewares = ::Excon.defaults[:middlewares].dup + return if middlewares.include?(Appsignal::Integrations::ExconMiddleware) + + index = middlewares.index(::Excon::Middleware::Mock) || middlewares.length + middlewares.insert(index, Appsignal::Integrations::ExconMiddleware) + ::Excon.defaults[:middlewares] = middlewares + end end end end diff --git a/lib/appsignal/hooks/faraday.rb b/lib/appsignal/hooks/faraday.rb index fa5b4ead3..def83f5ac 100644 --- a/lib/appsignal/hooks/faraday.rb +++ b/lib/appsignal/hooks/faraday.rb @@ -6,6 +6,22 @@ class Hooks class FaradayHook < Appsignal::Hooks::Hook register :faraday + # This integration records the request itself, so Faraday's own + # instrumentation middleware would report the same work again as a + # `request.faraday` notification. Claim it so the generic notification + # paths leave it alone. + # + # Claimed here, when this file is required, rather than in `install`. + # `install` only runs when Faraday instrumentation is turned on, but a + # customer who turns it off does not want to see the native + # notification reported instead. This call does not touch any + # `Faraday` constant, so it is safe to run even when the library is not + # present. + Appsignal::EventFormatter.register( + "request.faraday", + Appsignal::EventFormatter::RecordedElsewhere + ) + def dependencies_present? defined?(::Faraday) && Appsignal.config && Appsignal.config[:instrument_faraday] end diff --git a/lib/appsignal/hooks/http.rb b/lib/appsignal/hooks/http.rb index f6c00421c..f3f8901ef 100644 --- a/lib/appsignal/hooks/http.rb +++ b/lib/appsignal/hooks/http.rb @@ -32,6 +32,11 @@ def install if defined?(HTTP::Session) HTTP::Session.prepend Appsignal::Integrations::HttpIntegration::KeywordOptions end + # Propagate trace context onto every outgoing hop (redirects included) at + # `Client#perform`, where the live request headers are reachable. Kept + # separate from the request-boundary event above: it only injects context + # and no-ops outside collector mode. + HTTP::Client.prepend Appsignal::Integrations::HttpIntegration::ContextInjection Appsignal::Environment.report_enabled("http_rb") end diff --git a/lib/appsignal/hooks/resque.rb b/lib/appsignal/hooks/resque.rb index 75cc6ad59..7bf8de0b6 100644 --- a/lib/appsignal/hooks/resque.rb +++ b/lib/appsignal/hooks/resque.rb @@ -15,7 +15,7 @@ def install Resque::Job.prepend Appsignal::Integrations::ResqueIntegration # Resque enqueues through the `Resque.push` singleton method, so prepend - # onto its singleton class to record the enqueue event. + # onto its singleton class to write the trace context onto outgoing jobs. Resque.singleton_class.prepend Appsignal::Integrations::ResquePushIntegration end end diff --git a/lib/appsignal/hooks/sequel.rb b/lib/appsignal/hooks/sequel.rb index 2f690fecc..9ef904716 100644 --- a/lib/appsignal/hooks/sequel.rb +++ b/lib/appsignal/hooks/sequel.rb @@ -10,8 +10,13 @@ def log_yield(sql, args = nil) "sql.sequel", nil, sql, - Appsignal::EventFormatter::SQL_BODY_FORMAT + Appsignal::EventFormatter::SQL_BODY_FORMAT, + :opentelemetry_kind => :client, + :opentelemetry_scope => ["appsignal-ruby/sequel", Appsignal::VERSION] ) do + Appsignal::Transaction.current.add_opentelemetry_attributes( + Appsignal::Hooks::SequelHook.sequel_db_attributes(self) + ) super end end @@ -25,8 +30,13 @@ def log_connection_yield(sql, conn, args = nil) "sql.sequel", nil, sql, - Appsignal::EventFormatter::SQL_BODY_FORMAT + Appsignal::EventFormatter::SQL_BODY_FORMAT, + :opentelemetry_kind => :client, + :opentelemetry_scope => ["appsignal-ruby/sequel", Appsignal::VERSION] ) do + Appsignal::Transaction.current.add_opentelemetry_attributes( + Appsignal::Hooks::SequelHook.sequel_db_attributes(self) + ) super end end @@ -35,6 +45,26 @@ def log_connection_yield(sql, conn, args = nil) class SequelHook < Appsignal::Hooks::Hook register :sequel + # The query's `Sequel::Database` names both the engine it talks to and + # the database it is connected to, neither of which the sql.sequel + # formatter can see -- it only gets the query text. Shared by both + # extensions above, whichever one a given Sequel version registers. + # + # @!visibility private + def self.sequel_db_attributes(database) + attributes = {} + + name = Appsignal::OpenTelemetry::SqlDbSystem.name_for_sequel(database.database_type) + attributes["db.system.name"] = name if name + + # `opts[:database]` is Sequel's own option key for the database to + # connect to, so it doubles as the database's name. + namespace = database.opts[:database].to_s + attributes["db.namespace"] = namespace unless namespace.empty? + + attributes + end + def dependencies_present? defined?(::Sequel::Database) && Appsignal.config && diff --git a/lib/appsignal/hooks/shoryuken.rb b/lib/appsignal/hooks/shoryuken.rb index 82d9dce88..d138117f6 100644 --- a/lib/appsignal/hooks/shoryuken.rb +++ b/lib/appsignal/hooks/shoryuken.rb @@ -19,9 +19,9 @@ def install end # Servers enqueue jobs too, so they need the client middleware that - # records the enqueue event. Shoryuken only yields `configure_client` - # outside the server, so register it here as well for enqueues from - # within a worker. + # writes the trace context onto outgoing messages. Shoryuken only + # yields `configure_client` outside the server, so register it here as + # well for enqueues from within a worker. config.client_middleware do |chain| chain.add Appsignal::Integrations::ShoryukenClientMiddleware end diff --git a/lib/appsignal/hooks/sidekiq.rb b/lib/appsignal/hooks/sidekiq.rb index 62178323e..2e8a949d0 100644 --- a/lib/appsignal/hooks/sidekiq.rb +++ b/lib/appsignal/hooks/sidekiq.rb @@ -45,7 +45,7 @@ def install end # Servers enqueue jobs too, so they need the client middleware that - # records the enqueue event. + # writes the trace context onto outgoing jobs. config.client_middleware do |chain| chain.add Appsignal::Integrations::SidekiqClientMiddleware end diff --git a/lib/appsignal/integrations/action_cable.rb b/lib/appsignal/integrations/action_cable.rb index 98c52c40c..6e53096ee 100644 --- a/lib/appsignal/integrations/action_cable.rb +++ b/lib/appsignal/integrations/action_cable.rb @@ -11,7 +11,10 @@ def perform_action(*args, &block) request_id = request.request_id || SecureRandom.uuid env[Appsignal::Hooks::ActionCableHook::REQUEST_ID] ||= request_id - transaction = Appsignal::Transaction.create(Appsignal::Transaction::ACTION_CABLE) + transaction = Appsignal::Transaction.create( + Appsignal::Transaction::ACTION_CABLE, + :opentelemetry_scope => ["appsignal-ruby/action_cable", Appsignal::VERSION] + ) begin super @@ -20,7 +23,7 @@ def perform_action(*args, &block) raise exception ensure transaction.set_action_if_nil("#{self.class}##{args.first["action"]}") - transaction.add_params_if_nil(args.first) + transaction.add_request_payload_if_nil(args.first) transaction.add_session_data { request.session.to_h if request.respond_to? :session } transaction.set_metadata("path", request.path) transaction.set_metadata("method", "websocket") diff --git a/lib/appsignal/integrations/active_support_notifications.rb b/lib/appsignal/integrations/active_support_notifications.rb index 074219964..c03cc1e62 100644 --- a/lib/appsignal/integrations/active_support_notifications.rb +++ b/lib/appsignal/integrations/active_support_notifications.rb @@ -7,24 +7,52 @@ module ActiveSupportNotificationsIntegration class << self BANG = "!" - # Events a dedicated AppSignal integration already records, so the - # generic notifications path must not record them a second time. The - # ActiveJob hook owns `enqueue.active_job` (it wraps the enqueue in its - # own event, with Rails' native notification nested inside), and the - # Faraday integration owns `request.faraday`. - SUPPRESSED_EVENT_NAMES = ["enqueue.active_job", "request.faraday"].freeze - def start_event(name) return unless record_event?(name) - Appsignal::Transaction.current.start_event + # The event's formatter says what kind of work the event is, such as + # a SQL query being an outgoing call to a database, and can name the + # library the instrumentation is for. Both are immutable once the + # span exists, so they have to be set here at event start. + # + # A formatter that names no library leaves the scope to be derived + # from the event name, which is right for everything Rails reports. + Appsignal::Transaction.current.start_event( + :opentelemetry_kind => Appsignal::EventFormatter.opentelemetry_kind(name), + :opentelemetry_scope => + Appsignal::EventFormatter.opentelemetry_scope(name) || scope_for(name) + ) + end + + # ActiveSupport::Notifications bridges many Rails components through this + # one path (`sql.active_record`, `render_template.action_view`, ...), so + # derive the instrumentation scope from the event name's group: the part + # after the last dot. That gives each Rails component its own scope + # (`appsignal-ruby/active_record`, `appsignal-ruby/action_view`, ...) + # rather than lumping them under one. A name without a group falls back + # to the default scope in the backend. + def scope_for(name) + # Only names with a group (a dot) map to a component scope. A name + # without one has no component to attribute it to, so it falls back to + # the default scope in the backend (returning nil here). + parts = name.to_s.split(".") + return if parts.length < 2 || parts.last.empty? + + ["appsignal-ruby/#{parts.last}", Appsignal::VERSION] end def finish_event(name, payload = {}) return unless record_event?(name) title, body, body_format = Appsignal::EventFormatter.format(name, payload) - Appsignal::Transaction.current.finish_event( + transaction = Appsignal::Transaction.current + # Set while the event's span is still open, so the attributes land on + # the event rather than on the transaction. + transaction.add_opentelemetry_attributes( + Appsignal::EventFormatter.opentelemetry_attributes(name, payload) + ) + record_error_type(transaction, payload) + transaction.finish_event( name.to_s, title, body, @@ -32,12 +60,29 @@ def finish_event(name, payload = {}) ) end - # Events starting with a bang are internal to Rails; suppressed events - # are recorded by a dedicated integration instead. Both `start_event` - # and `finish_event` gate on this so the event stack stays balanced. + # Says what kind of failure ended the event, which the OpenTelemetry + # semantic conventions ask for on a span whose operation failed. + # + # ActiveSupport puts the exception in the payload when the instrumented + # block raised, and it does so before it hands control to any of the + # paths this integration hooks into. So the failure is readable here and + # there is nothing to rescue, whether the event was reported through a + # block or through a `start` and `finish` pair. + def record_error_type(transaction, payload) + error = payload[:exception_object] + return unless error + + transaction.add_opentelemetry_attributes( + Appsignal::OpenTelemetry::ErrorType.attributes_for(error.class.name) + ) + end + + # Events starting with a bang are internal to Rails. An event that the + # registry says a dedicated integration records is not recorded again + # here. Both `start_event` and `finish_event` gate on this so the event + # stack stays balanced. def record_event?(name) - name = name.to_s - name[0] != BANG && !SUPPRESSED_EVENT_NAMES.include?(name) + name.to_s[0] != BANG && Appsignal::EventFormatter.record?(name) end end diff --git a/lib/appsignal/integrations/data_mapper.rb b/lib/appsignal/integrations/data_mapper.rb index 358ea4ada..f408d01f6 100644 --- a/lib/appsignal/integrations/data_mapper.rb +++ b/lib/appsignal/integrations/data_mapper.rb @@ -12,22 +12,34 @@ module DataMapperLogListener ].freeze def log(message) + attributes = {} + # If scheme is SQL-like, try to sanitize it, otherwise clear the body if SQL_CLASSES.include?(self.class.to_s) body_content = message.query body_format = Appsignal::EventFormatter::SQL_BODY_FORMAT + # The connection class names the engine it talks to, one of the four + # SQL_CLASSES above; a class this map does not recognise leaves the + # SQL sentinel to apply, same as it would for a fifth SQL_CLASSES + # entry this map has not been taught about. + db_system = Appsignal::OpenTelemetry::SqlDbSystem.name_for_data_mapper(self.class.to_s) + attributes["db.system.name"] = db_system if db_system else body_content = "" body_format = Appsignal::EventFormatter::DEFAULT end - # Record event + # Record event. The query is an outgoing call to the database, so tag it + # as a client span (collector mode); no-op in agent mode. Appsignal::Transaction.current.record_event( "query.data_mapper", "DataMapper Query", body_content, message.duration, - body_format + body_format, + :opentelemetry_kind => :client, + :opentelemetry_scope => ["appsignal-ruby/data_mapper", Appsignal::VERSION], + :opentelemetry_attributes => attributes ) super end diff --git a/lib/appsignal/integrations/delayed_job_plugin.rb b/lib/appsignal/integrations/delayed_job_plugin.rb index ed804fd63..139ff1c4f 100644 --- a/lib/appsignal/integrations/delayed_job_plugin.rb +++ b/lib/appsignal/integrations/delayed_job_plugin.rb @@ -25,6 +25,11 @@ class DelayedJobPlugin < ::Delayed::Plugin # another job). An enqueue with no active transaction is a transparent # pass-through. def self.enqueue_with_instrumentation(job, block) + # Skip the enqueue event when enqueue instrumentation is disabled. + if Appsignal.config && !Appsignal.config[:enable_job_enqueue_instrumentation] + return block.call(job) + end + # Under Active Job the enqueue is already recorded as an # `enqueue.active_job` event, so skip recording it again here. if Appsignal::Transaction.current? && @@ -32,7 +37,20 @@ def self.enqueue_with_instrumentation(job, block) return block.call(job) end - Appsignal.instrument("enqueue.delayed_job", "enqueue #{enqueue_name(job)} job") do + Appsignal.instrument( + "enqueue.delayed_job", + "enqueue #{enqueue_name(job)} job", + :opentelemetry_kind => :producer, + :opentelemetry_scope => ["appsignal-ruby/delayed_job", Appsignal::VERSION] + ) do + # Describes this span as a job being enqueued. The messaging system + # is what the trace timeline reads to recognize background job work, + # and `delayed_job` is the value OpenTelemetry's own Delayed Job + # instrumentation uses. + Appsignal::Transaction.current.add_opentelemetry_attributes( + Appsignal::OpenTelemetry::Messaging + .enqueue_attributes("delayed_job", :destination => queue_name(job)) + ) block.call(job) end end @@ -43,6 +61,12 @@ def self.enqueue_with_instrumentation(job, block) # `enqueue Class#method job` rather than the bare `enqueue Class job`. We # accept that inconsistency so the enqueue and perform events stay tied to # the same name for the rare job that sets it. + # The queue a job is on. Not every Delayed Job backend has queues, so a + # job that does not know its queue is described without one. + def self.queue_name(job) + job.queue if job.respond_to?(:queue) + end + def self.enqueue_name(job) payload = job.payload_object appsignal_name = extract_value(payload, :appsignal_name, nil) @@ -53,10 +77,26 @@ def self.enqueue_name(job) def self.invoke_with_instrumentation(job, block) transaction = - Appsignal::Transaction.create(Appsignal::Transaction::BACKGROUND_JOB) + Appsignal::Transaction.create( + Appsignal::Transaction::BACKGROUND_JOB, + :opentelemetry_scope => ["appsignal-ruby/delayed_job", Appsignal::VERSION], + :opentelemetry_kind => :consumer, + :opentelemetry_relationship => :both + ) + transaction.add_opentelemetry_attributes( + Appsignal::OpenTelemetry::Messaging + .perform_attributes("delayed_job", :destination => queue_name(job)) + ) begin - Appsignal.instrument("perform_job.delayed_job") do + Appsignal.instrument( + "perform_job.delayed_job", + :opentelemetry_scope => ["appsignal-ruby/delayed_job", Appsignal::VERSION] + ) do + Appsignal::Transaction.current.add_opentelemetry_attributes( + Appsignal::OpenTelemetry::Messaging + .perform_attributes("delayed_job", :destination => queue_name(job)) + ) block.call(job) end rescue Exception => error @@ -68,11 +108,11 @@ def self.invoke_with_instrumentation(job, block) # ActiveJob job_data = payload.job_data transaction.set_action_if_nil("#{job_data["job_class"]}#perform") - transaction.add_params_if_nil(job_data.fetch("arguments", {})) + transaction.add_function_parameters_if_nil(job_data.fetch("arguments", {})) else # Delayed Job transaction.set_action_if_nil(action_name_from_payload(payload, job.name)) - transaction.add_params_if_nil(extract_value(payload, :args, {})) + transaction.add_function_parameters_if_nil(extract_value(payload, :args, {})) end transaction.add_tags( diff --git a/lib/appsignal/integrations/dry_monitor.rb b/lib/appsignal/integrations/dry_monitor.rb index 47e769853..5a344c4aa 100644 --- a/lib/appsignal/integrations/dry_monitor.rb +++ b/lib/appsignal/integrations/dry_monitor.rb @@ -4,24 +4,48 @@ module Appsignal module Integrations # @!visibility private module DryMonitorIntegration + # The event's formatter says what kind of work the event is, such as ROM + # reporting a SQL query as a dry-monitor `"sql"` event, and which library + # the instrumentation is for. Both are immutable once the span exists, so + # they have to be set here at event start. + # + # dry-monitor is a notification bus, so an event arriving over it is not + # necessarily dry-monitor's own work. A formatter that knows better says + # so; anything else is attributed to dry-monitor. def instrument(event_id, payload = {}, &block) - Appsignal::Transaction.current.start_event - - super - ensure name = "#{event_id}.dry" - event_name, body, body_format = Appsignal::EventFormatter.format(name, payload) + # An event a dedicated integration already records is not recorded a + # second time here. + return super unless Appsignal::EventFormatter.record?(name) + + begin + Appsignal::Transaction.current.start_event( + :opentelemetry_kind => Appsignal::EventFormatter.opentelemetry_kind(name), + :opentelemetry_scope => + Appsignal::EventFormatter.opentelemetry_scope(name) || + ["appsignal-ruby/dry_monitor", Appsignal::VERSION] + ) + + super + ensure + event_name, body, body_format = Appsignal::EventFormatter.format(name, payload) + # Set while the event's span is still open, so the attributes land + # on the event rather than on the transaction. + Appsignal::Transaction.current.add_opentelemetry_attributes( + Appsignal::EventFormatter.opentelemetry_attributes(name, payload) + ) - # dry-monitor reports an event under an id, such as `sql`, rather than - # a name. A formatter names the event it knows about, and an event - # without one is named after its id in the dry-monitor group. Either - # way the name has a group, which is what an event is listed under. - Appsignal::Transaction.current.finish_event( - event_name || name, - nil, - body, - body_format - ) + # dry-monitor reports an event under an id, such as `sql`, rather than + # a name. A formatter names the event it knows about, and an event + # without one is named after its id in the dry-monitor group. Either + # way the name has a group, which is what an event is listed under. + Appsignal::Transaction.current.finish_event( + event_name || name, + nil, + body, + body_format + ) + end end end end diff --git a/lib/appsignal/integrations/excon.rb b/lib/appsignal/integrations/excon.rb index 05f4e3bbe..d932ee765 100644 --- a/lib/appsignal/integrations/excon.rb +++ b/lib/appsignal/integrations/excon.rb @@ -4,16 +4,20 @@ module Appsignal module Integrations # @!visibility private module ExconIntegration + # The method of a request. Excon defaults it to GET further down, so the + # same default is applied here. + def self.method_for(datum) + datum[:method] || :get + end + # The title of the event, built the way the Net::HTTP integration builds # its own: the request method and where the request went, without the # path, so paths stay out of event titles. # # Excon splits a request's data between the connection it is made on and - # the call that makes it, so both are read to build this. Excon defaults - # the method to GET itself, so the same default is applied here. + # the call that makes it, so both are read to build this. def self.title_for(datum) - method = (datum[:method] || :get).to_s.upcase - "#{method} #{datum[:scheme]}://#{datum[:host]}" + "#{method_for(datum).to_s.upcase} #{datum[:scheme]}://#{datum[:host]}" end def request(params = {}) @@ -38,18 +42,51 @@ def request(params = {}) # A pipelined request is the exception to this method being the whole of # a request. It returns before the response is read, so its event covers # only the sending. - title = ExconIntegration.title_for(data.merge(params)) + datum = data.merge(params) + + Appsignal.instrument( + "request.excon", + ExconIntegration.title_for(datum), + :opentelemetry_kind => :client, + :opentelemetry_scope => ["appsignal-ruby/excon", Appsignal::VERSION] + ) do + # Describes the span as an outgoing HTTP request. Together with the + # CLIENT kind, this is what the trace timeline reads to recognize it + # as one. + Appsignal::Transaction.current.add_opentelemetry_attributes( + Appsignal::OpenTelemetry::HttpClientRequest.attributes_for( + :method => ExconIntegration.method_for(datum), + :scheme => datum[:scheme], + :host => datum[:host], + :port => datum[:port], + :path => datum[:path] + ) + ) + + response = + if Appsignal::Transaction.current? + # Excon retries a request, and follows a redirect, by calling this + # method again from inside the request it is retrying or + # following. Suppressing those means they count towards this event + # rather than becoming events of their own, so one request stays + # one event. + Appsignal::Transaction.current.suppress_http_client_events { super } + else + super + end - Appsignal.instrument("request.excon", title) do - if Appsignal::Transaction.current? - # Excon retries a request, and follows a redirect, by calling this - # method again from inside the request it is retrying or following. - # Suppressing those means they count towards this event rather than - # becoming events of their own, so one request stays one event. - Appsignal::Transaction.current.suppress_http_client_events { super } - else - super - end + # Describes the response on the same span as the request, which the + # semantic conventions ask for whenever one was received. + # + # A pipelined request returns the request data rather than a response, + # because its response has not been read yet, so there is no status to + # report for it. + Appsignal::Transaction.current.add_opentelemetry_attributes( + Appsignal::OpenTelemetry::HttpResponse.attributes_for( + response.respond_to?(:status) ? response.status : nil + ) + ) + response end end end diff --git a/lib/appsignal/integrations/excon/appsignal_middleware.rb b/lib/appsignal/integrations/excon/appsignal_middleware.rb new file mode 100644 index 000000000..0c9237099 --- /dev/null +++ b/lib/appsignal/integrations/excon/appsignal_middleware.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Appsignal + module Integrations + # Excon middleware that writes trace context onto the outgoing request, so + # the called service joins this trace. The integration on the connection + # records the span; this middleware only injects. + # + # @!visibility private + class ExconMiddleware < ::Excon::Middleware::Base + def request_call(datum) + datum[:headers] ||= {} + # Inject from whatever span is current. The connection's client span is + # open around the whole request, so the written `traceparent` reflects + # the Excon client event. No-op outside collector mode. + Appsignal::OpenTelemetry.inject_context(datum[:headers]) + super + end + end + end +end diff --git a/lib/appsignal/integrations/faraday.rb b/lib/appsignal/integrations/faraday.rb index af5d253df..70731490a 100644 --- a/lib/appsignal/integrations/faraday.rb +++ b/lib/appsignal/integrations/faraday.rb @@ -2,10 +2,11 @@ module Appsignal module Integrations - # Faraday middleware that records each request as a `request.faraday` event - # and suppresses the downstream HTTP client's own instrumentation, so the - # request is recorded once rather than as nested Faraday + Net::HTTP (or - # Excon) client events. + # Faraday middleware that records each request as a `request.faraday` client + # event, writes trace context onto the outgoing request so the called service + # joins this trace, and suppresses the downstream HTTP client's own + # instrumentation, so the request is recorded once rather than as nested + # Faraday + Net::HTTP (or Excon) client events. # # @!visibility private class FaradayMiddleware < ::Faraday::Middleware @@ -16,17 +17,50 @@ def call(env) # Net::HTTP's (scheme and host only), keeping paths out of event titles. Appsignal.instrument( "request.faraday", - "#{http_method} #{uri.scheme}://#{uri.host}" + "#{http_method} #{uri.scheme}://#{uri.host}", + :opentelemetry_kind => :client, + :opentelemetry_scope => ["appsignal-ruby/faraday", Appsignal::VERSION] ) do + # Describes the span as an outgoing HTTP request. Together with the + # CLIENT kind, this is what the trace timeline reads to recognize it + # as one. + Appsignal::Transaction.current.add_opentelemetry_attributes( + Appsignal::OpenTelemetry::HttpClientRequest.attributes_for( + :method => env[:method], + :scheme => uri.scheme, + :host => uri.host, + :port => uri.port, + :path => uri.path + ) + ) + # Write trace context onto the outgoing request so the called service + # joins this trace. Injected inside the instrument block, so the written + # `traceparent` reflects the Faraday client event's span. No-op outside + # collector mode. `env.request_headers` is the live outgoing header set + # and a valid carrier (it responds to `[]=`). + Appsignal::OpenTelemetry.inject_context(env.request_headers) + # Faraday's default adapter is Net::HTTP, which AppSignal also # instruments. Suppress the adapter's own instrumentation so the # request appears once (as the Faraday event) rather than as nested # Faraday + Net::HTTP client events. - if Appsignal::Transaction.current? - Appsignal::Transaction.current.suppress_http_client_events { @app.call(env) } - else - @app.call(env) - end + response = + if Appsignal::Transaction.current? + Appsignal::Transaction.current.suppress_http_client_events { @app.call(env) } + else + @app.call(env) + end + + # Describes the response on the same span, which the semantic + # conventions ask for whenever one was received. The event is still + # open here, so it lands on the request's own span. The status is read + # off the environment rather than the returned response, because an + # adapter fills the response in later than it fills in the environment. + Appsignal::Transaction.current.add_opentelemetry_attributes( + Appsignal::OpenTelemetry::HttpResponse.attributes_for(env[:status]) + ) + + response end end end @@ -37,8 +71,9 @@ def call(env) # the build path is the only way to instrument every connection automatically. # # Just before the adapter (the innermost handler, where the request is sent) - # it inserts `FaradayMiddleware`, which records the `request.faraday` event - # and suppresses the downstream client. Skipped if it's already present. + # it inserts `FaradayMiddleware`, which records the `request.faraday` event, + # injects trace context, and suppresses the downstream client. Skipped if it's + # already present. # # @!visibility private module FaradayRackBuilderPatch diff --git a/lib/appsignal/integrations/http.rb b/lib/appsignal/integrations/http.rb index fa65cfbec..17e333b50 100644 --- a/lib/appsignal/integrations/http.rb +++ b/lib/appsignal/integrations/http.rb @@ -9,7 +9,34 @@ def self.instrument(verb, uri, &block) parsed_request_uri = uri.is_a?(URI) ? uri : uri_module.parse(uri.to_s) request_uri = "#{parsed_request_uri.scheme}://#{parsed_request_uri.host}" - Appsignal.instrument("request.http_rb", "#{verb.upcase} #{request_uri}", &block) + Appsignal.instrument( + "request.http_rb", + "#{verb.to_s.upcase} #{request_uri}", + :opentelemetry_kind => :client, + :opentelemetry_scope => ["appsignal-ruby/http_rb", Appsignal::VERSION] + ) do + # Describes the span as an outgoing HTTP request. Together with the + # CLIENT kind, this is what the trace timeline reads to recognize it + # as one. + Appsignal::Transaction.current.add_opentelemetry_attributes( + Appsignal::OpenTelemetry::HttpClientRequest.attributes_for( + :method => verb, + :scheme => parsed_request_uri.scheme, + :host => parsed_request_uri.host, + :port => parsed_request_uri.port, + :path => parsed_request_uri.path + ) + ) + # Describes the response on the same span, which the semantic + # conventions ask for whenever one was received. The event is still + # open here, so it lands on the request's own span. A request that + # followed redirects reports the status of the response it ended on. + block.call.tap do |response| + Appsignal::Transaction.current.add_opentelemetry_attributes( + Appsignal::OpenTelemetry::HttpResponse.attributes_for(response&.code) + ) + end + end end # The event is recorded at the request boundary, so a redirected request @@ -32,6 +59,21 @@ def request(verb, uri, **opts) HttpIntegration.instrument(verb, uri) { super } end end + + # Trace context has to ride on each outgoing hop's headers, so it's + # injected at `HTTP::Client#perform` -- the single send chokepoint in both + # http5 and http6, called once per request and once per redirect hop -- + # where the live request headers are reachable. The event stays at the + # request boundary above, so a redirected request is still a single event; + # this only propagates context, and every hop carries it. No-op outside + # collector mode. `req.headers` is the live outgoing header set and a valid + # carrier (it responds to `[]=`). + module ContextInjection + def perform(req, options) + Appsignal::OpenTelemetry.inject_context(req.headers) + super + end + end end end end diff --git a/lib/appsignal/integrations/mongo_ruby_driver.rb b/lib/appsignal/integrations/mongo_ruby_driver.rb index e5950223c..8c7d78c75 100644 --- a/lib/appsignal/integrations/mongo_ruby_driver.rb +++ b/lib/appsignal/integrations/mongo_ruby_driver.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require "json" + module Appsignal class Hooks # @!visibility private @@ -19,8 +21,25 @@ def started(event) store = transaction.store("mongo_driver") store[event.request_id] = command - # Start this event - transaction.start_event + # Start this event. The query is an outgoing client call. + transaction.start_event( + :opentelemetry_kind => :client, + :opentelemetry_scope => ["appsignal-ruby/mongo", Appsignal::VERSION] + ) + # Names the datastore this span talks to, which is what the trace + # timeline reads to recognize a database call, along with the command + # that ran, what it ran against, and the server it went to. Set here, + # where the event span it describes is the open one. + transaction.add_opentelemetry_attributes( + { + "db.system.name" => "mongodb", + "db.operation.name" => event.command_name, + "db.collection.name" => collection_name(event), + "db.namespace" => event.database_name, + "server.address" => event.address&.host, + "server.port" => event.address&.port + }.compact + ) end # Called by Mongo::Monitor when query succeeds @@ -46,9 +65,29 @@ def finish(result, event) store = transaction.store("mongo_driver") command = store.delete(event.request_id) || {} + # Say what kind of failure ended the query, which the OpenTelemetry + # semantic conventions ask for on a span whose operation failed. The + # driver reports a failure by calling us rather than by raising, so this + # is the only place it can be read. Set before the event is finished, so + # it lands on the query's own span. + # + # Only a failure event carries a failure, which is what tells the two + # apart here. + if event.respond_to?(:failure) + transaction.add_opentelemetry_attributes( + Appsignal::OpenTelemetry::ErrorType.attributes_for(error_name(event.failure)) + ) + # MongoDB's own code for the error, which the conventions ask for + # whenever the database reported one. + transaction.add_opentelemetry_attributes( + { "db.response.status_code" => error_code(event.failure) }.compact + ) + end + # Finish the event. The sanitized command is a (nested) Hash; emit it - # as a JSON string. The agent serializes structured bodies to JSON - # anyway, so this is equivalent output. + # as a JSON string so it works with both transaction backends. The + # agent serializes structured bodies to JSON anyway, so this is + # equivalent output there, and the collector receives a plain string. transaction.finish_event( "query.mongodb", "#{event.command_name} | #{event.database_name} | #{result}", @@ -63,6 +102,36 @@ def finish(result, event) :database => event.database_name ) end + + private + + # The collection a command worked on. MongoDB puts it in the field named + # after the command itself, as in `{ "find" => "users" }`. A command that + # works on the database as a whole rather than on one collection has + # something else in that field, such as the number 1, so only a String + # counts as a collection name. + def collection_name(event) + return unless event.command.respond_to?(:[]) + + collection = event.command[event.command_name] + collection if collection.is_a?(String) + end + + # MongoDB's own code for an error, which it puts in the `code` field of the + # error document it replies with. Reported as a String, which is what the + # conventions ask for. + def error_code(failure) + return unless failure.respond_to?(:[]) + + failure["code"]&.to_s + end + + # MongoDB names an error in the `codeName` field of the error document it + # replies with. A failure the driver never got a document for, such as a + # connection that dropped, has no name. + def error_name(failure) + failure["codeName"] if failure.respond_to?(:[]) + end end end end diff --git a/lib/appsignal/integrations/net_http.rb b/lib/appsignal/integrations/net_http.rb index 731085b91..9fec35355 100644 --- a/lib/appsignal/integrations/net_http.rb +++ b/lib/appsignal/integrations/net_http.rb @@ -14,9 +14,38 @@ def request(request, body = nil, &block) Appsignal.instrument( "request.net_http", - "#{request.method} #{use_ssl? ? "https" : "http"}://#{request["host"] || address}" + "#{request.method} #{use_ssl? ? "https" : "http"}://#{request["host"] || address}", + :opentelemetry_kind => :client, + :opentelemetry_scope => ["appsignal-ruby/net_http", Appsignal::VERSION] ) do - super + # Describes the span as an outgoing HTTP request. Together with the + # CLIENT kind, this is what the trace timeline reads to recognize it + # as one. + # + # The client's own `address` and `port` name the host being called. + # The request's `path` is a request target, so it can carry a query + # string, which the attribute builder cuts off. + Appsignal::Transaction.current.add_opentelemetry_attributes( + Appsignal::OpenTelemetry::HttpClientRequest.attributes_for( + :method => request.method, + :scheme => use_ssl? ? "https" : "http", + :host => address, + :port => port, + :path => request.path + ) + ) + # Write trace context onto the outgoing request so the called service + # joins this trace. No-op outside collector mode. The request object + # is a valid carrier (it responds to `[]=`). + Appsignal::OpenTelemetry.inject_context(request) + # Describes the response on the same span, which the semantic + # conventions ask for whenever one was received. The event is still + # open here, so it lands on the request's own span. + super.tap do |response| + Appsignal::Transaction.current.add_opentelemetry_attributes( + Appsignal::OpenTelemetry::HttpResponse.attributes_for(response&.code) + ) + end end end end diff --git a/lib/appsignal/integrations/puma.rb b/lib/appsignal/integrations/puma.rb index 2123256f8..9a0ae22ad 100644 --- a/lib/appsignal/integrations/puma.rb +++ b/lib/appsignal/integrations/puma.rb @@ -13,7 +13,10 @@ def lowlevel_error(error, env, response_status = 500) end unless PumaServerHelper.ignored_error?(error) - Appsignal.report_error(error) do |transaction| + Appsignal.report_error( + error, + :opentelemetry_scope => ["appsignal-ruby/puma", Appsignal::VERSION] + ) do |transaction| Appsignal::Rack::ApplyRackRequest .new(::Rack::Request.new(env)) .apply_to(transaction) diff --git a/lib/appsignal/integrations/que.rb b/lib/appsignal/integrations/que.rb index ed67cd68e..983d6a10d 100644 --- a/lib/appsignal/integrations/que.rb +++ b/lib/appsignal/integrations/que.rb @@ -2,21 +2,152 @@ module Appsignal module Integrations + # @!visibility private + # + # Reads and writes W3C trace context the way OpenTelemetry's Que + # instrumentation does: as `"key:value"` strings in the job's tags array + # (the only carrier Que's enqueue API exposes). Collector mode only. + module QueTraceContext + module_function + + # Que has no header map, so context rides in the tags array. OTel writes + # each header as a `"key:value"` tag; mirror that exact format. + module TagSetter + def self.set(carrier, key, value) + carrier << "#{key}:#{value}" + end + end + + # Que rejects jobs with too many or too-long tags, so injected context + # must stay within these or the enqueue would raise. Read the limits from + # Que when available, with the documented defaults as a fallback. + MAX_TAGS_COUNT = + defined?(::Que::Job::MAXIMUM_TAGS_COUNT) ? ::Que::Job::MAXIMUM_TAGS_COUNT : 5 + MAX_TAG_LENGTH = + defined?(::Que::Job::MAXIMUM_TAG_LENGTH) ? ::Que::Job::MAXIMUM_TAG_LENGTH : 100 + + # Marks a job as one of a batch enqueued by `bulk_enqueue`, so the worker + # can tell the two enqueue paths apart. Que itself records nothing that + # distinguishes them: both insert paths write the same columns, and the + # job's `data` only ever holds its tags. So the enqueue side has to say so. + # + # This deliberately contains no colon. The trace context rides in the same + # tags array as `"key:value"` strings, so a reader that splits tags on the + # colon to rebuild the carrier drops this tag on its own. That keeps it out + # of the carrier for us and for OpenTelemetry's own Que instrumentation. + BULK_TAG = "appsignal.bulk_enqueue" + + # Que only has tags from version 1.0 on, and they are the only carrier its + # enqueue API exposes. On Que 0.x there is nowhere to put the trace context + # that survives to the worker, so propagation is skipped there. Writing the + # context into the job's arguments instead would change the arguments the + # job is called with, which breaks the job. + TAGS_SUPPORTED = defined?(::Que::Job::MAXIMUM_TAGS_COUNT) + + # Read the incoming context off the job's tags. Splits each `"key:value"` + # tag on the first colon back into a carrier hash, then extracts. Returns + # an `OpenTelemetry::Context`, or `nil` outside collector mode and on Que + # versions without tags. + def extract(tags) + return unless TAGS_SUPPORTED + + Appsignal::OpenTelemetry.if_started do + carrier = Array(tags) + .map { |tag| tag.split(":", 2) } + .select { |pair| pair.size == 2 } + .to_h + ::OpenTelemetry.propagation.extract(carrier) + end + end + + # Returns the tags array to enqueue the job with. In collector mode that is + # a copy with the current context injected, kept only if it still fits + # Que's limits: propagation is skipped rather than break the enqueue. + # Outside collector mode, and on Que versions without tags, the tags are + # returned unchanged. + # + # Pass `bulk` for a `bulk_enqueue` batch, which adds the bulk marker too. + # Both are kept or dropped together, because a batch missing its marker + # would parent every job in it to the one producer span. + def inject(tags, bulk: false) + original = Array(tags) + return original unless TAGS_SUPPORTED + + injected = Appsignal::OpenTelemetry.if_started do + copy = original.dup + ::OpenTelemetry.propagation.inject(copy, :setter => TagSetter) + # The marker has nothing to link back to without a trace context, so + # only add it when the context was actually injected. The propagator + # writes nothing when there is no valid span to propagate. + copy << BULK_TAG if bulk && copy.length > original.length + copy + end + return original if injected.nil? || !within_limits?(injected) + + injected + end + + # Whether the job being performed was enqueued as part of a batch, which + # the enqueue side records by adding `BULK_TAG` to the job's tags. + def bulk?(tags) + Array(tags).include?(BULK_TAG) + end + + def within_limits?(tags) + tags.length <= MAX_TAGS_COUNT && tags.all? { |tag| tag.length <= MAX_TAG_LENGTH } + end + end + # @!visibility private module QuePlugin def _run(*args) + local_attrs = respond_to?(:que_attrs) ? que_attrs : attrs + tags = local_attrs.dig(:data, :tags) + + # A job enqueued on its own is the only job its producer span produced, so + # it can be a child of that span as well as link to it. Every job in a + # batch shares one producer span, and a span can only have one parent, so + # parenting a batch would hang the whole batch off that single span. Only + # link those, which is what the OpenTelemetry messaging conventions ask + # for: they use links as the default, and allow the producer to be the + # parent only when it produced a single message. + relationship = QueTraceContext.bulk?(tags) ? :link : :both + + # Read the incoming trace context off the job's tags so the transaction + # links back to the enqueuer. No-op outside collector mode. transaction = - Appsignal::Transaction.create(Appsignal::Transaction::BACKGROUND_JOB) + Appsignal::Transaction.create( + Appsignal::Transaction::BACKGROUND_JOB, + :opentelemetry_context => QueTraceContext.extract(tags), + :opentelemetry_scope => ["appsignal-ruby/que", Appsignal::VERSION], + :opentelemetry_kind => :consumer, + :opentelemetry_relationship => relationship + ) + # Describes this span as a job being performed. The messaging system is + # what the trace timeline reads to recognize background job work, and + # `que` is the value OpenTelemetry's own Que instrumentation uses. + transaction.add_opentelemetry_attributes( + Appsignal::OpenTelemetry::Messaging + .perform_attributes("que", :destination => local_attrs[:queue]) + ) begin - Appsignal.instrument("perform_job.que") { super } + Appsignal.instrument( + "perform_job.que", + :opentelemetry_scope => ["appsignal-ruby/que", Appsignal::VERSION] + ) do + Appsignal::Transaction.current.add_opentelemetry_attributes( + Appsignal::OpenTelemetry::Messaging + .perform_attributes("que", :destination => local_attrs[:queue]) + ) + super + end rescue Exception => error transaction.set_error(error) raise error ensure - local_attrs = respond_to?(:que_attrs) ? que_attrs : attrs transaction.set_action_if_nil("#{local_attrs[:job_class]}#run") - transaction.add_params_if_nil do + transaction.add_function_parameters_if_nil do { :arguments => local_attrs[:args] }.tap do |hash| @@ -37,36 +168,91 @@ def _run(*args) # @!visibility private # - # Prepended to `Que::Job`'s singleton so it records each enqueue as an - # `enqueue.que` event under the active transaction. Like all AppSignal - # events, it only records when there's an active transaction (e.g. enqueuing - # from within a web request or another job); otherwise it's a transparent - # pass-through. + # Prepended to `Que::Job`'s singleton so it wraps enqueues. Records the + # enqueue as an AppSignal event (a producer span in collector mode), and in + # collector mode writes the current trace context onto the job's tags so the + # job that later performs links back to it. Like all AppSignal events, the + # enqueue only records when there's an active transaction; otherwise it's a + # transparent pass-through. module QueClientPlugin - # The keyword arguments are captured into a single `**kwargs` hash, rather - # than declaring a `job_options:` keyword with a default, so that an - # implicit `super` forwards the original call unchanged. Declaring - # `job_options: {}` would bind that default even when the caller did not - # pass it, and `super` would then forward it to Que. On Que 0.14, whose - # `enqueue` takes only positional arguments, that extra keyword ends up - # persisted as an additional job argument. - def enqueue(*_args, **kwargs) - job_options = kwargs[:job_options] || {} - - # Inside a `bulk_enqueue` block the batch is recorded once by the - # `bulk_enqueue` wrapper, so each inner enqueue is a pass-through to - # avoid recording an event per job. + # The keyword arguments are captured into a single `kwargs` hash, rather + # than declaring a `job_options:` keyword with a default, so that a + # keyword the caller did not pass is never forwarded to Que. See + # `forward_job_options` for why that matters. + def enqueue(*args, **kwargs) + # Inside a `bulk_enqueue` block the per-job enqueue must stay a + # pass-through: tags come from `bulk_enqueue`'s own `job_options` (Que + # raises if an inner enqueue passes them), and the batch's event and + # propagation are recorded once by the `bulk_enqueue` wrapper. return super if Thread.current[:appsignal_que_bulk_enqueue] - # Under Active Job the enqueue is already recorded as an - # `enqueue.active_job` event, so skip recording it again here. - return super if Appsignal::Transaction.current? && - Appsignal::Transaction.current.job_enqueue_events_suppressed? + job_options = kwargs[:job_options] || {} # Resolve the job class the way Que does: an explicit `:job_class`, else # the class `enqueue` was called on. title = "enqueue #{job_options[:job_class] || name} job" - Appsignal.instrument("enqueue.que", title) { super } + record_enqueue(job_options, "enqueue.que", title) do |merged| + super(*args, **forward_job_options(kwargs, merged)) + end + end + + private + + # Builds the keyword arguments to enqueue the job with. Passes + # `job_options` on to Que only when the caller passed it, or when the + # trace context was injected into it. Adding a `job_options` keyword to a + # call that did not have one breaks Que 0.x: it does not recognise the + # keyword, so it stores it as an extra job argument, and the job then + # fails because it is called with one argument too many. + def forward_job_options(kwargs, job_options) + return kwargs unless kwargs.key?(:job_options) || job_options.any? + + kwargs.merge(:job_options => job_options) + end + + # Records the enqueue as a producer event and, in collector mode, injects + # the current trace context into the job's tags so the job that later + # performs links back. Yields the (possibly tag-augmented) `job_options` to + # do the actual enqueue. + def record_enqueue(job_options, event_name, title, bulk: false) + # When enqueue instrumentation is disabled, drop the trace context along + # with the event, so yield the job options untouched. Without an enqueue + # event there is no producer span, so the context we would write is that + # of whatever span is current, such as the surrounding web request. The + # job that performs later would then link back to a span that is not a + # producer. + if Appsignal.config && !Appsignal.config[:enable_job_enqueue_instrumentation] + return yield job_options + end + + # Under Active Job the enqueue is already recorded as an + # `enqueue.active_job` event, so skip recording it again here. The trace + # context is still injected so the performed job links back. + if Appsignal::Transaction.current? && + Appsignal::Transaction.current.job_enqueue_events_suppressed? + return yield job_options_with_context(job_options, :bulk => bulk) + end + + Appsignal.instrument( + event_name, + title, + :opentelemetry_kind => :producer, + :opentelemetry_scope => ["appsignal-ruby/que", Appsignal::VERSION] + ) do + Appsignal::Transaction.current.add_opentelemetry_attributes( + Appsignal::OpenTelemetry::Messaging + .enqueue_attributes("que", :destination => job_options[:queue]) + ) + yield job_options_with_context(job_options, :bulk => bulk) + end + end + + # In collector mode, injects the current trace context into a copy of the + # job's tags and returns the tag-augmented `job_options`; a no-op that + # returns `job_options` unchanged outside collector mode. + def job_options_with_context(job_options, bulk: false) + tags = QueTraceContext.inject(job_options[:tags], :bulk => bulk) + tags.empty? ? job_options : job_options.merge(:tags => tags) end end @@ -74,22 +260,23 @@ def enqueue(*_args, **kwargs) # # `bulk_enqueue` exists only on Que 2+, so this lives in its own module that # the hook prepends only when Que has the method -- otherwise we'd define a - # `bulk_enqueue` on Que versions that have none. The whole batch records a - # single `bulk_enqueue.que` event; the inner enqueues are pass-throughs. + # `bulk_enqueue` on Que versions that have none. The whole batch shares one + # `job_options`, so it records a single `bulk_enqueue.que` producer event and + # the inner enqueues are pass-throughs. module QueBulkClientPlugin - def bulk_enqueue(*_args, job_options: {}, **_rest) - # Under Active Job the enqueue is already recorded as an - # `enqueue.active_job` event, so skip recording it again here. - return super if Appsignal::Transaction.current? && - Appsignal::Transaction.current.job_enqueue_events_suppressed? - - Appsignal.instrument("bulk_enqueue.que", bulk_enqueue_title(job_options)) do + def bulk_enqueue(job_options: {}, **rest, &block) + record_enqueue( + job_options, + "bulk_enqueue.que", + bulk_enqueue_title(job_options), + :bulk => true + ) do |merged| # Flag the batch so the enqueues this block triggers pass through # without recording, without reading Que's internal bulk state. was_bulk = Thread.current[:appsignal_que_bulk_enqueue] Thread.current[:appsignal_que_bulk_enqueue] = true begin - super + super(:job_options => merged, **rest, &block) ensure Thread.current[:appsignal_que_bulk_enqueue] = was_bulk end diff --git a/lib/appsignal/integrations/railtie.rb b/lib/appsignal/integrations/railtie.rb index 82e6eda8a..3860c89c6 100644 --- a/lib/appsignal/integrations/railtie.rb +++ b/lib/appsignal/integrations/railtie.rb @@ -96,7 +96,10 @@ def report(error, handled:, severity:, context: {}, source: nil) # rubocop:disab is_rails_runner = source == "application.runner.railties" namespace, action_name, tags, custom_data = context_for(context.dup) - Appsignal.report_error(error) do |transaction| + Appsignal.report_error( + error, + :opentelemetry_scope => ["appsignal-ruby/rails", Appsignal::VERSION] + ) do |transaction| if namespace transaction.set_namespace(namespace) elsif is_rails_runner diff --git a/lib/appsignal/integrations/rake.rb b/lib/appsignal/integrations/rake.rb index 0ae8d0e22..f892c1c27 100644 --- a/lib/appsignal/integrations/rake.rb +++ b/lib/appsignal/integrations/rake.rb @@ -22,7 +22,10 @@ def execute(*args) end begin - Appsignal.instrument "task.rake" do + Appsignal.instrument( + "task.rake", + :opentelemetry_scope => ["appsignal-ruby/rake", Appsignal::VERSION] + ) do super end rescue Exception => error @@ -38,7 +41,7 @@ def execute(*args) params, _ = args params = params.to_hash if params.respond_to?(:to_hash) transaction.set_action(name) - transaction.add_params_if_nil(params) + transaction.add_request_payload_if_nil(params) transaction.complete end end @@ -47,7 +50,10 @@ def execute(*args) private def _appsignal_create_transaction - Appsignal::Transaction.create("rake") + Appsignal::Transaction.create( + "rake", + :opentelemetry_scope => ["appsignal-ruby/rake", Appsignal::VERSION] + ) end end diff --git a/lib/appsignal/integrations/redis.rb b/lib/appsignal/integrations/redis.rb index ddb1ccd96..159aa17a2 100644 --- a/lib/appsignal/integrations/redis.rb +++ b/lib/appsignal/integrations/redis.rb @@ -11,8 +11,29 @@ def write(command) else "#{command[0]}#{" ?" * (command.size - 1)}" end + operation_name = command[0].to_s - Appsignal.instrument "query.redis", id, sanitized_command do + Appsignal.instrument( + "query.redis", + id, + sanitized_command, + :opentelemetry_kind => :client, + :opentelemetry_scope => ["appsignal-ruby/redis", Appsignal::VERSION] + ) do + # Names the datastore this span talks to, which is what the trace + # timeline reads to recognize a cache call, along with the command and + # the database it ran against. + # + # The command stays in the event body rather than moving to + # `db.query.text`: it is sanitized here already, and the collector + # sanitizes `db.query.text` again for Redis, which would mangle it. + Appsignal::Transaction.current.add_opentelemetry_attributes( + { + "db.system.name" => "redis", + "db.operation.name" => (operation_name unless operation_name.empty?), + "db.namespace" => (db.to_s if respond_to?(:db) && db) + }.compact + ) super end end diff --git a/lib/appsignal/integrations/redis_client.rb b/lib/appsignal/integrations/redis_client.rb index 891e915e7..45ed4c06e 100644 --- a/lib/appsignal/integrations/redis_client.rb +++ b/lib/appsignal/integrations/redis_client.rb @@ -11,8 +11,29 @@ def write(command) else "#{command[0]}#{" ?" * (command.size - 1)}" end + operation_name = command[0].to_s - Appsignal.instrument "query.redis", @config.id, sanitized_command do + Appsignal.instrument( + "query.redis", + @config.id, + sanitized_command, + :opentelemetry_kind => :client, + :opentelemetry_scope => ["appsignal-ruby/redis_client", Appsignal::VERSION] + ) do + # Names the datastore this span talks to, which is what the trace + # timeline reads to recognize a cache call, along with the command and + # the database it ran against. + # + # The command stays in the event body rather than moving to + # `db.query.text`: it is sanitized here already, and the collector + # sanitizes `db.query.text` again for Redis, which would mangle it. + Appsignal::Transaction.current.add_opentelemetry_attributes( + { + "db.system.name" => "redis", + "db.operation.name" => (operation_name unless operation_name.empty?), + "db.namespace" => (@config.db.to_s if @config.respond_to?(:db) && @config.db) + }.compact + ) super end end diff --git a/lib/appsignal/integrations/resque.rb b/lib/appsignal/integrations/resque.rb index 70e86d451..87ee9d1b1 100644 --- a/lib/appsignal/integrations/resque.rb +++ b/lib/appsignal/integrations/resque.rb @@ -5,9 +5,31 @@ module Integrations # @!visibility private module ResqueIntegration def perform - transaction = Appsignal::Transaction.create(Appsignal::Transaction::BACKGROUND_JOB) + # Read trace context off the job so the transaction links back to the + # enqueuer. No-op outside collector mode. + transaction = Appsignal::Transaction.create( + Appsignal::Transaction::BACKGROUND_JOB, + :opentelemetry_context => Appsignal::OpenTelemetry.extract_job_context(payload), + :opentelemetry_scope => ["appsignal-ruby/resque", Appsignal::VERSION], + :opentelemetry_kind => :consumer, + :opentelemetry_relationship => :both + ) + # Describes this span as a job being performed. The messaging system is + # what the trace timeline reads to recognize background job work, and + # `resque` is the value OpenTelemetry's own Resque instrumentation uses. + transaction.add_opentelemetry_attributes( + Appsignal::OpenTelemetry::Messaging + .perform_attributes("resque", :destination => queue) + ) - Appsignal.instrument "perform.resque" do + Appsignal.instrument( + "perform.resque", + :opentelemetry_scope => ["appsignal-ruby/resque", Appsignal::VERSION] + ) do + Appsignal::Transaction.current.add_opentelemetry_attributes( + Appsignal::OpenTelemetry::Messaging + .perform_attributes("resque", :destination => queue) + ) super end rescue Exception => exception @@ -16,7 +38,7 @@ def perform ensure if transaction transaction.set_action_if_nil("#{payload["class"]}#perform") - transaction.add_params_if_nil { ResqueHelpers.arguments(payload) } + transaction.add_function_parameters_if_nil { ResqueHelpers.arguments(payload) } transaction.add_tags("queue" => queue) Appsignal::Transaction.complete_current! @@ -25,8 +47,10 @@ def perform end end - # Wraps `Resque.push` to record an `enqueue.resque` event so the enqueue - # shows up under the active transaction. + # Wraps `Resque.push` to record an `enqueue.resque` event so the + # enqueue shows up under the active transaction (both modes), and in + # collector mode writes the trace context onto the job hash so the job that + # later performs links back to it. # # Like all AppSignal events, this only records when there's an active # transaction (e.g. enqueuing from within a web request or another job). @@ -34,13 +58,36 @@ def perform # # @!visibility private module ResquePushIntegration - def push(_queue, item) + def push(queue, item) + # When enqueue instrumentation is disabled, drop the trace context along + # with the event. Without an enqueue event there is no producer span, so + # the context we would write is that of whatever span is current, such as + # the surrounding web request. The job that performs later would then + # link back to a span that is not a producer. + return super if Appsignal.config && !Appsignal.config[:enable_job_enqueue_instrumentation] + # Under Active Job the enqueue is already recorded as an - # `enqueue.active_job` event, so skip recording it again here. - return super if Appsignal::Transaction.current? && - Appsignal::Transaction.current.job_enqueue_events_suppressed? + # `enqueue.active_job` event, so skip recording it again here. The trace + # context is still injected so the performed job links back. + if Appsignal::Transaction.current? && + Appsignal::Transaction.current.job_enqueue_events_suppressed? + Appsignal::OpenTelemetry.inject_context(item) + return super + end - Appsignal.instrument("enqueue.resque", "enqueue #{item["class"]} job") { super } + Appsignal.instrument( + "enqueue.resque", + "enqueue #{item["class"]} job", + :opentelemetry_kind => :producer, + :opentelemetry_scope => ["appsignal-ruby/resque", Appsignal::VERSION] + ) do + Appsignal::Transaction.current.add_opentelemetry_attributes( + Appsignal::OpenTelemetry::Messaging + .enqueue_attributes("resque", :destination => queue) + ) + Appsignal::OpenTelemetry.inject_context(item) + super + end end end diff --git a/lib/appsignal/integrations/shoryuken.rb b/lib/appsignal/integrations/shoryuken.rb index 3c46b4b2c..c8d500e09 100644 --- a/lib/appsignal/integrations/shoryuken.rb +++ b/lib/appsignal/integrations/shoryuken.rb @@ -2,20 +2,118 @@ module Appsignal module Integrations + # @!visibility private + # + # Reads and writes W3C trace context the way OpenTelemetry's aws-sdk + # instrumentation does: as SQS message attributes, using the global + # propagator. Staying wire-equivalent means that if both AppSignal and + # OpenTelemetry's aws-sdk instrumentation are active, one simply shadows the + # other rather than corrupting the carrier. Collector mode only. + module ShoryukenTraceContext + module_function + + # SQS allows at most 10 message attributes per message. Mirror + # OpenTelemetry and skip propagation rather than risk the enqueue failing + # when the user already fills the slots. + MAX_MESSAGE_ATTRIBUTES = 10 + + # Writes each trace header as an SQS message attribute, matching the shape + # OpenTelemetry's aws-sdk instrumentation injects on send. + module MessageAttributeSetter + def self.set(carrier, key, value) + return if carrier.length >= MAX_MESSAGE_ATTRIBUTES + + carrier[key] = { :string_value => value, :data_type => "String" } + end + end + + # Reads a trace header back out of a message attribute. Works both for the + # plain hash we inject and for the `Aws::SQS::Types::MessageAttributeValue` + # struct delivered on receive, since both respond to `[:string_value]` / + # `[:data_type]`. + module MessageAttributeGetter + def self.get(carrier, key) + attribute = carrier[key] + attribute[:string_value] if attribute && attribute[:data_type] == "String" + end + end + + # Read the incoming context off a message's SQS message attributes so the + # transaction links back to the enqueuer. Returns an + # `OpenTelemetry::Context`, or `nil` outside collector mode. + def extract(message_attributes) + Appsignal::OpenTelemetry.if_started do + ::OpenTelemetry.propagation.extract( + message_attributes || {}, + :getter => MessageAttributeGetter + ) + end + end + + # Write the current trace context into the outgoing send `options`. + # Injects into a scratch carrier first and merges it into the message + # attributes only when something was written, so an enqueue with no active + # span (no transaction, or outside collector mode) leaves the options + # untouched -- a transparent pass-through. + def inject(options) + Appsignal::OpenTelemetry.if_started do + carrier = {} + ::OpenTelemetry.propagation.inject(carrier, :setter => MessageAttributeSetter) + next if carrier.empty? + + options[:message_attributes] = (options[:message_attributes] || {}).merge(carrier) + end + end + end + # @!visibility private class ShoryukenMiddleware def call(worker_instance, queue, sqs_msg, body, &block) - transaction = Appsignal::Transaction.create(Appsignal::Transaction::BACKGROUND_JOB) + batch = sqs_msg.is_a?(Array) + # How many messages this call covers, which is only reported for a batch. + batch_size = sqs_msg.size if batch + + # Read the incoming trace context off the message so the transaction + # links back to the enqueuer. A batch carries messages from multiple + # traces with no single parent, so only single messages link back. + # No-op outside collector mode. + context = ShoryukenTraceContext.extract(sqs_msg.message_attributes) unless batch + + transaction = Appsignal::Transaction.create( + Appsignal::Transaction::BACKGROUND_JOB, + :opentelemetry_context => context, + :opentelemetry_scope => ["appsignal-ruby/shoryuken", Appsignal::VERSION], + :opentelemetry_kind => :consumer, + :opentelemetry_relationship => :both + ) + # Describes this span as a job being performed. The messaging system is + # what the trace timeline reads to recognize background job work. + # Shoryuken runs on Amazon SQS, so it takes the `aws_sqs` value the + # OpenTelemetry semantic conventions define for it. + transaction.add_opentelemetry_attributes( + Appsignal::OpenTelemetry::Messaging.perform_attributes( + "aws_sqs", :destination => queue, :batch_size => batch_size + ) + ) - Appsignal.instrument("perform_job.shoryuken", &block) + Appsignal.instrument( + "perform_job.shoryuken", + :opentelemetry_scope => ["appsignal-ruby/shoryuken", Appsignal::VERSION] + ) do + Appsignal::Transaction.current.add_opentelemetry_attributes( + Appsignal::OpenTelemetry::Messaging.perform_attributes( + "aws_sqs", :destination => queue, :batch_size => batch_size + ) + ) + block.call + end rescue Exception => error transaction.set_error(error) raise ensure - batch = sqs_msg.is_a?(Array) attributes = fetch_attributes(batch, sqs_msg) transaction.set_action_if_nil("#{worker_instance.class.name}#perform") - transaction.add_params_if_nil { fetch_args(batch, sqs_msg, body) } + transaction.add_function_parameters_if_nil { fetch_args(batch, sqs_msg, body) } transaction.add_tags(attributes) transaction.add_tags("queue" => queue) transaction.add_tags("batch" => true) if batch @@ -73,7 +171,9 @@ def fetch_args(batch, sqs_msg, body) end # Shoryuken client middleware that records an `enqueue.shoryuken` event so - # the enqueue shows up under the active transaction. + # the enqueue shows up under the active transaction (both modes), and in + # collector mode writes the current trace context onto the outgoing message + # so the job that later performs links back to it. # # Like all AppSignal events, this only records when there's an active # transaction (e.g. enqueuing from within a web request or another job). An @@ -81,13 +181,36 @@ def fetch_args(batch, sqs_msg, body) # # @!visibility private class ShoryukenClientMiddleware - def call(options, &block) + def call(options) + # When enqueue instrumentation is disabled, drop the trace context along + # with the event. Without an enqueue event there is no producer span, so + # the context we would write is that of whatever span is current, such as + # the surrounding web request. The job that performs later would then + # link back to a span that is not a producer. + return yield if Appsignal.config && !Appsignal.config[:enable_job_enqueue_instrumentation] + # Under Active Job the enqueue is already recorded as an - # `enqueue.active_job` event, so skip recording it again here. - return yield if Appsignal::Transaction.current? && - Appsignal::Transaction.current.job_enqueue_events_suppressed? + # `enqueue.active_job` event, so skip recording it again here. The trace + # context is still injected so the performed job links back. + if Appsignal::Transaction.current? && + Appsignal::Transaction.current.job_enqueue_events_suppressed? + ShoryukenTraceContext.inject(options) + return yield + end - Appsignal.instrument("enqueue.shoryuken", enqueue_title(options), &block) + Appsignal.instrument( + "enqueue.shoryuken", + enqueue_title(options), + :opentelemetry_kind => :producer, + :opentelemetry_scope => ["appsignal-ruby/shoryuken", Appsignal::VERSION] + ) do + Appsignal::Transaction.current.add_opentelemetry_attributes( + Appsignal::OpenTelemetry::Messaging + .enqueue_attributes("aws_sqs", :destination => queue_name(options)) + ) + ShoryukenTraceContext.inject(options) + yield + end end private @@ -99,8 +222,13 @@ def enqueue_title(options) worker_class = options.dig(:message_attributes, "shoryuken_class", :string_value) return "enqueue #{worker_class} job" if worker_class - queue = options[:queue_url].to_s.split("/").last - "enqueue on #{queue}" + "enqueue on #{queue_name(options)}" + end + + # The queue a message is being sent to, which SQS identifies by a URL whose + # last segment is the queue's name. + def queue_name(options) + options[:queue_url].to_s.split("/").last end end end diff --git a/lib/appsignal/integrations/sidekiq.rb b/lib/appsignal/integrations/sidekiq.rb index fd1f178ae..33925995b 100644 --- a/lib/appsignal/integrations/sidekiq.rb +++ b/lib/appsignal/integrations/sidekiq.rb @@ -38,10 +38,18 @@ def call(exception, sidekiq_context, _sidekiq_config = nil) # Sidekiq error outside of the middleware scope. # Can be a job JSON parse error or some other error happening in # Sidekiq. - transaction = Appsignal::Transaction.create(Appsignal::Transaction::BACKGROUND_JOB) + transaction = Appsignal::Transaction.create( + Appsignal::Transaction::BACKGROUND_JOB, + :opentelemetry_scope => ["appsignal-ruby/sidekiq", Appsignal::VERSION], + :opentelemetry_kind => :consumer, + :opentelemetry_relationship => :both + ) + transaction.add_opentelemetry_attributes( + Appsignal::OpenTelemetry::Messaging.perform_attributes("sidekiq") + ) transaction.set_action_if_nil("SidekiqInternal") transaction.set_metadata("sidekiq_error", sidekiq_context[:context]) - transaction.add_params_if_nil(:jobstr => sidekiq_context[:jobstr]) + transaction.add_function_parameters_if_nil(:jobstr => sidekiq_context[:jobstr]) transaction.set_error(exception) end @@ -98,24 +106,52 @@ def safe_load(content, default) end end - # Sidekiq client middleware that runs on enqueue. Records an - # `enqueue.sidekiq` event so the enqueue shows up under the active - # transaction. + # Client middleware that runs on enqueue. Records an `enqueue.sidekiq` + # event so the enqueue shows up under the active transaction (both modes), + # and in collector mode writes the trace context onto the job hash so the + # job that later performs links back to it. # # Like all AppSignal events, this only records when there's an active - # transaction (e.g. enqueuing from within a web request or another job). An - # enqueue with no transaction is a transparent pass-through. + # transaction (e.g. enqueuing from within a web request or another job). + # An enqueue with no transaction is a transparent pass-through. # # @!visibility private class SidekiqClientMiddleware - def call(_worker_class, job, _queue, _redis_pool, &block) + def call(_worker_class, job, _queue, _redis_pool) + # When enqueue instrumentation is disabled, drop the trace context along + # with the event. Without an enqueue event there is no producer span, so + # the context we would write is that of whatever span is current, such as + # the surrounding web request. The job that performs later would then + # link back to a span that is not a producer. + return yield if Appsignal.config && !Appsignal.config[:enable_job_enqueue_instrumentation] + # Under Active Job the enqueue is already recorded as an - # `enqueue.active_job` event, so skip recording it again here. - return yield if Appsignal::Transaction.current? && - Appsignal::Transaction.current.job_enqueue_events_suppressed? + # `enqueue.active_job` event, so skip recording it again here. The trace + # context is still injected so the performed job links back. + if Appsignal::Transaction.current? && + Appsignal::Transaction.current.job_enqueue_events_suppressed? + Appsignal::OpenTelemetry.inject_context(job) + return yield + end title = "enqueue #{SidekiqActionName.parse_action_name(job)} job" - Appsignal.instrument("enqueue.sidekiq", title, &block) + Appsignal.instrument( + "enqueue.sidekiq", + title, + :opentelemetry_kind => :producer, + :opentelemetry_scope => ["appsignal-ruby/sidekiq", Appsignal::VERSION] + ) do + # Describes this span as a job being enqueued. The messaging system + # is what the trace timeline reads to recognize background job work, + # and `sidekiq` is the value OpenTelemetry's own Sidekiq + # instrumentation uses. + Appsignal::Transaction.current.add_opentelemetry_attributes( + Appsignal::OpenTelemetry::Messaging + .enqueue_attributes("sidekiq", :destination => job["queue"]) + ) + Appsignal::OpenTelemetry.inject_context(job) + yield + end end end @@ -126,7 +162,7 @@ class SidekiqMiddleware EXCLUDED_JOB_KEYS = %w[ args backtrace class created_at enqueued_at error_backtrace error_class error_message failed_at jid retried_at retry wrapped cattr tags retry_for - unique_for + unique_for traceparent tracestate __otel_headers ].freeze def self.sidekiq8? @@ -138,7 +174,19 @@ def self.sidekiq8? def call(_worker, item, _queue, &block) job_status = nil action_name = formatted_action_name(item) - transaction = Appsignal::Transaction.create(Appsignal::Transaction::BACKGROUND_JOB) + # Read trace context off the job so the transaction links back to the + # enqueuer. No-op outside collector mode. + transaction = Appsignal::Transaction.create( + Appsignal::Transaction::BACKGROUND_JOB, + :opentelemetry_context => Appsignal::OpenTelemetry.extract_job_context(item), + :opentelemetry_scope => ["appsignal-ruby/sidekiq", Appsignal::VERSION], + :opentelemetry_kind => :consumer, + :opentelemetry_relationship => :both + ) + transaction.add_opentelemetry_attributes( + Appsignal::OpenTelemetry::Messaging + .perform_attributes("sidekiq", :destination => item["queue"]) + ) transaction.set_action_if_nil(action_name) formatted_metadata(item).each do |key, value| @@ -146,13 +194,22 @@ def call(_worker, item, _queue, &block) end begin - Appsignal.instrument "perform_job.sidekiq", &block + Appsignal.instrument( + "perform_job.sidekiq", + :opentelemetry_scope => ["appsignal-ruby/sidekiq", Appsignal::VERSION] + ) do + Appsignal::Transaction.current.add_opentelemetry_attributes( + Appsignal::OpenTelemetry::Messaging + .perform_attributes("sidekiq", :destination => item["queue"]) + ) + block.call + end rescue Exception => exception job_status = :failed raise exception ensure if transaction - transaction.add_params_if_nil { parse_arguments(item) } + transaction.add_function_parameters_if_nil { parse_arguments(item) } enqueued_at = item["enqueued_at"] queue_start = if self.class.sidekiq8? diff --git a/lib/appsignal/integrations/webmachine.rb b/lib/appsignal/integrations/webmachine.rb index 884143053..2e1d42709 100644 --- a/lib/appsignal/integrations/webmachine.rb +++ b/lib/appsignal/integrations/webmachine.rb @@ -10,20 +10,63 @@ def run if has_parent_transaction Appsignal::Transaction.current else - Appsignal::Transaction.create(Appsignal::Transaction::HTTP_REQUEST) + # Read the incoming trace context off the request headers so the + # transaction continues the upstream trace. No-op outside collector + # mode. Webmachine isn't Rack: `request.headers` is a case-insensitive + # `Webmachine::Headers`, so the default getter reads it directly. + Appsignal::Transaction.create( + Appsignal::Transaction::HTTP_REQUEST, + :opentelemetry_context => Appsignal::OpenTelemetry.if_started do + ::OpenTelemetry.propagation.extract(request.headers) + end, + :opentelemetry_scope => ["appsignal-ruby/webmachine", Appsignal::VERSION] + ) end + unless has_parent_transaction + # Describes the transaction's span as an incoming HTTP request. + # Together with the SERVER span kind the transaction already carries, + # this is what the trace timeline reads to recognize a web request. + # Set here, where the transaction is created, so they land on the + # transaction's own span rather than on the event started below. + # + # Webmachine isn't Rack: the path, scheme and query string come off the + # request's `URI` rather than from Rack's readers. The request's own + # `query` is the parsed form, so the URI is where the string itself is. + transaction.add_opentelemetry_attributes( + Appsignal::OpenTelemetry::HttpServerRequest.attributes_for( + :method => request.method, + :path => request.uri&.path, + :scheme => request.uri&.scheme, + :query => request.uri&.query + ) + ) + end + begin - transaction.add_params_if_nil { request.query } + transaction.add_query_parameters_if_nil { request.query } transaction.add_headers_if_nil { request.headers if request.respond_to?(:headers) } - Appsignal.instrument("process_action.webmachine") do + Appsignal.instrument( + "process_action.webmachine", + :opentelemetry_scope => ["appsignal-ruby/webmachine", Appsignal::VERSION] + ) do super end ensure transaction.set_action_if_nil("#{resource.class.name}##{request.method}") - Appsignal::Transaction.complete_current! unless has_parent_transaction + unless has_parent_transaction + # Describes the response on the transaction's span, which the + # semantic conventions ask for whenever a response was sent. The + # event above has closed by now, so this lands on the transaction's + # own span. + transaction.add_opentelemetry_attributes( + Appsignal::OpenTelemetry::HttpResponse.attributes_for(response.code) + ) + + Appsignal::Transaction.complete_current! + end end end diff --git a/lib/appsignal/loaders/padrino.rb b/lib/appsignal/loaders/padrino.rb index 2dde17f84..dadaadd62 100644 --- a/lib/appsignal/loaders/padrino.rb +++ b/lib/appsignal/loaders/padrino.rb @@ -20,7 +20,8 @@ def on_start Padrino.before_load do Padrino.use Appsignal::Rack::EventMiddleware Padrino.use Appsignal::Rack::SinatraBaseInstrumentation, - :instrument_event_name => "process_action.padrino" + :instrument_event_name => "process_action.padrino", + :opentelemetry_scope => ["appsignal-ruby/padrino", Appsignal::VERSION] end end diff --git a/lib/appsignal/logger.rb b/lib/appsignal/logger.rb index c912e5401..e41335ae8 100644 --- a/lib/appsignal/logger.rb +++ b/lib/appsignal/logger.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true require "logger" -require "set" module Appsignal # Logger that flushes logs to the AppSignal logging service. @@ -55,6 +54,8 @@ def to_proc # @!visibility private AUTODETECT = 3 # @!visibility private + FORMATS = [PLAINTEXT, LOGFMT, JSON, AUTODETECT].freeze + # @!visibility private SEVERITY_MAP = { DEBUG => 2, INFO => 3, @@ -83,7 +84,7 @@ def initialize(group, level: INFO, format: AUTODETECT, attributes: {}) @group = group @level = level @silenced = false - @format = format + @format = validated_format(format) @mutex = Mutex.new @default_attributes = attributes @appsignal_attributes = attributes @@ -145,13 +146,7 @@ def add(severity, message = nil, group = nil, &block) message = formatter.call(severity, Time.now, group, message) if formatter - Appsignal::Extension.log( - group, - SEVERITY_MAP.fetch(severity, 0), - @format, - message.to_s, - Appsignal::Utils::Data.generate(appsignal_attributes) - ) + Appsignal::Backends.logger.emit(group, severity, @format, message.to_s, appsignal_attributes) false end @@ -299,5 +294,14 @@ def add_with_attributes(severity, message, group, attributes, &block) ensure @appsignal_attributes = default_attributes end + + def validated_format(format) + return format if FORMATS.include?(format) + + Appsignal.internal_logger.warn( + "Unknown Appsignal::Logger format #{format.inspect}; falling back to AUTODETECT" + ) + AUTODETECT + end end end diff --git a/lib/appsignal/logger/extension_backend.rb b/lib/appsignal/logger/extension_backend.rb new file mode 100644 index 000000000..3ab716d54 --- /dev/null +++ b/lib/appsignal/logger/extension_backend.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Appsignal + class Logger < ::Logger + # @!visibility private + # + # Routes Appsignal::Logger emits through the AppSignal C-extension, + # which forwards them to the agent. This is the default backend used + # when collector mode is not active. + module ExtensionBackend + class << self + def emit(group, severity, format, message, attributes) + Appsignal::Extension.log( + group, + SEVERITY_MAP.fetch(severity, 0), + format, + message, + Appsignal::Utils::Data.generate(attributes) + ) + end + end + end + end +end diff --git a/lib/appsignal/logger/opentelemetry_backend.rb b/lib/appsignal/logger/opentelemetry_backend.rb new file mode 100644 index 000000000..6e3584e0c --- /dev/null +++ b/lib/appsignal/logger/opentelemetry_backend.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +require "appsignal/opentelemetry/attributes" + +module Appsignal + class Logger < ::Logger + # @!visibility private + # + # Routes Appsignal::Logger emits through the OpenTelemetry logs SDK + # using the logger provider configured at `Appsignal.start` time when + # collector mode is active. + # + # Each emit attaches two well-known attributes that the AppSignal + # collector consumes: + # + # - `appsignal.group` — overrides the collector's default + # `service.name`-based grouping with the logger's `group` argument. + # - `appsignal.format` — the lowercase parse-format name + # (`plaintext`/`logfmt`/`json`/`autodetect`) the processor uses to + # extract structured attributes from the message body. + module OpenTelemetryBackend + # Maps Ruby `::Logger` severities to OTel SeverityNumber + the + # human-readable severity text. + OTEL_SEVERITY_MAP = { + ::Logger::DEBUG => [5, "DEBUG"], + ::Logger::INFO => [9, "INFO"], + ::Logger::WARN => [13, "WARN"], + ::Logger::ERROR => [17, "ERROR"], + ::Logger::FATAL => [21, "FATAL"] + }.freeze + + # Maps the integer parse-format flag on `Appsignal::Logger` to the + # lowercase string the AppSignal collector and processor share. + FORMAT_NAMES = { + Appsignal::Logger::PLAINTEXT => "plaintext", + Appsignal::Logger::LOGFMT => "logfmt", + Appsignal::Logger::JSON => "json", + Appsignal::Logger::AUTODETECT => "autodetect" + }.freeze + + class << self + def emit(group, severity, format, message, attributes) + number, text = OTEL_SEVERITY_MAP.fetch(severity, [0, nil]) + otel_attributes = Appsignal::OpenTelemetry::Attributes.format(attributes) + otel_attributes["appsignal.group"] = group.to_s + otel_attributes["appsignal.format"] = FORMAT_NAMES.fetch(format, "autodetect") + logger.on_emit( + :severity_number => number, + :severity_text => text, + :body => message, + :attributes => otel_attributes + ) + end + + private + + # Resolve the OTel logger on each emit. The logger provider caches it + # by name, so this is a registry lookup rather than a rebuild, and it + # always reflects the currently configured provider. + def logger + ::OpenTelemetry.logger_provider.logger(:name => "appsignal-logger") + end + end + end + end +end diff --git a/lib/appsignal/metrics/extension_backend.rb b/lib/appsignal/metrics/extension_backend.rb new file mode 100644 index 000000000..ad2d63909 --- /dev/null +++ b/lib/appsignal/metrics/extension_backend.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +module Appsignal + module Metrics + # @!visibility private + # + # Routes custom metric helper calls through the AppSignal C-extension, + # which forwards them to the agent. This is the default backend used + # when collector mode is not active. + module ExtensionBackend + class << self + def set_gauge(name, value, tags) + Appsignal::Extension.set_gauge( + name.to_s, + value.to_f, + Appsignal::Utils::Data.generate(tags) + ) + rescue RangeError + Appsignal.internal_logger + .warn("The gauge value '#{value}' for metric '#{name}' is too big") + end + + def increment_counter(name, value, tags) + Appsignal::Extension.increment_counter( + name.to_s, + value.to_f, + Appsignal::Utils::Data.generate(tags) + ) + rescue RangeError + Appsignal.internal_logger + .warn("The counter value '#{value}' for metric '#{name}' is too big") + end + + def add_distribution_value(name, value, tags) + Appsignal::Extension.add_distribution_value( + name.to_s, + value.to_f, + Appsignal::Utils::Data.generate(tags) + ) + rescue RangeError + Appsignal.internal_logger + .warn("The distribution value '#{value}' for metric '#{name}' is too big") + end + end + end + end +end diff --git a/lib/appsignal/metrics/opentelemetry_backend.rb b/lib/appsignal/metrics/opentelemetry_backend.rb new file mode 100644 index 000000000..188a73ae1 --- /dev/null +++ b/lib/appsignal/metrics/opentelemetry_backend.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +require "appsignal/opentelemetry/attributes" + +module Appsignal + module Metrics + # @!visibility private + # + # Routes custom metric helper calls through the OpenTelemetry metrics + # SDK using the meter provider configured at `Appsignal.start` time when + # collector mode is active. Mirrors the Python integration's + # `appsignal/metrics.py`: + # + # - `set_gauge` uses a synchronous OTel Gauge. + # - `increment_counter` uses an UpDownCounter so negative increments + # work (Counter would reject them). + # - `add_distribution_value` uses a Histogram. + # + # Instruments are created once per name and cached: the OTel SDK logs a + # "duplicate instrument registration" warning and swaps the instrument + # if `create_*` is called again for the same name. Tags attach at record + # time, not at instrument creation time. + module OpenTelemetryBackend + MUTEX = Mutex.new + + class << self + def set_gauge(name, value, tags) + instrument(:gauge, name).record( + value.to_f, + :attributes => Appsignal::OpenTelemetry::Attributes.format(tags) + ) + end + + def increment_counter(name, value, tags) + instrument(:up_down_counter, name).add( + value.to_f, + :attributes => Appsignal::OpenTelemetry::Attributes.format(tags) + ) + end + + def add_distribution_value(name, value, tags) + instrument(:histogram, name).record( + value.to_f, + :attributes => Appsignal::OpenTelemetry::Attributes.format(tags) + ) + end + + # @!visibility private + # + # Test-only. Drops the cached meter and instruments so the next + # call re-resolves `OpenTelemetry.meter_provider`. + def reset! + MUTEX.synchronize do + @meter = nil + @gauges = nil + @counters = nil + @histograms = nil + end + end + + private + + # Fetch the named instrument, creating and caching it on first use. + # The lookup-or-create runs under the mutex so two concurrent + # first-time calls don't both create the instrument (which would + # make the SDK log a duplicate-registration warning). + def instrument(kind, name) + name = name.to_s + MUTEX.synchronize do + case kind + when :gauge + (@gauges ||= {})[name] ||= meter.create_gauge(name) + when :up_down_counter + (@counters ||= {})[name] ||= meter.create_up_down_counter(name) + when :histogram + (@histograms ||= {})[name] ||= meter.create_histogram(name) + end + end + end + + # Only called from `instrument` while the mutex is held, so the plain + # memoisation needs no extra locking of its own. + def meter + @meter ||= ::OpenTelemetry.meter_provider.meter("appsignal-helpers") + end + end + end + end +end diff --git a/lib/appsignal/opentelemetry.rb b/lib/appsignal/opentelemetry.rb new file mode 100644 index 000000000..4ee32b739 --- /dev/null +++ b/lib/appsignal/opentelemetry.rb @@ -0,0 +1,337 @@ +# frozen_string_literal: true + +require "appsignal/opentelemetry/attributes" +require "appsignal/opentelemetry/dependencies" +require "appsignal/opentelemetry/error_type" +require "appsignal/opentelemetry/http_client_request" +require "appsignal/opentelemetry/http_method" +require "appsignal/opentelemetry/http_response" +require "appsignal/opentelemetry/http_server_request" +require "appsignal/opentelemetry/messaging" +require "appsignal/opentelemetry/rendering" +require "appsignal/opentelemetry/sql_db_system" + +module Appsignal + # @!visibility private + module OpenTelemetry + class << self + # Configure the global OpenTelemetry SDK to export OTLP/HTTP protobuf to + # the collector endpoint in `config[:collector_endpoint]`. + # + # The SDK and exporter gems are required lazily, so an application not in + # collector mode does not pay the load cost. Sets `@started`, which + # {.started?} reads to decide whether to route through the OTel backends. + def configure(config) + # The OTel Ruby SDK exposes no programmatic knob for the default + # aggregation temporality; this env var is the only way to set + # it. We pick `:delta` to match the Python integration. (Note: + # the Ruby SDK keeps `UpDownCounter` cumulative regardless of + # this preference, per the OTel spec.) + ENV["OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE"] ||= "delta" + + # With the metrics and logs SDK gems loaded, `SDK.configure` below + # auto-installs a metrics reader and a log processor from these env vars, + # each with its own background thread. Both providers are replaced right + # after, which would leave those threads running and unreachable by any + # shutdown. Set unconditionally, so a user-set "otlp" cannot slip past + # and reintroduce them. + ENV["OTEL_METRICS_EXPORTER"] = "none" + ENV["OTEL_LOGS_EXPORTER"] = "none" + + require_sdk_gems + + # The OpenTelemetry gems are optional and installed by the user (not + # declared in the gemspec). If they're present but older than the + # versions we support, fall back to the agent rather than booting an + # SDK that may misbehave (e.g. a metrics SDK without fork hooks). + return unless required_gem_versions_met? + + endpoint = config[:collector_endpoint].to_s.sub(%r{/+\z}, "") + # Merge with the SDK's default resource so all three signal types + # carry the same `telemetry.sdk.*` and `process.*` attributes that + # `SDK.configure` would have added on its own. `MeterProvider` and + # `LoggerProvider` take a `resource:` kwarg that replaces (not + # merges), so we do the merge ourselves and use the same merged + # resource for the tracer provider to keep all three in sync. + resource = ::OpenTelemetry::SDK::Resources::Resource.default.merge(build_resource(config)) + + ::OpenTelemetry::SDK.configure do |c| + c.resource = resource + c.add_span_processor( + ::OpenTelemetry::SDK::Trace::Export::BatchSpanProcessor.new( + ::OpenTelemetry::Exporter::OTLP::Exporter.new( + :endpoint => "#{endpoint}/v1/traces" + ) + ) + ) + end + + # Wrap the OTLP MetricsExporter in a PeriodicMetricReader so that + # `MeterProvider#force_flush` actually triggers an export. The OTLP + # exporter itself is also a MetricReader but its inherited + # `force_flush` is a no-op. + ::OpenTelemetry.meter_provider = + ::OpenTelemetry::SDK::Metrics::MeterProvider.new(:resource => resource) + ::OpenTelemetry.meter_provider.add_metric_reader( + ::OpenTelemetry::SDK::Metrics::Export::PeriodicMetricReader.new( + :exporter => ::OpenTelemetry::Exporter::OTLP::Metrics::MetricsExporter.new( + :endpoint => "#{endpoint}/v1/metrics" + ) + ) + ) + + ::OpenTelemetry.logger_provider = + ::OpenTelemetry::SDK::Logs::LoggerProvider.new(:resource => resource) + ::OpenTelemetry.logger_provider.add_log_record_processor( + ::OpenTelemetry::SDK::Logs::Export::BatchLogRecordProcessor.new( + ::OpenTelemetry::Exporter::OTLP::Logs::LogsExporter.new( + :endpoint => "#{endpoint}/v1/logs" + ) + ) + ) + + @started = true + rescue LoadError => e + @started = false + Appsignal::Utils::StdoutAndLoggerMessage.error( + "Cannot configure OpenTelemetry SDK for collector mode: #{e.class}: #{e.message}" + ) + rescue => e + @started = false + Appsignal::Utils::StdoutAndLoggerMessage.error( + "Error configuring OpenTelemetry SDK for collector mode: " \ + "#{e.class}: #{e.message}\n#{e.backtrace&.join("\n")}" + ) + end + + # Whether {.configure} has successfully booted the OpenTelemetry SDK + # for this process. Returns `false` before {.configure} runs and + # `false` if it ran but raised. + def started? + defined?(@started) ? @started : false + end + + # Write the current trace context onto an outgoing carrier (HTTP request, + # job hash, ...) with the configured propagator, so a downstream service + # joins the same trace. + # + # A no-op unless the SDK has booted. The carrier is injected from whatever + # span is current at call time, which inside an `Appsignal.instrument` + # block is the event's own span. + def inject_context(carrier) + if_started do + ::OpenTelemetry.propagation.inject(carrier) + end + end + + # Read the trace context off an incoming Rack request env using the + # globally configured propagator, so an AppSignal transaction created for + # the request can continue the upstream trace. Returns an + # `OpenTelemetry::Context` (its current span is the remote parent), or + # `nil` when the SDK has not booted -- outside collector mode there is + # nothing to continue. `rack_env_getter` reads the `HTTP_*`-mangled header + # names Rack puts in the env. + def extract_rack_context(env) + if_started do + # Extract onto an empty context, not `Context.current`. The W3C + # extractor returns its base context unchanged when the carrier has no + # `traceparent`, so a request with no incoming context would inherit + # whatever span is ambient on the fiber. Starting empty means "no + # carrier" yields no parent, and the transaction starts its own trace. + ::OpenTelemetry.propagation.extract( + env, + :context => ::OpenTelemetry::Context.empty, + :getter => ::OpenTelemetry::Common::Propagation.rack_env_getter + ) + end + end + + # Read the trace context off an incoming background job hash, so the + # transaction can link back to the enqueuer. Returns an + # `OpenTelemetry::Context`, or `nil` when the SDK has not booted. + # + # Reads both carriers a job can arrive with: top-level `traceparent` and + # `tracestate` keys, as OpenTelemetry's Sidekiq instrumentation injects + # them, and a nested `__otel_headers`, as its Active Job one does. Active + # Job puts that through its argument serializer, so it can arrive as an + # array of pairs rather than a hash. The nested keys win, being the more + # specific layer. + def extract_job_context(item) + if_started do + carrier = item + nested = item["__otel_headers"] + nested = nested.to_h if otel_header_pairs?(nested) + carrier = item.merge(nested) if nested.is_a?(Hash) + # Extract onto an empty context rather than the default + # `Context.current`, for the same reason as `extract_rack_context`: + # a job with no injected trace context must not inherit an ambient + # span left on the fiber. Otherwise the job's transaction would link + # back to an unrelated leaked span instead of standing on its own. + ::OpenTelemetry.propagation.extract( + carrier, + :context => ::OpenTelemetry::Context.empty + ) + end + end + + # Run `block` only when the OpenTelemetry SDK has booted (collector mode), + # returning its result; a no-op returning `nil` otherwise. The block can + # touch the OTel SDK freely -- it only runs when the SDK is loaded. + # + # This is the gate every integration's OTel-specific work goes through, so + # integration-specific carrier/getter/setter logic lives in the + # integration rather than as a bespoke helper here. + def if_started + return unless started? + + yield + end + + # @!visibility private + # + # Test-only. Drops the started flag so subsequent tests start from a + # clean slate; does not touch the global `::OpenTelemetry` providers. + def reset! + @started = false + end + + # Flush and shut down the OpenTelemetry SDK providers booted by + # {.configure}. Called from `Appsignal.stop` so buffered + # metrics/logs/spans don't get dropped on exit. + def shutdown + return unless started? + + ::OpenTelemetry.tracer_provider&.shutdown + ::OpenTelemetry.meter_provider&.shutdown + ::OpenTelemetry.logger_provider&.shutdown + rescue => e + Appsignal.internal_logger.error( + "Error shutting down OpenTelemetry SDK: #{e.class}: #{e.message}" + ) + end + + # Build the OpenTelemetry Resource that carries AppSignal config to the + # collector. Attributes whose underlying option is nil or an empty array + # are omitted so the collector applies its own defaults. + def build_resource(config) + revision = config[:revision].to_s.empty? ? "unknown" : config[:revision] + service_name = config[:service_name].to_s.empty? ? "unknown" : config[:service_name] + host_name = config[:hostname].to_s.empty? ? "unknown" : config[:hostname] + + attrs = { + "appsignal.config.name" => config[:name], + "appsignal.config.environment" => config.env, + "appsignal.config.push_api_key" => config[:push_api_key], + "appsignal.config.revision" => revision, + "appsignal.config.language_integration" => "ruby", + "service.name" => service_name, + "host.name" => host_name, + "appsignal.config.filter_attributes" => config[:filter_attributes], + "appsignal.config.filter_function_parameters" => config[:filter_function_parameters], + "appsignal.config.filter_request_query_parameters" => + config[:filter_request_query_parameters], + "appsignal.config.filter_request_payload" => config[:filter_request_payload], + "appsignal.config.filter_request_session_data" => config[:filter_session_data], + "appsignal.config.ignore_actions" => config[:ignore_actions], + "appsignal.config.ignore_errors" => config[:ignore_errors], + "appsignal.config.ignore_namespaces" => config[:ignore_namespaces], + "appsignal.config.response_headers" => config[:response_headers], + "appsignal.config.request_headers" => config[:request_headers], + "appsignal.config.send_function_parameters" => config[:send_function_parameters], + "appsignal.config.send_request_query_parameters" => + config[:send_request_query_parameters], + "appsignal.config.send_request_payload" => config[:send_request_payload], + "appsignal.config.send_request_session_data" => config[:send_session_data] + } + attrs.reject! { |_, v| v.nil? || (v.respond_to?(:empty?) && v.empty?) } + ::OpenTelemetry::SDK::Resources::Resource.create(attrs) + end + + private + + # Whether a `__otel_headers` value is the array-of-`[key, value]`-pairs + # shape produced by ActiveJob's argument serializer, so it can be turned + # into a hash carrier. Anything else (including a malformed array) is left + # alone rather than raising on `to_h` inside a job perform. + def otel_header_pairs?(value) + value.is_a?(Array) && value.all? { |pair| pair.is_a?(Array) && pair.size == 2 } + end + + # The optional OpenTelemetry gems, required lazily so users not in + # collector mode don't pay the load cost. A missing gem raises LoadError, + # caught by {.configure}. + def require_sdk_gems + require "opentelemetry/sdk" + require "opentelemetry-common" + require "opentelemetry/exporter/otlp" + require "opentelemetry-metrics-sdk" + require "opentelemetry-exporter-otlp-metrics" + require "opentelemetry-logs-sdk" + require "opentelemetry-exporter-otlp-logs" + end + + # Checks the installed OpenTelemetry gem versions against {REQUIRED_GEMS}. + # On a shortfall, warns and flags the SDK as not started so the caller + # falls back to the agent; returns whether all requirements are met. + def required_gem_versions_met? + incompatible = incompatible_gems + return true if incompatible.nil? + + @started = false + Appsignal::Utils::StdoutAndLoggerMessage.warning( + collector_gems_warning(incompatible) + ) + false + end + + # Builds the warning shown when the OpenTelemetry gems collector mode needs + # are not all installed at a supported version. `incompatible` describes + # the gems that are installed but at a version we do not support. + # + # The message always recommends the `appsignal-opentelemetry` gem, which + # installs the whole set. It only lists gems when some are installed at an + # incompatible version, because that usually means a constraint in the + # bundle that the gem cannot override on its own. + def collector_gems_warning(incompatible) + message = + "AppSignal collector mode requires a set of OpenTelemetry gems. " \ + "Add the `appsignal-opentelemetry` gem to your bundle to install " \ + "them. The AppSignal agent will be used instead." + + unless incompatible.empty? + message += "\n\nThese installed OpenTelemetry gems are not compatible " \ + "with this AppSignal version. Update them or remove a version " \ + "constraint:\n" + message += incompatible.map { |line| "- #{line}" }.join("\n") + end + + message + end + + # Checks the installed OpenTelemetry gems against {REQUIRED_GEMS}. Returns + # `nil` when every required gem is installed at a supported version. + # Otherwise returns the descriptions of gems that are installed but at a + # version we do not support. That list is empty when the only problem is + # that some required gems are not installed at all. + def incompatible_gems + missing = false + incompatible = [] + + REQUIRED_GEMS.each do |name, constraints| + spec = Gem.loaded_specs[name] + requirement = Gem::Requirement.new(*constraints) + + if spec.nil? + missing = true + elsif !requirement.satisfied_by?(spec.version) + incompatible << "#{name} #{spec.version} (requires #{requirement})" + end + end + + return nil if !missing && incompatible.empty? + + incompatible + end + end + end +end diff --git a/lib/appsignal/opentelemetry/attributes.rb b/lib/appsignal/opentelemetry/attributes.rb new file mode 100644 index 000000000..6a664ef10 --- /dev/null +++ b/lib/appsignal/opentelemetry/attributes.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Appsignal + module OpenTelemetry + # @!visibility private + # + # Coerces user-supplied tag hashes into a shape the OpenTelemetry SDK + # accepts as attribute values: string keys, and values restricted to + # the primitive types the OTLP spec allows. Anything else falls back + # to `to_s`. Shared by the metric and log backends so both behave + # identically. + module Attributes + class << self + def format(attrs) + attrs.each_with_object({}) do |(key, value), result| + result[key.to_s] = format_value(value) + end + end + + private + + def format_value(value) + case value + when String, Integer, Float, TrueClass, FalseClass then value + else value.to_s + end + end + end + end + end +end diff --git a/lib/appsignal/opentelemetry/dependencies.rb b/lib/appsignal/opentelemetry/dependencies.rb new file mode 100644 index 000000000..af571d18e --- /dev/null +++ b/lib/appsignal/opentelemetry/dependencies.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module Appsignal + module OpenTelemetry + # @!visibility private + # + # The OpenTelemetry gems collector mode supports, mapped to their version + # requirements. Each gem maps to an array of requirement strings so a gem + # can carry more than one constraint later, even though each holds a single + # `~>` requirement today. These gems are *not* declared in the gemspec: + # they are optional and only required when collector mode is active. Apps + # that opt into collector mode install them into their own bundle (see the + # collector documentation). + # + # Each requirement pins a floor and a ceiling. The floor is the version the + # minimum supported Ruby (3.1) resolves to in our CI collector matrix. The + # ceiling is a pessimistic `~>` cap at the next major version. We set the + # cap loosely on purpose so it does not block customers from updating these + # gems within a major version. + # + # This file must stay free of any other dependency so it can be required + # directly from a Gemfile (see `gemfiles/collector.rb`) and from the + # runtime version gate in `Appsignal::OpenTelemetry.configure` without + # loading the rest of the gem. + REQUIRED_GEMS = { + "opentelemetry-sdk" => ["~> 1.10"], + "opentelemetry-common" => ["~> 0.23"], + "opentelemetry-metrics-sdk" => ["~> 0.12"], + "opentelemetry-logs-sdk" => ["~> 0.4"], + "opentelemetry-exporter-otlp" => ["~> 0.32"], + "opentelemetry-exporter-otlp-metrics" => ["~> 0.7"], + "opentelemetry-exporter-otlp-logs" => ["~> 0.3"] + }.freeze + end +end diff --git a/lib/appsignal/opentelemetry/error_type.rb b/lib/appsignal/opentelemetry/error_type.rb new file mode 100644 index 000000000..59a0ff023 --- /dev/null +++ b/lib/appsignal/opentelemetry/error_type.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module Appsignal + module OpenTelemetry + # @!visibility private + # + # Builds the OpenTelemetry attribute that describes what kind of failure + # ended an operation. + # + # The semantic conventions ask for `error.type` on a span whose operation + # failed, and for it to be left unset on one that succeeded. The value has to + # have low cardinality, which for an exception means its class name rather + # than its message. A failure we have no name for becomes `_OTHER`, which is + # the fallback the conventions define. + module ErrorType + ATTRIBUTE = "error.type" + + # The value the semantic conventions use for a failure the + # instrumentation has no name for. + OTHER = "_OTHER" + + class << self + # The attributes describing the given failure, as a Hash to pass to + # `add_opentelemetry_attributes`. Takes the name of the failure: an + # exception's class name, or the error code a datastore reported. + # + # An anonymous exception class has no name, and a datastore does not + # always report a code, so a missing name falls back to `_OTHER` rather + # than leaving the span with no `error.type` at all. + def attributes_for(name) + value = name.to_s + { ATTRIBUTE => value.empty? ? OTHER : value } + end + end + end + end +end diff --git a/lib/appsignal/opentelemetry/http_client_request.rb b/lib/appsignal/opentelemetry/http_client_request.rb new file mode 100644 index 000000000..c4b7a3c9c --- /dev/null +++ b/lib/appsignal/opentelemetry/http_client_request.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +module Appsignal + module OpenTelemetry + # @!visibility private + # + # Builds the OpenTelemetry attributes that describe an outgoing HTTP request. + # + # The semantic conventions ask for the request method, the host and port + # being called, and the full URL. Those together are also what the trace + # timeline reads to recognize an outgoing request. + # + # The URL is built from the parts rather than taken whole, which has two + # consequences worth knowing about. + # + # It never contains credentials. A URL can carry a username and password, + # which the conventions say must not be sent. Building the URL from the + # scheme, host, port and path means there is nowhere for them to come from. + # + # It never contains the query string either. The conventions do ask for it, + # but the query string of a call to somebody else's API is the part most + # likely to carry a key or a token, and unlike an incoming request it is not + # something the application chose the shape of. Anything a caller passes as a + # path is cut at the first question mark for the same reason, because some + # clients hand us a request target rather than a path. + module HttpClientRequest + ADDRESS_ATTRIBUTE = "server.address" + PORT_ATTRIBUTE = "server.port" + URL_ATTRIBUTE = "url.full" + + # The port each scheme uses when a URL does not name one. Left out of the + # URL, which is how a URL is normally written, but still reported as the + # port, which the conventions ask for either way. + DEFAULT_PORTS = { + "http" => 80, + "https" => 443 + }.freeze + + class << self + # The attributes describing the given request, as a Hash to pass to + # `add_opentelemetry_attributes`. Takes the request's parts, because + # some clients hand us a URI and others only the parts. + def attributes_for(method:, scheme: nil, host: nil, port: nil, path: nil) + attributes = HttpMethod.attributes_for(method) + attributes[ADDRESS_ATTRIBUTE] = host.to_s unless host.to_s.empty? + + port = port_for(scheme, port) + attributes[PORT_ATTRIBUTE] = port if port + + url = url_for(scheme, host, port, path) + attributes[URL_ATTRIBUTE] = url if url + + attributes + end + + private + + # A client that was not given a port uses the one its scheme implies, so + # report that rather than nothing. + def port_for(scheme, port) + Integer(port, :exception => false) || DEFAULT_PORTS[scheme.to_s] + end + + def url_for(scheme, host, port, path) + return if scheme.to_s.empty? || host.to_s.empty? + + url = +"#{scheme}://#{host}" + url << ":#{port}" if port && port != DEFAULT_PORTS[scheme.to_s] + url << path_without_query(path) + url + end + + # Some clients report the path of a request to the root of a host as + # empty, and others as "/". The request goes to "/" either way, so say so + # rather than let the URL differ by which client made the request. + def path_without_query(path) + without_query = path.to_s.split("?", 2).first.to_s + without_query.empty? ? "/" : without_query + end + end + end + end +end diff --git a/lib/appsignal/opentelemetry/http_method.rb b/lib/appsignal/opentelemetry/http_method.rb new file mode 100644 index 000000000..1b999a0b8 --- /dev/null +++ b/lib/appsignal/opentelemetry/http_method.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +module Appsignal + module OpenTelemetry + # @!visibility private + # + # Builds the OpenTelemetry attributes that describe an HTTP request method. + # + # The semantic conventions treat `http.request.method` as a closed set of + # known method names, so an arbitrary value cannot be passed through. A + # method outside the set becomes `_OTHER`, and a method that only matches + # after upcasing is replaced by its canonical form. Both of those cases keep + # the value we were given in `http.request.method_original`, so the original + # is never lost. + module HttpMethod + # The methods the semantic conventions know about: those defined in + # RFC 9110, plus PATCH from RFC 5789. + KNOWN_METHODS = [ + "CONNECT", + "DELETE", + "GET", + "HEAD", + "OPTIONS", + "PATCH", + "POST", + "PUT", + "TRACE" + ].freeze + + # The value the semantic conventions use for a method they do not know. + OTHER = "_OTHER" + + METHOD_ATTRIBUTE = "http.request.method" + ORIGINAL_ATTRIBUTE = "http.request.method_original" + + class << self + # The attributes describing the given request method, as a Hash to pass + # to `add_opentelemetry_attributes`. Returns an empty Hash when there is + # no method to describe, so a caller that could not read one can pass + # the result on without checking. + def attributes_for(method) + original = method.to_s + return {} if original.empty? + + # Method names are case sensitive, so a value that already matches a + # known method is used as it is, with nothing to preserve. + return { METHOD_ATTRIBUTE => original } if KNOWN_METHODS.include?(original) + + canonical = original.upcase + if KNOWN_METHODS.include?(canonical) + { METHOD_ATTRIBUTE => canonical, ORIGINAL_ATTRIBUTE => original } + else + { METHOD_ATTRIBUTE => OTHER, ORIGINAL_ATTRIBUTE => original } + end + end + end + end + end +end diff --git a/lib/appsignal/opentelemetry/http_response.rb b/lib/appsignal/opentelemetry/http_response.rb new file mode 100644 index 000000000..b37a006f1 --- /dev/null +++ b/lib/appsignal/opentelemetry/http_response.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module Appsignal + module OpenTelemetry + # @!visibility private + # + # Builds the OpenTelemetry attributes that describe an HTTP response. + # + # The semantic conventions ask for the response status code on the span of an + # HTTP request, whether that is a request this application handled or one it + # made. They ask for it only when a response was actually received or sent, + # so a request that never got one is described without it. + module HttpResponse + STATUS_CODE_ATTRIBUTE = "http.response.status_code" + + class << self + # The attributes describing the given response status, as a Hash to pass + # to `add_opentelemetry_attributes`. Returns an empty Hash when there is + # no status to describe, so a caller whose request produced no response + # can pass the result on without checking. + def attributes_for(status) + code = Integer(status, :exception => false) + return {} unless code + + { STATUS_CODE_ATTRIBUTE => code } + end + end + end + end +end diff --git a/lib/appsignal/opentelemetry/http_server_request.rb b/lib/appsignal/opentelemetry/http_server_request.rb new file mode 100644 index 000000000..19ba934d1 --- /dev/null +++ b/lib/appsignal/opentelemetry/http_server_request.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +module Appsignal + module OpenTelemetry + # @!visibility private + # + # Builds the OpenTelemetry attributes that describe an incoming HTTP + # request. + # + # The semantic conventions ask for the request method, the path and the + # scheme on the span of a request a server handled. Those three together are + # also what the trace timeline reads to recognize a web request. + # + # The path is the concrete path the request was made to, such as `/users/1`. + # It is not the route template the application matched it against, which the + # conventions call `http.route` and which a Rack application does not + # necessarily have. + # + # The query string is asked for whenever the request had one. It is sent + # whole, without the leading question mark, and it is not filtered here. The + # collector filters it with the `filter_request_query_parameters` option, and + # builds the request's query parameters out of it. + # + # Every value is optional, because reading any of them from the request can + # fail. An attribute we have no value for is left out rather than sent + # empty. + module HttpServerRequest + PATH_ATTRIBUTE = "url.path" + SCHEME_ATTRIBUTE = "url.scheme" + QUERY_ATTRIBUTE = "url.query" + + class << self + # The attributes describing the given request, as a Hash to pass to + # `add_opentelemetry_attributes`. + def attributes_for(method:, path: nil, scheme: nil, query: nil) + attributes = HttpMethod.attributes_for(method) + attributes[PATH_ATTRIBUTE] = path.to_s unless path.to_s.empty? + attributes[SCHEME_ATTRIBUTE] = scheme.to_s unless scheme.to_s.empty? + attributes[QUERY_ATTRIBUTE] = query.to_s unless query.to_s.empty? + attributes + end + end + end + end +end diff --git a/lib/appsignal/opentelemetry/messaging.rb b/lib/appsignal/opentelemetry/messaging.rb new file mode 100644 index 000000000..2db28ac94 --- /dev/null +++ b/lib/appsignal/opentelemetry/messaging.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +module Appsignal + module OpenTelemetry + # @!visibility private + # + # Builds the OpenTelemetry attributes that describe a messaging operation, + # which for AppSignal means a background job being enqueued or performed. + # + # The semantic conventions ask for the messaging system, the name of the + # operation, and the kind of operation it was. The name is the job library's + # own word for what happened. The kind is one of the five values the + # conventions define, and AppSignal only ever produces two of them: + # enqueuing a job sends a message, and performing one processes it. + # + # They also ask for the destination of the message, which for a job library + # is the queue it is on, and for how many messages a span covers when it + # covers a batch of them. + # + # The system is the only part that differs per job library, so each + # integration passes its own. + module Messaging + SYSTEM_ATTRIBUTE = "messaging.system" + OPERATION_NAME_ATTRIBUTE = "messaging.operation.name" + OPERATION_TYPE_ATTRIBUTE = "messaging.operation.type" + DESTINATION_ATTRIBUTE = "messaging.destination.name" + BATCH_COUNT_ATTRIBUTE = "messaging.batch.message_count" + + # The operations AppSignal records, and the kind of operation the + # conventions class each of them as. + ENQUEUE = "enqueue" + PERFORM = "perform" + OPERATION_TYPES = { + ENQUEUE => "send", + PERFORM => "process" + }.freeze + + class << self + # The attributes describing a job being enqueued, as a Hash to pass to + # `add_opentelemetry_attributes`. + def enqueue_attributes(system, destination: nil, batch_size: nil) + attributes_for(system, ENQUEUE, destination, batch_size) + end + + # The attributes describing a job being performed, as a Hash to pass to + # `add_opentelemetry_attributes`. + def perform_attributes(system, destination: nil, batch_size: nil) + attributes_for(system, PERFORM, destination, batch_size) + end + + private + + def attributes_for(system, operation, destination, batch_size) + { + SYSTEM_ATTRIBUTE => system, + OPERATION_NAME_ATTRIBUTE => operation, + OPERATION_TYPE_ATTRIBUTE => OPERATION_TYPES.fetch(operation), + DESTINATION_ATTRIBUTE => destination_name(destination), + BATCH_COUNT_ATTRIBUTE => batch_count(batch_size) + }.compact + end + + # The queue the job is on, which the conventions call the destination of + # the message. A job whose queue we cannot name is described without it. + def destination_name(destination) + name = destination.to_s + name unless name.empty? + end + + # How many messages the span covers. The conventions ask for this only + # when a span describes a batch, and say it must not be set on a span + # that describes a single message. + def batch_count(batch_size) + return unless batch_size + + count = batch_size.to_i + count if count.positive? + end + end + end + end +end diff --git a/lib/appsignal/opentelemetry/rendering.rb b/lib/appsignal/opentelemetry/rendering.rb new file mode 100644 index 000000000..e01b96dbd --- /dev/null +++ b/lib/appsignal/opentelemetry/rendering.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Appsignal + module OpenTelemetry + # @!visibility private + # + # Builds the OpenTelemetry attributes that describe an event as template + # rendering. + # + # Rendering a template has no semantic convention to describe it, so these + # events say which group they belong to directly. The trace timeline reads + # `appsignal.group` before it looks at any convention attribute, and + # "render" is the group it shows as "Templating". + module Rendering + GROUP_ATTRIBUTE = "appsignal.group" + GROUP = "render" + + ATTRIBUTES = { GROUP_ATTRIBUTE => GROUP }.freeze + + class << self + # The attributes describing an event as template rendering, as a Hash + # to pass to `add_opentelemetry_attributes`. + def attributes + ATTRIBUTES + end + end + end + end +end diff --git a/lib/appsignal/opentelemetry/sql_db_system.rb b/lib/appsignal/opentelemetry/sql_db_system.rb new file mode 100644 index 000000000..7f0551dcb --- /dev/null +++ b/lib/appsignal/opentelemetry/sql_db_system.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +module Appsignal + module OpenTelemetry + # @!visibility private + # + # Maps a SQL library's own name for the engine it is talking to onto the + # `db.system.name` semantic conventions value for that engine. + # + # Each library names engines differently, and none of them matches the + # semantic conventions spelling, so every SQL integration needs a lookup. + # Kept as one map per library, each keyed on that library's own + # vocabulary, rather than one shared map: ActiveRecord's `ADAPTER_NAME` + # strings, Sequel's `database_type` symbols, and DataMapper's + # `DataObjects` connection class names do not collide today, but nothing + # about that is enforced by combining them. A wrong value passed into the + # wrong library's lookup would silently return another library's answer + # instead of nil, and a library's own gaps would be invisible next to two + # other libraries' complete entries. ROM is the exception: its payload + # already carries Sequel's own `database_type` symbol, so it is + # legitimate for it to share Sequel's map, not just convenient. + # + # A name none of these maps recognise returns `nil`, so the caller's own + # `other_sql` fallback applies -- exactly what already happens for a + # library this gem has no mapping for at all. + # + # This intentionally follows the older semantic conventions value set: + # SQL Server maps to `mssql`, not the current registry's + # `microsoft.sql_server`, and Oracle maps to `oracle`, not `oracle.db`. + # That is what the AppSignal collector's sanitizer recognizes today; + # emitting the newer values would silently turn sanitization off for + # those engines' queries. + module SqlDbSystem + # ActiveRecord's `ADAPTER_NAME`. Rails bundles the Postgres, Mysql2, + # SQLite and (7.1+) Trilogy adapters; SQL Server and Oracle come from + # the separate `activerecord-sqlserver-adapter` and + # `activerecord-oracle_enhanced-adapter` gems, which declare + # `ADAPTER_NAME` the same way. + ACTIVE_RECORD = { + "PostgreSQL" => "postgresql", + "Mysql2" => "mysql", + "Trilogy" => "mysql", + "SQLite" => "sqlite", + "SQLServer" => "mssql", + "OracleEnhanced" => "oracle" + }.freeze + + # Sequel's `database_type`. ROM's dry-monitor payload reuses this + # symbol directly, so `name_for_sequel` is also ROM's lookup. + SEQUEL = { + :postgres => "postgresql", + :mysql => "mysql", + :sqlite => "sqlite", + :mssql => "mssql", + :oracle => "oracle" + }.freeze + + # DataMapper's `DataObjects` connection classes. + DATA_MAPPER = { + "DataObjects::Postgres::Connection" => "postgresql", + "DataObjects::Mysql::Connection" => "mysql", + "DataObjects::Sqlite3::Connection" => "sqlite", + "DataObjects::SqlServer::Connection" => "mssql" + }.freeze + + class << self + # The `db.system.name` value for an ActiveRecord connection's + # `adapter_name`, or `nil` when the adapter is not one this map + # recognises. + def name_for_active_record(adapter_name) + ACTIVE_RECORD[adapter_name] + end + + # The `db.system.name` value for Sequel's `database_type`, or `nil` + # when it is not one this map recognises. Also the lookup ROM's + # formatter uses, since ROM reports this same symbol. + def name_for_sequel(database_type) + SEQUEL[database_type] + end + + # The `db.system.name` value for DataMapper's connection class name, + # or `nil` when it is not one this map recognises. + def name_for_data_mapper(connection_class_name) + DATA_MAPPER[connection_class_name] + end + end + end + end +end diff --git a/lib/appsignal/rack.rb b/lib/appsignal/rack.rb index 40b23ca70..637e8f510 100644 --- a/lib/appsignal/rack.rb +++ b/lib/appsignal/rack.rb @@ -7,9 +7,46 @@ module Rack APPSIGNAL_EVENT_HANDLER_ID = "appsignal.event_handler_id" APPSIGNAL_EVENT_HANDLER_HAS_ERROR = "appsignal.event_handler.error" APPSIGNAL_RESPONSE_INSTRUMENTED = "appsignal.response_instrumentation_active" + APPSIGNAL_RESPONSE_STATUS = "appsignal.response_status" RACK_AFTER_REPLY = "rack.after_reply" class Utils + # Fetch the HTTP request method from the request. + # + # The request class is configurable, so reading the method can raise. + # Log and return nil in that case, leaving it to the caller to skip + # whatever it needed the method for. + # + # @param request [Rack::Request] Request object. + # @return [String, NilClass] + def self.request_method_from(request) + request.request_method + rescue => error + Appsignal.internal_logger.error( + "Exception while fetching the HTTP request method: #{error.class}: #{error}" + ) + nil + end + + # Fetch a value that describes the request, named after the method that + # reads it. + # + # The request class is configurable, so reading from the request can + # raise. Log and return nil in that case, leaving it to the caller to skip + # whatever it needed the value for. + # + # @param request [Rack::Request] Request object. + # @param name [Symbol] Name of the method that reads the value. + # @return [Object, NilClass] + def self.request_value_from(request, name) + request.public_send(name) + rescue => error + Appsignal.internal_logger.error( + "Exception while fetching the HTTP request #{name}: #{error.class}: #{error}" + ) + nil + end + # Fetch the queue start time from the request environment. # # @since 3.11.0 @@ -53,14 +90,14 @@ def apply_to(transaction) # TODO: Remove in next major/minor version transaction.set_metadata("path", request_path) - request_method = request_method_for(request) + request_method = Appsignal::Rack::Utils.request_method_from(request) if request_method transaction.set_metadata("request_method", request_method) # TODO: Remove in next major/minor version transaction.set_metadata("method", request_method) end - transaction.add_params { params_for(request) } + transaction.add_request_payload { params_for(request) } transaction.add_session_data { session_data_for(request) } transaction.add_headers do request.env if request.respond_to?(:env) @@ -84,15 +121,6 @@ def params_for(request) nil end - def request_method_for(request) - request.request_method - rescue => error - Appsignal.internal_logger.error( - "Exception while fetching the HTTP request method: #{error.class}: #{error}" - ) - nil - end - def session_data_for(request) return unless request.respond_to?(:session) diff --git a/lib/appsignal/rack/abstract_middleware.rb b/lib/appsignal/rack/abstract_middleware.rb index 8761559d5..ba815d546 100644 --- a/lib/appsignal/rack/abstract_middleware.rb +++ b/lib/appsignal/rack/abstract_middleware.rb @@ -20,6 +20,10 @@ def initialize(app, options = {}) @options = options @request_class = options.fetch(:request_class, ::Rack::Request) @instrument_event_name = options.fetch(:instrument_event_name, nil) + # The OpenTelemetry instrumentation scope for the request's spans in + # collector mode. Each framework middleware sets its own; nil falls back + # to the default scope in the backend. + @opentelemetry_scope = options.fetch(:opentelemetry_scope, nil) @report_errors = options.fetch(:report_errors, DEFAULT_ERROR_REPORTING) end @@ -33,7 +37,11 @@ def call(env) if wrapped_instrumentation env[Appsignal::Rack::APPSIGNAL_TRANSACTION] else - Appsignal::Transaction.create(Appsignal::Transaction::HTTP_REQUEST) + Appsignal::Transaction.create( + Appsignal::Transaction::HTTP_REQUEST, + :opentelemetry_context => Appsignal::OpenTelemetry.extract_rack_context(env), + :opentelemetry_scope => @opentelemetry_scope + ) end unless wrapped_instrumentation @@ -41,6 +49,7 @@ def call(env) # middleware can detect if there is parent instrumentation # middleware active. env[Appsignal::Rack::APPSIGNAL_TRANSACTION] = transaction + add_opentelemetry_request_attributes(transaction, request) end begin @@ -59,8 +68,13 @@ def call(env) ensure add_transaction_metadata_after(transaction, request) - # Complete transaction because this is the top instrumentation middleware. - Appsignal::Transaction.complete_current! unless wrapped_instrumentation + unless wrapped_instrumentation + add_opentelemetry_response_attributes(transaction, request) + + # Complete transaction because this is the top instrumentation + # middleware. + Appsignal::Transaction.complete_current! + end end else @app.call(env) @@ -69,6 +83,45 @@ def call(env) private + # Describes the transaction's span as an incoming HTTP request. Together + # with the SERVER span kind the transaction already carries, this is what + # the trace timeline reads to recognize a web request. + # + # Set where the transaction is created, rather than with the rest of the + # request metadata in {ApplyRackRequest}, which runs after the app has been + # called: by then an event span may be open, and the attributes would land + # on that instead. Only the middleware that created the transaction sets + # them, so nested middleware does not write them again. + def add_opentelemetry_request_attributes(transaction, request) + transaction.add_opentelemetry_attributes( + Appsignal::OpenTelemetry::HttpServerRequest.attributes_for( + :method => Appsignal::Rack::Utils.request_method_from(request), + :path => Appsignal::Rack::Utils.request_value_from(request, :path), + :scheme => Appsignal::Rack::Utils.request_value_from(request, :scheme), + :query => Appsignal::Rack::Utils.request_value_from(request, :query_string) + ) + ) + end + + # Describes the response the app produced on the transaction's span, which + # the semantic conventions ask for whenever a response was sent. + # + # Set from the `ensure` in {#call} rather than from {#call_app}, where the + # status is first known. That runs inside the instrumented event, so the + # attribute would land on the event span instead of on the transaction's + # own span. The status travels between the two in the request environment + # for that reason. + # + # A request whose app raised never produced a status, and is described + # without one. + def add_opentelemetry_response_attributes(transaction, request) + transaction.add_opentelemetry_attributes( + Appsignal::OpenTelemetry::HttpResponse.attributes_for( + request.env[Appsignal::Rack::APPSIGNAL_RESPONSE_STATUS] + ) + ) + end + # Another instrumentation middleware is active earlier in the stack, so # don't report any exceptions here, the top instrumentation middleware # will be the one reporting the exception. @@ -79,7 +132,10 @@ def call(env) # @see #instrument_app_call_with_exception_handling def instrument_app_call(env, transaction) if @instrument_event_name - Appsignal.instrument(@instrument_event_name) do + Appsignal.instrument( + @instrument_event_name, + :opentelemetry_scope => @opentelemetry_scope + ) do call_app(env, transaction) end else @@ -89,6 +145,9 @@ def instrument_app_call(env, transaction) def call_app(env, transaction) status, headers, obody = @app.call(env) + # Remember the status for {#add_opentelemetry_response_attributes}, which + # runs once this event has closed. + env[Appsignal::Rack::APPSIGNAL_RESPONSE_STATUS] = status body = if env[Appsignal::Rack::APPSIGNAL_RESPONSE_INSTRUMENTED] obody diff --git a/lib/appsignal/rack/body_wrapper.rb b/lib/appsignal/rack/body_wrapper.rb index 901330f76..e3504e83b 100644 --- a/lib/appsignal/rack/body_wrapper.rb +++ b/lib/appsignal/rack/body_wrapper.rb @@ -48,7 +48,10 @@ def close # of the body has already closed itself (as prescribed) we do not # attempt to close it twice if !@body_already_closed && @body.respond_to?(:close) - Appsignal.instrument("close_response_body.rack") { @body.close } + Appsignal.instrument( + "close_response_body.rack", + :opentelemetry_scope => ["appsignal-ruby/rack", Appsignal::VERSION] + ) { @body.close } end @body_already_closed = true rescue *IGNORED_ERRORS # Do not report @@ -104,7 +107,11 @@ def each(&blk) # in a blockless way it is still a good idea to have it in place. return enum_for(:each) unless block_given? - Appsignal.instrument("process_response_body.rack", "Process Rack response body (#each)") do + Appsignal.instrument( + "process_response_body.rack", + "Process Rack response body (#each)", + :opentelemetry_scope => ["appsignal-ruby/rack", Appsignal::VERSION] + ) do @body.each(&blk) end rescue *IGNORED_ERRORS # Do not report @@ -125,7 +132,11 @@ class CallableBodyWrapper < BodyWrapper def call(stream) # `stream` will be closed by the app we are calling, no need for us # to close it ourselves - Appsignal.instrument("process_response_body.rack", "Process Rack response body (#call)") do + Appsignal.instrument( + "process_response_body.rack", + "Process Rack response body (#call)", + :opentelemetry_scope => ["appsignal-ruby/rack", Appsignal::VERSION] + ) do @body.call(stream) end rescue *IGNORED_ERRORS # Do not report @@ -150,7 +161,8 @@ def to_ary @body_already_closed = true Appsignal.instrument( "process_response_body.rack", - "Process Rack response body (#to_ary)" + "Process Rack response body (#to_ary)", + :opentelemetry_scope => ["appsignal-ruby/rack", Appsignal::VERSION] ) do @body.to_ary end @@ -168,7 +180,8 @@ class PathableBodyWrapper < EnumerableBodyWrapper def to_path Appsignal.instrument( "process_response_body.rack", - "Process Rack response body (#to_path)" + "Process Rack response body (#to_path)", + :opentelemetry_scope => ["appsignal-ruby/rack", Appsignal::VERSION] ) do @body.to_path end diff --git a/lib/appsignal/rack/event_handler.rb b/lib/appsignal/rack/event_handler.rb index 77c299dd6..f9e383af6 100644 --- a/lib/appsignal/rack/event_handler.rb +++ b/lib/appsignal/rack/event_handler.rb @@ -63,8 +63,30 @@ def on_start(request, _response) request.env[APPSIGNAL_EVENT_HANDLER_ID] ||= id return unless request_handler?(request.env[APPSIGNAL_EVENT_HANDLER_ID]) - transaction = Appsignal::Transaction.create(Appsignal::Transaction::HTTP_REQUEST) - transaction.start_event + transaction = Appsignal::Transaction.create( + Appsignal::Transaction::HTTP_REQUEST, + :opentelemetry_context => Appsignal::OpenTelemetry.extract_rack_context(request.env), + :opentelemetry_scope => ["appsignal-ruby/rack", Appsignal::VERSION] + ) + # Describes the transaction's span as an incoming HTTP request. + # Together with the SERVER span kind the transaction already carries, + # this is what the trace timeline reads to recognize a web request. + # + # Set before the event below starts, because attributes go on + # whichever span is open and these belong on the transaction's own + # span. That event stays open until the response finishes, so there is + # no later point in the request at which these could be set. + transaction.add_opentelemetry_attributes( + Appsignal::OpenTelemetry::HttpServerRequest.attributes_for( + :method => Appsignal::Rack::Utils.request_method_from(request), + :path => Appsignal::Rack::Utils.request_value_from(request, :path), + :scheme => Appsignal::Rack::Utils.request_value_from(request, :scheme), + :query => Appsignal::Rack::Utils.request_value_from(request, :query_string) + ) + ) + transaction.start_event( + :opentelemetry_scope => ["appsignal-ruby/rack", Appsignal::VERSION] + ) request.env[APPSIGNAL_TRANSACTION] = transaction request.env[RACK_AFTER_REPLY] ||= [] @@ -118,13 +140,24 @@ def on_finish(request, response) self.class.safe_execution("Appsignal::Rack::EventHandler#on_finish") do transaction.finish_event("process_request.rack", "callback: on_finish", "") - transaction.add_params_if_nil { request.params } + transaction.add_request_payload_if_nil { request.params } transaction.add_headers_if_nil { request.env } transaction.add_session_data_if_nil do request.session if request.respond_to?(:session) end queue_start = Appsignal::Rack::Utils.queue_start_from(request.env) transaction.set_queue_start(queue_start) if queue_start + # Describes the response on the transaction's span, which the semantic + # conventions ask for whenever a response was sent. It can be set here + # because the `process_request.rack` event was finished above, which + # leaves the transaction's own span as the one attributes go on. + # + # Only a response the app actually produced counts. The 500 below + # stands in for a status that was never sent, so it is reported as a + # tag and a metric but not as this attribute. + transaction.add_opentelemetry_attributes( + Appsignal::OpenTelemetry::HttpResponse.attributes_for(response&.status) + ) response_status = if response response.status diff --git a/lib/appsignal/rack/grape_middleware.rb b/lib/appsignal/rack/grape_middleware.rb index 1446871ae..07ac709fd 100644 --- a/lib/appsignal/rack/grape_middleware.rb +++ b/lib/appsignal/rack/grape_middleware.rb @@ -7,6 +7,7 @@ class GrapeMiddleware < Appsignal::Rack::AbstractMiddleware # @api private def initialize(app, options = {}) options[:instrument_event_name] = "process_request.grape" + options[:opentelemetry_scope] = ["appsignal-ruby/grape", Appsignal::VERSION] options[:report_errors] = lambda { |env| !env["grape.skip_appsignal_error"] } super end diff --git a/lib/appsignal/rack/hanami_middleware.rb b/lib/appsignal/rack/hanami_middleware.rb index bb8805c5f..6a481a244 100644 --- a/lib/appsignal/rack/hanami_middleware.rb +++ b/lib/appsignal/rack/hanami_middleware.rb @@ -7,6 +7,7 @@ class HanamiMiddleware < AbstractMiddleware def initialize(app, options = {}) options[:params_method] = nil options[:instrument_event_name] ||= "process_action.hanami" + options[:opentelemetry_scope] ||= ["appsignal-ruby/hanami", Appsignal::VERSION] super end @@ -19,7 +20,7 @@ def initialize(app, options = {}) def add_transaction_metadata_after(transaction, request) action_name = fetch_hanami_action(request.env) transaction.set_action_if_nil(action_name) if action_name - transaction.add_params { params_for(request) } + transaction.add_request_payload { params_for(request) } end def params_for(request) diff --git a/lib/appsignal/rack/instrumentation_middleware.rb b/lib/appsignal/rack/instrumentation_middleware.rb index 3b1f6b445..e633c5302 100644 --- a/lib/appsignal/rack/instrumentation_middleware.rb +++ b/lib/appsignal/rack/instrumentation_middleware.rb @@ -55,6 +55,7 @@ module Rack class InstrumentationMiddleware < AbstractMiddleware def initialize(app, options = {}) options[:instrument_event_name] ||= "process_request_middleware.rack" + options[:opentelemetry_scope] ||= ["appsignal-ruby/rack", Appsignal::VERSION] super end end diff --git a/lib/appsignal/rack/rails_instrumentation.rb b/lib/appsignal/rack/rails_instrumentation.rb index 764822da7..d13f7e50f 100644 --- a/lib/appsignal/rack/rails_instrumentation.rb +++ b/lib/appsignal/rack/rails_instrumentation.rb @@ -8,6 +8,7 @@ def initialize(app, options = {}) options[:request_class] ||= ActionDispatch::Request options[:params_method] ||= :filtered_parameters options[:instrument_event_name] = nil + options[:opentelemetry_scope] = ["appsignal-ruby/rails", Appsignal::VERSION] options[:report_errors] = true super end diff --git a/lib/appsignal/rack/sinatra_instrumentation.rb b/lib/appsignal/rack/sinatra_instrumentation.rb index ce0e66606..435a86d4a 100644 --- a/lib/appsignal/rack/sinatra_instrumentation.rb +++ b/lib/appsignal/rack/sinatra_instrumentation.rb @@ -33,6 +33,7 @@ def initialize(app, options = {}) options[:request_class] ||= Sinatra::Request options[:params_method] ||= :params options[:instrument_event_name] ||= "process_action.sinatra" + options[:opentelemetry_scope] ||= ["appsignal-ruby/sinatra", Appsignal::VERSION] super @raise_errors_on = raise_errors?(app) end diff --git a/lib/appsignal/sample_data.rb b/lib/appsignal/sample_data.rb index c8359dd23..7ec7be276 100644 --- a/lib/appsignal/sample_data.rb +++ b/lib/appsignal/sample_data.rb @@ -61,6 +61,10 @@ def empty? @empty end + # The sample-data category this holds (e.g. `:params`, `:request_payload`). + # Used to name the data in log messages. + attr_reader :key + protected attr_reader :blocks diff --git a/lib/appsignal/transaction.rb b/lib/appsignal/transaction.rb index c901c275b..10378c148 100644 --- a/lib/appsignal/transaction.rb +++ b/lib/appsignal/transaction.rb @@ -22,14 +22,37 @@ class Transaction ERROR_CAUSES_LIMIT = 10 # @!visibility private ERRORS_LIMIT = 10 + # Guards the process-wide `add_params`/`set_params` deprecation warn-once + # flag, which transactions touch concurrently on threaded servers. A + # constant so it is created once at load time rather than lazily. + # @!visibility private + PARAMS_DEPRECATION_LOCK = Mutex.new class << self # Create a new transaction and set it as the currently active # transaction. # # @param namespace [String] Namespace of the to be created transaction. + # @param opentelemetry_kind [Symbol] In collector mode, the OpenTelemetry + # span kind: one of `:server`, `:consumer`, `:producer` or `:internal`. + # Defaults to `:server`. + # @param opentelemetry_relationship [Symbol] In collector mode, how an + # incoming `opentelemetry_context` relates to this transaction's span: + # one of `:parent`, `:link`, `:both` or `:none`. Defaults to `:parent`. + # @param opentelemetry_context In collector mode, an incoming OpenTelemetry + # trace context to relate this transaction's span to. + # @param opentelemetry_scope [Array(String, String)] In collector mode, the + # OpenTelemetry instrumentation scope to record this transaction's spans + # under, given as a `[name, version]` pair. Defaults to the AppSignal + # scope. # @return [Transaction] - def create(namespace) + def create( + namespace, + opentelemetry_context: nil, + opentelemetry_scope: nil, + opentelemetry_kind: nil, + opentelemetry_relationship: nil + ) # Reset the transaction if it was already completed but not cleared if Thread.current[:appsignal_transaction]&.completed? Thread.current[:appsignal_transaction] = nil @@ -37,7 +60,15 @@ def create(namespace) if Thread.current[:appsignal_transaction].nil? # If not, start a new transaction - set_current_transaction(Appsignal::Transaction.new(namespace)) + set_current_transaction( + Appsignal::Transaction.new( + namespace, + :opentelemetry_context => opentelemetry_context, + :opentelemetry_scope => opentelemetry_scope, + :opentelemetry_kind => opentelemetry_kind, + :opentelemetry_relationship => opentelemetry_relationship + ) + ) else transaction = current # Otherwise, log the issue about trying to start another transaction @@ -150,6 +181,26 @@ def last_errors # @!visibility private attr_writer :last_errors + + # Runs the block to emit the collector-mode `add_params`/`set_params` + # deprecation warning the first time it is called in the process, then + # stays quiet. The check-and-set is done under a lock so concurrent + # transactions on threaded runtimes don't race and warn more than once. + # @!visibility private + def warn_params_deprecation_once + should_warn = PARAMS_DEPRECATION_LOCK.synchronize do + next false if @params_deprecation_warned + + @params_deprecation_warned = true + end + yield if should_warn + end + + # @!visibility private + # Resets the warn-once state. Only used to keep test runs isolated. + def reset_params_deprecation_warning! + PARAMS_DEPRECATION_LOCK.synchronize { @params_deprecation_warned = false } + end end # @!visibility private @@ -160,7 +211,15 @@ def last_errors # @param namespace [String] Namespace of the to be created transaction. # @see create # @!visibility private - def initialize(namespace, id: SecureRandom.uuid, ext: nil) + def initialize( # rubocop:disable Metrics/ParameterLists + namespace, + id: SecureRandom.uuid, + backend: nil, + opentelemetry_context: nil, + opentelemetry_scope: nil, + opentelemetry_kind: nil, + opentelemetry_relationship: nil + ) @transaction_id = id @action = nil @namespace = namespace @@ -168,22 +227,47 @@ def initialize(namespace, id: SecureRandom.uuid, ext: nil) @discarded = false @completed = false @tags = {} - @breadcrumbs = [] @store = Hash.new { |hash, key| hash[key] = {} } + # The distinct errors added to this transaction, in add order. Drives + # `last_errors`, the dedup check and the `ERRORS_LIMIT`, in both modes. + # A Set, so membership is by object identity (`eql?`/`hash`), matching how + # these errors used to be deduplicated as Hash keys. `Exception#==` would + # instead collapse distinct errors with the same class, message and + # backtrace, which we don't want. + @errors = Set.new + # Blocks per error, only populated in agent mode, where they run against + # the duplicate transactions at completion. Collector mode runs blocks + # when the error is added and never touches this. @error_blocks = Hash.new { |hash, key| hash[key] = [] } @is_duplicate = false @error_set = nil - @params = Appsignal::SampleData.new(:params) @session_data = Appsignal::SampleData.new(:session_data, Hash) @headers = Appsignal::SampleData.new(:headers, Hash) @custom_data = Appsignal::SampleData.new(:custom_data) - @ext = ext || Appsignal::Extension.start_transaction( + @backend = backend || Appsignal::Backends.transaction.new( @transaction_id, @namespace, - 0 - ) || Appsignal::Extension::MockTransaction.new + :opentelemetry_context => opentelemetry_context, + :opentelemetry_scope => opentelemetry_scope, + :opentelemetry_kind => opentelemetry_kind, + :opentelemetry_relationship => opentelemetry_relationship + ) + + # The backend decides how the params channels are stored. Its + # `params_mapping` maps each logical channel to a storage bucket: the + # extension backend maps them all to one `:params` bucket, so agent mode + # keeps a single merged blob, while the OpenTelemetry backend keeps the + # request payload, function parameters and query parameters apart. + # + # Each distinct bucket gets its own `SampleData`, named after the bucket. + # That symbol is also the sample-data key the backend receives, so it can + # route the bucket to the right storage. + @params_mapping = @backend.params_mapping + @params_buckets = @params_mapping.values.uniq.to_h do |bucket| + [bucket, Appsignal::SampleData.new(bucket)] + end run_after_create_hooks end @@ -205,9 +289,22 @@ def completed? # @!visibility private def complete + # Completing is idempotent: a transaction can be completed explicitly and + # then again by a `complete_current!` cleanup path. Re-running would, for a + # multi-error transaction, re-record the extra errors (a second duplicate + # in agent mode, or an event on an already-finished span in collector mode). + return if completed? + if discarded? Appsignal.internal_logger.debug "Skipping transaction '#{transaction_id}' " \ "because it was manually discarded." + # Let the backend tear itself down. The agent backend drops the + # transaction (nothing is sent); the OpenTelemetry backend still + # finishes and exports the root span, but flags it with + # `appsignal.ignore_subtrace` so the collector ignores the subtrace. + # `@completed` stays false either way: a discarded transaction was + # never reported. + @backend.discard return end @@ -219,40 +316,18 @@ def complete should_sample = true unless duplicate? - self.class.last_errors = @error_blocks.keys - should_sample = @ext.finish(0) + self.class.last_errors = @errors.to_a + should_sample = @backend.finish end - @error_blocks.each do |error, blocks| - # Ignore the error that is already set in this transaction. - next if error == @error_set - - duplicate.tap do |transaction| - # In the duplicate transaction for each error, set an error - # with a block that calls all the blocks set for that error - # in the original transaction. - transaction.internal_set_error(error) do - blocks.each { |block| block.call(transaction) } - end - - transaction.complete - end - end - - if @error_set && @error_blocks[@error_set].any? - self.class.with_transaction(self) do - @error_blocks[@error_set].each do |block| - block.call(self) - end - end - end + report_errors run_before_complete_hooks sample_data if should_sample @completed = true - @ext.complete + @backend.complete end # @!visibility private @@ -328,12 +403,13 @@ def suppress_job_enqueue_events end # @!visibility private + # + # True when an outer integration (Active Job) is already recording this + # enqueue. Nested integrations use it to skip their own enqueue event, but + # they must still propagate trace context: the outer integration's producer + # span is what the performing job links back to, and only the nested + # integration owns the carrier that job travels on. def job_enqueue_events_suppressed? - # When enqueue instrumentation is disabled, every enqueue integration - # treats its event as suppressed. That is how the config option turns the - # enqueue events off across all integrations at once. - return true if Appsignal.config && !Appsignal.config[:enable_job_enqueue_instrumentation] - store("job_enqueue")[:suppressed] == true end @@ -356,17 +432,28 @@ def job_enqueue_events_suppressed? # @see https://docs.appsignal.com/guides/custom-data/sample-data.html # Sample data guide def add_params(given_params = nil, &block) - @params.add(given_params, &block) + warn_params_deprecation + params_data(:params).add(given_params, &block) end alias set_params add_params + # Marks every params channel as explicitly empty, so no parameters are + # reported for this transaction whatever their source. + # + # This is a deliberate choice for collector mode, where the request payload + # and the function parameters live in separate buckets: it empties both, not + # only the request payload the legacy `:params` channel maps to. Otherwise + # an integration's `_if_nil` setter could still add function parameters (a + # background job's arguments) after params were emptied, and the behavior + # would differ from agent mode, where all channels share one bucket. + # # @since 4.0.0 # @return [void] # @!visibility private # # @see Helpers::Instrumentation#set_empty_params! def set_empty_params! - @params.set_empty_value! + @params_buckets.each_value(&:set_empty_value!) end # Add parameters to the transaction if not already set. @@ -382,10 +469,119 @@ def set_empty_params! # # @see #add_params def add_params_if_nil(given_params = nil, &block) - add_params(given_params, &block) if !@params.value? && !@params.empty? + add_params(given_params, &block) if params_unset?(:params) end alias set_params_if_nil add_params_if_nil + # Add the request payload to the transaction. + # + # These are the parameters of an incoming request, such as the query string + # and the request body. In collector mode they map to the request payload + # attribute. In agent mode they are the transaction's params. + # + # Behaves like {#add_params}: merges when called multiple times, and a + # block takes precedence over the argument. + # + # @param given_params [Hash, Array] The parameters to add to the + # transaction. + # @yield This block is called when the transaction is sampled. The block's + # return value will become the new parameters. + # @yieldreturn [Hash, Array] + # @return [void] + # + # @see #add_function_parameters + def add_request_payload(given_params = nil, &block) + params_data(:request_payload).add(given_params, &block) + end + + # Add the request payload to the transaction if not already set. + # + # @param given_params [Hash, Array] The parameters to add to the + # transaction if none are already set. + # @yield This block is called when the transaction is sampled. The block's + # return value will become the new parameters. + # @yieldreturn [Hash, Array] + # @return [void] + # @!visibility private + # + # @see #add_request_payload + def add_request_payload_if_nil(given_params = nil, &block) + add_request_payload(given_params, &block) if params_unset?(:request_payload) + end + + # Add the function parameters to the transaction. + # + # These are the arguments a background job or function was called with. In + # collector mode they map to the function parameters attribute. In agent + # mode they are the transaction's params. + # + # Behaves like {#add_params}: merges when called multiple times, and a + # block takes precedence over the argument. + # + # @param given_params [Hash, Array] The parameters to add to the + # transaction. + # @yield This block is called when the transaction is sampled. The block's + # return value will become the new parameters. + # @yieldreturn [Hash, Array] + # @return [void] + # + # @see #add_request_payload + def add_function_parameters(given_params = nil, &block) + params_data(:function_parameters).add(given_params, &block) + end + + # Add the function parameters to the transaction if not already set. + # + # @param given_params [Hash, Array] The parameters to add to the + # transaction if none are already set. + # @yield This block is called when the transaction is sampled. The block's + # return value will become the new parameters. + # @yieldreturn [Hash, Array] + # @return [void] + # @!visibility private + # + # @see #add_function_parameters + def add_function_parameters_if_nil(given_params = nil, &block) + add_function_parameters(given_params, &block) if params_unset?(:function_parameters) + end + + # Add the query parameters to the transaction. + # + # These are the parameters parsed from an incoming request's query string. + # In collector mode they map to their own attribute, separate from the + # request payload and the function parameters. In agent mode they are the + # transaction's params. + # + # Behaves like {#add_params}: merges when called multiple times, and a + # block takes precedence over the argument. + # + # @param given_params [Hash, Array] The parameters to add to the + # transaction. + # @yield This block is called when the transaction is sampled. The block's + # return value will become the new parameters. + # @yieldreturn [Hash, Array] + # @return [void] + # + # @see #add_request_payload + def add_query_parameters(given_params = nil, &block) + params_data(:query_parameters).add(given_params, &block) + end + + # Add the query parameters to the transaction if not already set. + # + # @param given_params [Hash, Array] The parameters to add to the + # transaction if none are already set. + # @yield This block is called when the transaction is sampled. The block's + # return value will become the new parameters. + # @yieldreturn [Hash, Array] + # @return [void] + # @!visibility private + # + # @see #add_query_parameters + def add_query_parameters_if_nil(given_params = nil, &block) + add_query_parameters(given_params, &block) if params_unset?(:query_parameters) + end + # Add tags to the transaction. # # When this method is called multiple times, it will merge the tags. @@ -524,14 +720,16 @@ def add_breadcrumb(category, action, message = "", metadata = {}, time = Time.no return end - @breadcrumbs.push( + # The backend owns how breadcrumbs are stored: the agent backend buffers + # them and flushes at completion, the OpenTelemetry backend emits each as a + # span event right away (by completion its target span has finished). + @backend.add_breadcrumb( :time => time.to_i, :category => category, :action => action, :message => message, :metadata => metadata ) - @breadcrumbs = @breadcrumbs.last(BREADCRUMB_LIMIT) end # Set an action name for the transaction. @@ -548,7 +746,7 @@ def set_action(action) return unless action @action = action - @ext.set_action(action) + @backend.set_action(action) end # Set an action name only if there is no current action set. @@ -596,7 +794,7 @@ def set_namespace(namespace) return unless namespace @namespace = namespace - @ext.set_namespace(namespace) + @backend.set_namespace(namespace) end # Set queue start time for transaction. @@ -610,7 +808,7 @@ def set_namespace(namespace) def set_queue_start(start) return unless start - @ext.set_queue_start(start) + @backend.set_queue_start(start) rescue RangeError Appsignal.internal_logger.warn("Queue start value #{start} is too big") end @@ -620,12 +818,47 @@ def set_metadata(key, value) return unless key && value return if Appsignal.config[:filter_metadata].include?(key.to_s) - @ext.set_metadata(key, value) + @backend.set_metadata(key, value) + end + + # Add OpenTelemetry attributes to the span AppSignal is currently + # recording. + # + # In collector mode, AppSignal records a transaction as an OpenTelemetry + # span, and every instrumented event as a child span. This adds attributes + # to whichever of those spans is open right now: the innermost event + # started by {Appsignal::Helpers::Instrumentation#instrument}, or the + # transaction's own span when no event is open. + # + # Use this to describe what is being instrumented in OpenTelemetry's own + # terms, following the OpenTelemetry semantic conventions where they apply. + # Attributes have no equivalent outside collector mode, so this does + # nothing when collector mode is not active. + # + # @example Describing a database query + # Appsignal.instrument("query.my_database") do + # Appsignal::Transaction.current.add_opentelemetry_attributes( + # "db.system.name" => "mysql" + # ) + # run_the_query + # end + # + # @param attributes [Hash, nil] Attributes to add to the + # current span. Values that are not a String, Integer, Float or boolean + # are converted to a String. Nothing is added when this is nil or empty. + # @return [void] + # + # @see https://opentelemetry.io/docs/specs/semconv/ + # OpenTelemetry semantic conventions + def add_opentelemetry_attributes(attributes = {}) + return if attributes.nil? || attributes.empty? + + @backend.set_attributes(attributes) end # @!visibility private # @see Appsignal::Helpers::Instrumentation#report_error - def add_error(error, &block) + def add_error(error, source: nil, &block) unless error.is_a?(Exception) Appsignal.internal_logger.error "Appsignal::Transaction#add_error: Cannot add error. " \ "The given value is not an exception: #{error.inspect}" @@ -635,10 +868,15 @@ def add_error(error, &block) return unless error return unless Appsignal.active? - if error.instance_variable_get(:@__appsignal_error_reported) && !@error_blocks.include?(error) + if error.instance_variable_get(:@__appsignal_error_reported) && !@errors.include?(error) return end + # Wrap the block here, at the entry point, so it stays protected wherever + # it later runs: right away in collector mode, or at completion in agent + # mode. `source` names the helper the block was given to, when known, so + # the log points the customer at the right call. + block = protect(source ? "the block passed to #{source}" : "the error block", &block) if block internal_set_error(error, &block) # Mark errors and their causes as tracked so we don't report duplicates, @@ -653,10 +891,13 @@ def add_error(error, &block) # @!visibility private # @see Helpers::Instrumentation#instrument - def start_event + def start_event(opentelemetry_kind: nil, opentelemetry_scope: nil) return if paused? - @ext.start_event(0) + @backend.start_event( + :opentelemetry_kind => opentelemetry_kind, + :opentelemetry_scope => opentelemetry_scope + ) end # @!visibility private @@ -664,98 +905,280 @@ def start_event def finish_event(name, title, body, body_format = Appsignal::EventFormatter::DEFAULT) return if paused? - @ext.finish_event( + @backend.finish_event( name, title || BLANK, body || BLANK, - body_format || Appsignal::EventFormatter::DEFAULT, - 0 + body_format || Appsignal::EventFormatter::DEFAULT ) end # @!visibility private # @see Helpers::Instrumentation#instrument - def record_event(name, title, body, duration, body_format = Appsignal::EventFormatter::DEFAULT) + def record_event( # rubocop:disable Metrics/ParameterLists + name, + title, + body, + duration, + body_format = Appsignal::EventFormatter::DEFAULT, + opentelemetry_kind: nil, + opentelemetry_scope: nil, + opentelemetry_attributes: nil + ) return if paused? - @ext.record_event( + @backend.record_event( name, title || BLANK, body || BLANK, body_format || Appsignal::EventFormatter::DEFAULT, duration, - 0 + :opentelemetry_kind => opentelemetry_kind, + :opentelemetry_scope => opentelemetry_scope, + :opentelemetry_attributes => opentelemetry_attributes ) end # @!visibility private # @see Helpers::Instrumentation#instrument - def instrument(name, title = nil, body = nil, body_format = Appsignal::EventFormatter::DEFAULT) - start_event + def instrument( # rubocop:disable Metrics/ParameterLists + name, + title = nil, + body = nil, + body_format = Appsignal::EventFormatter::DEFAULT, + opentelemetry_kind: nil, + opentelemetry_scope: nil + ) + start_event( + :opentelemetry_kind => opentelemetry_kind, + :opentelemetry_scope => opentelemetry_scope + ) yield if block_given? + rescue Exception => error + # The block raised, so the operation this event describes failed. Say what + # kind of failure it was, which the OpenTelemetry semantic conventions ask + # for. This runs before the `ensure` below finishes the event, so the + # attribute lands on the event's own span. The error itself is not reported + # here; whatever catches it decides that. + # + # A paused transaction never started an event span, so there would be no + # span of this event's to describe. + unless paused? + add_opentelemetry_attributes( + Appsignal::OpenTelemetry::ErrorType.attributes_for(error.class.name) + ) + end + + raise ensure finish_event(name, title, body, body_format) end # @!visibility private def to_h - JSON.parse(@ext.to_json) + JSON.parse(@backend.to_json) end alias to_hash to_h protected # @!visibility private - attr_writer :is_duplicate, :tags, :custom_data, :breadcrumbs, :params, + attr_writer :is_duplicate, :tags, :custom_data, :params_buckets, :session_data, :headers # @!visibility private def internal_set_error(error, &block) - _set_error(error) if @error_blocks.empty? + is_new_error = !@errors.include?(error) - if !@error_blocks.include?(error) && @error_blocks.length >= ERRORS_LIMIT + if is_new_error && @errors.length >= ERRORS_LIMIT Appsignal.internal_logger.warn "Appsignal::Transaction#add_error: Transaction has more " \ "than #{ERRORS_LIMIT} distinct errors. Only the first " \ "#{ERRORS_LIMIT} distinct errors will be reported." return end - @error_blocks[error] << block - @error_blocks[error].compact! + + if @errors.empty? + _set_error(error) + elsif is_new_error && @backend.supports_multiple_errors? + # Record additional errors immediately so each exception event lands on + # the span current now, not the root span at completion. The agent + # backend instead reports extras as duplicate transactions. + _send_error_to_backend(error) + end + + @errors.add(error) + + if @backend.supports_multiple_errors? + # Collector mode: the error is already recorded, so run its block now + # rather than at completion. Anything a block attaches to the current + # span -- breadcrumbs, nested errors, custom instrumentation -- then + # lands where the error was reported, not on the root span at + # completion. + if block + self.class.with_transaction(self) do + block.call(self) + end + end + else + @error_blocks[error] << block + @error_blocks[error].compact! + end end private - attr_reader :breadcrumbs + # The `SampleData` bucket a logical params channel is stored in, per the + # backend's `params_mapping`. In agent mode every channel resolves to the + # same `:params` bucket (so they merge and share an `_if_nil` guard); in + # collector mode the request payload, function parameters and query + # parameters resolve to separate buckets. `fetch` raises if a backend's + # mapping omits a channel. + def params_data(channel) + @params_buckets.fetch(@params_mapping.fetch(channel)) + end + + # Whether a params channel's bucket has had nothing set yet, so the + # `_if_nil` setters do not overwrite params the caller already provided. + def params_unset?(channel) + bucket = params_data(channel) + !bucket.value? && !bucket.empty? + end + # `add_params`/`set_params` don't say whether the params are a request + # payload or function parameters, so in collector mode they always map to + # the request payload. Warn once per process to nudge callers toward the + # explicit methods. + def warn_params_deprecation + return unless Appsignal.config&.collector_mode? + + Appsignal::Transaction.warn_params_deprecation_once do + Appsignal::Utils::StdoutAndLoggerMessage.warning( + "`add_params`/`set_params` is deprecated in collector mode. Use " \ + "`add_request_payload` or `add_function_parameters` instead, or " \ + "`add_custom_data` for data that is neither." + ) + end + end + + # Wrap a block handed to the transaction by user code so that, wherever it + # later runs, a failure is logged and swallowed instead of breaking the + # transaction lifecycle. A raise would otherwise skip the rest of creation or + # completion, including the backend teardown that detaches the transaction's + # OpenTelemetry context, which would then become the parent of the next + # request's spans on that thread. Re-raising is wrong because the block runs + # far from where its caller defined it. + # + # Returns `nil` when no block is given, so it can wrap an optional block. + def protect(description, &block) + return unless block + + proc do |*args| + block.call(*args) + rescue => error + location = block.source_location&.join(":") || "an unknown location" + Appsignal.internal_logger.error( + "Error in #{description}, defined at #{location}: " \ + "#{error.class}: #{error.message}\n#{error.backtrace&.join("\n")}" + ) + end + end + + # Hooks are registered both as blocks and as method objects pushed onto the + # set directly, so there is no single entry point to wrap them at. They are + # protected here instead, at the one place that runs all of them. def run_after_create_hooks self.class.after_create.each do |block| - block.call(self) + protect("the after_create hook", &block).call(self) end end def run_before_complete_hooks self.class.before_complete.each do |block| - block.call(self, @error_set) + protect("the before_complete hook", &block).call(self, @error_set) + end + end + + # Reports the errors stored on the transaction at completion. + # + # In eager (collector) mode nothing is left to do: each error was recorded + # and its block run when the error was added. In deferred (agent) mode the + # extension holds a single error, so the primary error's blocks run on this + # transaction and every additional error is reported as a duplicate + # transaction. + def report_errors + return if @backend.supports_multiple_errors? + + report_errors_as_duplicates + end + + # Agent-only legacy path. The extension transaction holds a single error, so + # extra errors are reported as duplicate transactions. This whole method + # disappears once agent mode is dropped: collector mode records every error + # eagerly and leaves nothing to do at completion. + def report_errors_as_duplicates + @errors.each do |error| + # Ignore the error that is already set in this transaction. + next if error == @error_set + + duplicate.tap do |transaction| + # In the duplicate transaction for each error, set an error + # with a block that calls all the blocks set for that error + # in the original transaction. Those blocks were already wrapped + # when they were added, so they are called directly here. + transaction.internal_set_error(error) do + @error_blocks[error].each do |block| + block.call(transaction) + end + end + + transaction.complete + end + end + + return unless @error_set && @error_blocks[@error_set].any? + + self.class.with_transaction(self) do + @error_blocks[@error_set].each do |block| + block.call(self) + end end end def _set_error(error) - backtrace = cleaned_backtrace(error.backtrace) - @ext.set_error( + @error_set = error + _send_error_to_backend(error) + end + + # Records an error on the backend. The cause chain is walked once into + # neutral data ({name, message, backtrace}); each backend projects what it + # needs -- the agent's first-line `error_causes` sample data, or the + # OpenTelemetry `appsignal.error_causes` attribute. Called for the first + # error and, in collector mode, for each additional error as it is added. + def _send_error_to_backend(error) + causes, root_cause_missing = _error_causes(error) + @backend.set_error( error.class.name, cleaned_error_message(error), - backtrace ? Appsignal::Utils::Data.generate(backtrace) : Appsignal::Extension.data_array_new + cleaned_backtrace(error.backtrace), + causes.map do |cause| + { + :name => cause.class.name, + :message => cleaned_error_message(cause), + :backtrace => cleaned_backtrace(cause.backtrace) + } + end, + root_cause_missing ) - @error_set = error + end + # Walks the `error.cause` chain (without mutating `error`), collecting up to + # `ERROR_CAUSES_LIMIT` causes. Returns the causes and whether the chain was + # truncated (the root cause is missing). + def _error_causes(error) root_cause_missing = false - causes = [] - while error - error = error.cause - - break unless error - + cause = error + while (cause = cause.cause) if causes.length >= ERROR_CAUSES_LIMIT Appsignal.internal_logger.debug "Appsignal::Transaction#add_error: Error has more " \ "than #{ERROR_CAUSES_LIMIT} error causes. Only the first #{ERROR_CAUSES_LIMIT} " \ @@ -764,55 +1187,10 @@ def _set_error(error) break end - causes << error + causes << cause end - causes_sample_data = causes.map do |e| - { - :name => e.class.name, - :message => cleaned_error_message(e), - :first_line => first_formatted_backtrace_line(e) - } - end - - causes_sample_data.last[:is_root_cause] = false if root_cause_missing - - set_sample_data( - "error_causes", - causes_sample_data - ) - end - - BACKTRACE_REGEX = - %r{(?[\w-]+ \(.+\) )?(?:?/?\w+?.+?):(?:?\d+)(?::in `(?.+)')?$}.freeze - private_constant :BACKTRACE_REGEX - - def first_formatted_backtrace_line(error) - backtrace = cleaned_backtrace(error.backtrace) - first_line = backtrace&.first - return unless first_line - - captures = BACKTRACE_REGEX.match(first_line) - return unless captures - - captures.named_captures - .merge("original" => first_line) - .tap do |c| - config = Appsignal.config - # Strip of whitespace at the end of the gem name - c["gem"] = c["gem"]&.strip - # Strip the app path from the path if present - root_path = config.root_path - if c["path"].start_with?(root_path) - c["path"].delete_prefix!(root_path) - # Relative paths shouldn't start with a slash - c["path"].delete_prefix!("/") - end - # Add revision for linking to the repository from the UI - c["revision"] = config[:revision] - # Convert line number to an integer - c["line"] = c["line"].to_i - end + [causes, root_cause_missing] end def set_sample_data(key, data) @@ -825,10 +1203,11 @@ def set_sample_data(key, data) return end - @ext.set_sample_data( - key.to_s, - Appsignal::Utils::Data.generate(data) - ) + # Pass raw Ruby through to the backend. ExtensionBackend serializes to a + # C-extension `Data` object; OpenTelemetryBackend reads the Hash/Array + # directly. The `RuntimeError` rescue still covers ExtensionBackend's + # `Data.generate`, which now runs inside the backend call. + @backend.set_sample_data(key.to_s, data) rescue RuntimeError => e begin inspected_data = data.inspect @@ -843,15 +1222,21 @@ def set_sample_data(key, data) end def sample_data - { - :params => sanitized_params, + data = { :environment => sanitized_request_headers, :session_data => sanitized_session_data, :tags => sanitized_tags, - :breadcrumbs => breadcrumbs, :custom_data => custom_data - }.each do |key, data| - set_sample_data(key, data) + } + # Each params bucket is emitted under its own key. The extension backend + # has a single `:params` bucket; the OpenTelemetry backend has separate + # `:request_payload` and `:function_parameters` buckets. The backend maps + # each key to its storage (C-extension slot or OpenTelemetry attribute). + @params_buckets.each do |bucket, sample| + data[bucket] = sanitized_params(sample) + end + data.each do |key, value| + set_sample_data(key, value) end end @@ -860,30 +1245,38 @@ def duplicate self.class.new( namespace, :id => new_transaction_id, - :ext => @ext.duplicate(new_transaction_id) + :backend => @backend.duplicate(new_transaction_id) ).tap do |transaction| transaction.is_duplicate = true transaction.tags = @tags.dup transaction.custom_data = @custom_data.dup - transaction.breadcrumbs = @breadcrumbs.dup - transaction.params = @params.dup + transaction.params_buckets = @params_buckets.transform_values(&:dup) transaction.session_data = @session_data.dup transaction.headers = @headers.dup end end def params - @params.value - rescue => e - Appsignal.internal_logger.error("Exception while fetching params: #{e.class}: #{e}") - nil + params_value(params_data(:params)) end - def sanitized_params + def sanitized_params(sample = params_data(:params)) return unless Appsignal.config[:send_params] filter_keys = Appsignal.config[:filter_parameters] || [] - Appsignal::Utils::SampleDataSanitizer.sanitize(params, filter_keys) + Appsignal::Utils::SampleDataSanitizer.sanitize(params_value(sample), filter_keys) + end + + # Reads a params bucket's value. Evaluating it runs any block the caller + # passed to `add_params`/`add_request_payload`/`add_function_parameters`, + # which is user code that can raise, so a failure is logged and swallowed. + def params_value(sample) + sample.value + rescue => e + Appsignal.internal_logger.error( + "Exception while fetching params (#{sample.key}): #{e.class}: #{e}" + ) + nil end def session_data diff --git a/lib/appsignal/transaction/base_backend.rb b/lib/appsignal/transaction/base_backend.rb new file mode 100644 index 000000000..68f38ec6d --- /dev/null +++ b/lib/appsignal/transaction/base_backend.rb @@ -0,0 +1,107 @@ +# frozen_string_literal: true + +module Appsignal + class Transaction + # @!visibility private + # + # The interface every transaction backend implements. `Appsignal::Backends + # .transaction` picks a concrete backend per mode -- ExtensionBackend in + # agent mode, OpenTelemetryBackend in collector mode. This base documents the + # contract; a backend that leaves a method unimplemented raises here. + class BaseBackend + # Instrumented events. + def start_event(opentelemetry_kind: nil, opentelemetry_scope: nil) + raise NotImplementedError + end + + def finish_event(_name, _title, _body, _body_format) + raise NotImplementedError + end + + def record_event( # rubocop:disable Metrics/ParameterLists + _name, _title, _body, _body_format, _duration, + opentelemetry_kind: nil, opentelemetry_scope: nil + ) + raise NotImplementedError + end + + # Transaction metadata. + def set_action(_action) + raise NotImplementedError + end + + def set_namespace(_namespace) + raise NotImplementedError + end + + def set_metadata(_key, _value) + raise NotImplementedError + end + + def set_queue_start(_start) + raise NotImplementedError + end + + # OpenTelemetry span attributes, set on whichever span the backend is + # currently recording. Only meaningful in collector mode; agent mode has + # no span to hang them on. + def set_attributes(_attributes) + raise NotImplementedError + end + + # Maps each logical params channel (`:params`, `:request_payload`, + # `:function_parameters`) to the storage bucket it lands in. Channels that + # share a bucket merge into one `SampleData` object on the transaction; + # distinct buckets stay separate. The bucket name is also the sample-data + # key `set_sample_data` receives for that bucket, so it must be a key this + # backend's `set_sample_data` knows how to store. + def params_mapping + raise NotImplementedError + end + + # Sample data (params, session, tags, ...), breadcrumbs and errors. + def set_sample_data(_key, _data) + raise NotImplementedError + end + + def add_breadcrumb(_breadcrumb) + raise NotImplementedError + end + + def set_error(_class_name, _message, _backtrace, _causes, _root_cause_missing) + raise NotImplementedError + end + + # Whether the backend can hold more than one error on a single + # transaction. When it can't (agent mode), the Transaction reports the + # extra errors as duplicate transactions instead. + def supports_multiple_errors? + raise NotImplementedError + end + + # Lifecycle. + def finish + raise NotImplementedError + end + + def complete + raise NotImplementedError + end + + def discard + raise NotImplementedError + end + + # Only used when `supports_multiple_errors?` is false (agent mode). + # Backends that support multiple errors never duplicate and leave this + # unimplemented. + def duplicate(_new_transaction_id) + raise NotImplementedError + end + + def to_json # rubocop:disable Lint/ToJSON + raise NotImplementedError + end + end + end +end diff --git a/lib/appsignal/transaction/extension_backend.rb b/lib/appsignal/transaction/extension_backend.rb new file mode 100644 index 000000000..e71d20d0a --- /dev/null +++ b/lib/appsignal/transaction/extension_backend.rb @@ -0,0 +1,203 @@ +# frozen_string_literal: true + +module Appsignal + class Transaction + # @!visibility private + # + # The transaction backend used in agent mode. Wraps a per-transaction + # handle on the C extension (`Appsignal::Extension::Transaction`) and + # forwards every call to it. + # + # In agent mode `Appsignal::Backends.transaction` returns this class; + # `Appsignal::Transaction#initialize` instantiates one and stores it in + # `@backend`. + class ExtensionBackend < BaseBackend + # rubocop:disable Layout/LineLength + BACKTRACE_REGEX = + %r{(?[\w-]+ \(.+\) )?(?:?/?\w+?.+?):(?:?\d+)(?::in `(?.+)')?$}.freeze + # rubocop:enable Layout/LineLength + + # @!visibility private + attr_writer :breadcrumbs + + # The `opentelemetry_*` keyword arguments (context, scope, kind and + # relationship) shape the OpenTelemetry span in collector mode. Agent mode + # has no notion of them, so they are accepted and ignored. They are listed + # explicitly, rather than swallowed with `**`, so an unexpected keyword + # still raises, matching the OpenTelemetry backend. + # rubocop:disable Metrics/ParameterLists, Lint/UnusedMethodArgument + def initialize( + transaction_id, + namespace, + handle: nil, + opentelemetry_context: nil, + opentelemetry_scope: nil, + opentelemetry_kind: nil, + opentelemetry_relationship: nil + ) + super() + @handle = handle || + Appsignal::Extension.start_transaction(transaction_id, namespace, 0) || + Appsignal::Extension::MockTransaction.new + @breadcrumbs = [] + end + # rubocop:enable Metrics/ParameterLists, Lint/UnusedMethodArgument + + # Agent mode has no span kind or instrumentation scope; + # `opentelemetry_kind` and `opentelemetry_scope` are ignored here. + def start_event(opentelemetry_kind: nil, opentelemetry_scope: nil) # rubocop:disable Lint/UnusedMethodArgument + @handle.start_event(0) + end + + def finish_event(name, title, body, body_format) + @handle.finish_event(name, title, body, body_format, 0) + end + + # Agent mode has no span kind or instrumentation scope; + # `opentelemetry_kind` and `opentelemetry_scope` are ignored here. + def record_event(name, title, body, body_format, duration, opentelemetry_kind: nil, opentelemetry_scope: nil, opentelemetry_attributes: nil) # rubocop:disable Lint/UnusedMethodArgument, Metrics/ParameterLists, Layout/LineLength + @handle.record_event(name, title, body, body_format, duration, 0) + end + + def set_action(action) + @handle.set_action(action) + end + + def set_namespace(namespace) + @handle.set_namespace(namespace) + end + + def set_queue_start(start) + @handle.set_queue_start(start) + end + + def set_metadata(key, value) + @handle.set_metadata(key, value) + end + + # Agent mode has no OpenTelemetry spans, so there is nothing to set the + # attributes on. A transaction event has no attribute equivalent in the + # agent protocol either, so they are dropped rather than stored somewhere + # else. + def set_attributes(_attributes) + end + + # The agent has a single params slot, so every params channel maps to one + # `:params` bucket. The transaction merges the channels into it, and only + # the `:params` key ever reaches `set_sample_data`. + PARAMS_MAPPING = { + :params => :params, + :request_payload => :params, + :function_parameters => :params, + :query_parameters => :params + }.freeze + + def params_mapping + PARAMS_MAPPING + end + + # `data` is a raw Ruby Hash/Array; the C extension wants a `Data` object, + # so serialize it here (mirrors how `set_error` serializes its backtrace). + def set_sample_data(key, data) + @handle.set_sample_data(key, Appsignal::Utils::Data.generate(data)) + end + + # Buffer breadcrumbs, keeping the last `BREADCRUMB_LIMIT`, and flush them as + # sample data on completion. + def add_breadcrumb(breadcrumb) + @breadcrumbs.push(breadcrumb) + @breadcrumbs = @breadcrumbs.last(Appsignal::Transaction::BREADCRUMB_LIMIT) + end + + # Serializes the backtrace to a C-extension `Data` object and records the + # error, then flushes the causes as `error_causes` sample data in the + # agent's first-line shape. + def set_error(class_name, message, backtrace, causes, root_cause_missing) + backtrace_data = + if backtrace + Appsignal::Utils::Data.generate(backtrace) + else + Appsignal::Extension.data_array_new + end + @handle.set_error(class_name, message, backtrace_data) + + set_sample_data("error_causes", error_causes_sample_data(causes, root_cause_missing)) + end + + def finish + @handle.finish(0) + end + + def complete + unless @breadcrumbs.empty? + @handle.set_sample_data("breadcrumbs", Appsignal::Utils::Data.generate(@breadcrumbs)) + end + @handle.complete + end + + # Discarding in agent mode drops the transaction: the extension handle is + # simply abandoned and never told to complete, so nothing is sent. There + # is no `ignore_subtrace` concept on the agent path. This mirrors the + # pre-backend behavior, where `Transaction#complete` returned before + # touching the handle on a discarded transaction. + def discard + end + + # The extension transaction holds a single error, so the Transaction + # reports additional errors as duplicate transactions instead. + def supports_multiple_errors? + false + end + + def duplicate(new_transaction_id) + self.class.new( + new_transaction_id, nil, :handle => @handle.duplicate(new_transaction_id) + ).tap { |backend| backend.breadcrumbs = @breadcrumbs.dup } + end + + def to_json # rubocop:disable Lint/ToJSON + @handle.to_json + end + + private + + # Projects the neutral causes to the agent's first-line shape. A truncated + # chain marks its last entry as not the root cause. + def error_causes_sample_data(causes, root_cause_missing) + sample_data = causes.map do |cause| + { + :name => cause[:name], + :message => cause[:message], + :first_line => first_formatted_backtrace_line(cause[:backtrace]) + } + end + sample_data.last[:is_root_cause] = false if root_cause_missing && sample_data.any? + sample_data + end + + # Parses the first backtrace line into the fields the UI links on (gem, + # path, line, method), with the path made relative to the app root. + def first_formatted_backtrace_line(backtrace) + first_line = backtrace&.first + return unless first_line + + captures = BACKTRACE_REGEX.match(first_line) + return unless captures + + captures.named_captures + .merge("original" => first_line) + .tap do |c| + config = Appsignal.config + c["gem"] = c["gem"]&.strip + root_path = config.root_path + if c["path"].start_with?(root_path) + c["path"].delete_prefix!(root_path) + c["path"].delete_prefix!("/") + end + c["revision"] = config[:revision] + c["line"] = c["line"].to_i + end + end + end + end +end diff --git a/lib/appsignal/transaction/opentelemetry_backend.rb b/lib/appsignal/transaction/opentelemetry_backend.rb new file mode 100644 index 000000000..4f0d10ab0 --- /dev/null +++ b/lib/appsignal/transaction/opentelemetry_backend.rb @@ -0,0 +1,796 @@ +# frozen_string_literal: true + +require "json" +require "socket" + +module Appsignal + class Transaction + # @!visibility private + # + # The transaction backend used in collector mode. Emits an OpenTelemetry + # root span for the transaction, a child span per instrumented event, and + # queue timing as a metric. Errors and breadcrumbs attach to whichever span + # is current when they happen. + class OpenTelemetryBackend < BaseBackend + TRACER_NAME = "appsignal-ruby" + + # Guards the process-wide warn-once state, which transactions touch + # concurrently on threaded servers. A constant so it is created once at + # load time rather than lazily (which would race). + WARN_ONCE_LOCK = Mutex.new + + class << self + # Logs the block's message the first time it sees `key`, then stays quiet + # for that key for the rest of the process. Used for warnings that would + # otherwise repeat on every transaction. The message is built lazily, so + # a deduplicated call skips building it. The check-and-set is locked so + # concurrent transactions cannot both warn. + def warn_once(key) + first_time = WARN_ONCE_LOCK.synchronize do + next false if warned_keys.key?(key) + + warned_keys[key] = true + end + Appsignal.internal_logger.warn(yield) if first_time + end + + # @!visibility private + # Resets the warn-once state. Only used to keep test runs isolated. + def clear_warned! + WARN_ONCE_LOCK.synchronize { warned_keys.clear } + end + + private + + def warned_keys + @warned_keys ||= {} + end + end + + # Collector treats SERVER/CONSUMER spans as subtrace roots; SERVER is the + # safe default when no kind is given (a transaction is almost always an + # external-triggered unit of work). + DEFAULT_SPAN_KIND = :server + + # The span kinds a transaction may take. An unknown value would raise + # inside OpenTelemetry span creation, so it falls back to the default. + SPAN_KINDS = [:server, :consumer, :producer, :internal].freeze + + # How the transaction's span relates to an incoming OpenTelemetry context + # when none is given. A web request continues the upstream trace, so + # parenting is the safe default. + DEFAULT_RELATIONSHIP = :parent + + # How the transaction's span may relate to an incoming context. An unknown + # value would silently behave like `:none`, so it falls back to the + # default instead. + RELATIONSHIPS = [:parent, :link, :both, :none].freeze + + # The collector expects "web"/"background"; the agent's processor converts + # these internal namespaces in agent mode, but nothing does in collector + # mode. Other namespaces pass through unchanged. + DISPLAY_NAMESPACE = { + "http_request" => "web", + "background_job" => "background" + }.freeze + + # Placeholder name an event span carries between `start_event` and + # `finish_event`. `finish_event` overwrites it with the AS::N event name, + # so it only surfaces when `complete` has to drain a span that was started + # but never finished. It is deliberately an obvious placeholder rather + # than a plausible event name, so such a span reads as the unfinished + # event it is and is not mistaken for a real one. + EVENT_SPAN_PLACEHOLDER_NAME = "[unfinished transaction event]" + + # Sentinel value the AppSignal collector recognizes as "a SQL system + # we don't know the specific dialect of" — sufficient to trigger SQL + # sanitization on `db.query.text`. + SQL_DB_SYSTEM = "other_sql" + + # Epoch-ms floor (~year 2000) below which a queue start is ignored. Mirrors + # the agent's `set_queue_start` (ext `transaction.rs`), which only records a + # queue duration when `queue_start_ms > 946_681_200_000`. + QUEUE_START_MIN = 946_681_200_000 + + # One open event on the event stack. Holds the OpenTelemetry span and the + # context token attached for it, plus the allocation bookkeeping for the + # event. `allocation_start` is the allocation counter when the event began + # (nil when allocation tracking is off), and `child_allocation_count` + # accumulates the full allocation counts of the event's finished children, + # so the event's own allocations are `full - child_allocation_count`. + # `db_system_name_set` tracks whether the event itself already set + # `db.system.name` (through `set_attributes`) before it finishes, so the + # SQL sentinel written at finish only fills in a value that is missing. + EventFrame = Struct.new( + :span, :token, :allocation_start, :child_allocation_count, + :db_system_name_set + ) + + def initialize( # rubocop:disable Metrics/ParameterLists + transaction_id, + namespace, + opentelemetry_context: nil, + opentelemetry_scope: nil, + opentelemetry_kind: nil, + opentelemetry_relationship: nil + ) + super() + @transaction_id = transaction_id + @namespace = namespace + @scope = opentelemetry_scope + @completed = false + @event_stack = [] + @breadcrumb_count = 0 + @queue_start = nil + @start_time = Time.now + @action_set = false + @action = nil + @allocation_start = current_allocation_count + @root_child_allocation_count = 0 + + kind = validated_option( + opentelemetry_kind, SPAN_KINDS, DEFAULT_SPAN_KIND, "opentelemetry_kind" + ) + relationship = validated_option( + opentelemetry_relationship, RELATIONSHIPS, DEFAULT_RELATIONSHIP, + "opentelemetry_relationship" + ) + @span = start_transaction_span(namespace, kind, relationship, opentelemetry_context) + @context_token = ::OpenTelemetry::Context.attach( + ::OpenTelemetry::Trace.context_with_span(@span) + ) + + # Transaction#initialize sets the namespace directly without calling + # set_namespace, so emit the attribute from here. + @span.set_attribute("appsignal.namespace", display_namespace(namespace)) if namespace + end + + # `opentelemetry_kind` (e.g. `:client` for an outgoing HTTP request) is set + # at span creation because OTel span kind is immutable afterwards. `nil` + # leaves the SDK default (INTERNAL). + def start_event(opentelemetry_kind: nil, opentelemetry_scope: nil) + span = tracer_for(opentelemetry_scope) + .start_span(EVENT_SPAN_PLACEHOLDER_NAME, :kind => opentelemetry_kind) + token = ::OpenTelemetry::Context.attach( + ::OpenTelemetry::Trace.context_with_span(span) + ) + push_event(span, token) + end + + def finish_event(name, title, body, body_format) + return if @event_stack.empty? + + frame = @event_stack.pop + write_event_span_name(frame.span, name, title) + write_event_body_attributes(frame.span, body, body_format, frame.db_system_name_set) + write_event_allocation_count(frame) + ::OpenTelemetry::Context.detach(frame.token) + frame.span.finish + end + + # `opentelemetry_kind` is set at span creation (kind is immutable in OTel), + # mirroring `start_event`. `nil` leaves the SDK default (INTERNAL). + def record_event( # rubocop:disable Metrics/ParameterLists + name, title, body, body_format, duration, + opentelemetry_kind: nil, opentelemetry_scope: nil, opentelemetry_attributes: nil + ) + start_time = Time.now - (duration / 1_000_000_000.0) + span = tracer_for(opentelemetry_scope).start_span( + EVENT_SPAN_PLACEHOLDER_NAME, + :start_timestamp => start_time, + :kind => opentelemetry_kind + ) + write_event_span_name(span, name, title) + # A recorded event has no window between start and finish, so this is + # its only chance to attach attributes -- there is no event frame for + # a later `set_attributes` call to note a `db.system.name` on. + formatted_attributes = Appsignal::OpenTelemetry::Attributes.format( + opentelemetry_attributes || {} + ) + span.add_attributes(formatted_attributes) unless formatted_attributes.empty? + write_event_body_attributes( + span, body, body_format, named_db_system?(formatted_attributes) + ) + # A recorded event has no start hook, so we never measured its + # allocations. We deliberately set no allocation attribute rather than a + # misleading zero. Its allocations instead fall into the enclosing + # event's own count, matching agent mode. + span.finish + end + + def set_action(action) + # The collector reads the action from `appsignal.action_name`, not the + # span name. Set the name too so the OTel-native trace stays readable; + # the collector treats the span name as authoritative for display. + @span.name = action + @span.set_attribute("appsignal.action_name", action) + @action = action + @action_set = true + end + + def set_namespace(namespace) + # Only the attribute can change here: SpanKind is fixed at span + # creation (immutable in OTel) from the initial namespace. A later + # namespace override updates `appsignal.namespace` but not the kind -- + # the collector uses the attribute for the namespace and the kind only + # to pick the subtrace root, so this is fine for the rare late change. + @namespace = namespace + @span.set_attribute("appsignal.namespace", display_namespace(namespace)) + end + + # Queue start has no OTel-native home, so surface it two ways: an + # `appsignal.queue_start` event on the root span (per-trace timeline) and, + # at completion, a `transaction_queue_duration` metric (the aggregate + # graph). Like the agent, we record the delta and never shift span timing. + # + # The queue start time becomes the event's timestamp (events carry their + # own time), so it is not duplicated as an attribute. + def set_queue_start(start) + return unless start && start > QUEUE_START_MIN + + @queue_start = start + @span.add_event( + "appsignal.queue_start", + :timestamp => Time.at(start / 1000.0) + ) + end + + # Transaction metadata (request path, method, ...) has no dedicated OTel + # attribute, but it is the same shape as tags and the collector/trace UI + # already surface `appsignal.tag.*`, so emit metadata as a tag. + def set_metadata(key, value) + @span.set_attribute("appsignal.tag.#{key}", value) + end + + # Sets OpenTelemetry attributes on AppSignal's current span -- the open + # event span, or the root span when no event is open. This is how an + # integration describes what it instrumented in OpenTelemetry's own terms, + # such as `db.system.name` on a database query. + # + # Never the OTel current span, which may belong to another + # instrumentation. Values are coerced to the primitives OTLP accepts. + def set_attributes(attributes) + formatted = Appsignal::OpenTelemetry::Attributes.format(attributes) + # Note on the open event frame, if there is one, that this event + # already named a real `db.system.name`, so `write_event_body_attributes` + # knows not to overwrite it with the SQL sentinel when the event + # finishes. + if named_db_system?(formatted) && (frame = @event_stack.last) + frame.db_system_name_set = true + end + current_span.add_attributes(formatted) + end + + # The collector keeps the request payload, the function parameters and the + # query parameters as separate attributes, so each gets its own bucket. + # Legacy `params` has no channel of its own, so it maps to the request + # payload (the web/server default), matching how the span kind defaults to + # `:server`. + PARAMS_MAPPING = { + :params => :request_payload, + :request_payload => :request_payload, + :function_parameters => :function_parameters, + :query_parameters => :query_parameters + }.freeze + + def params_mapping + PARAMS_MAPPING + end + + # Routes each sample-data category to the attribute the collector reads. + # The params arrive on one of three channels: `request_payload` (web), + # `function_parameters` (jobs) and `query_parameters` (a request's query + # string), each its own attribute. The other JSON-blob + # categories (session, custom data) are serialized as JSON; `environment` + # becomes request-header attributes; tags fan out to `appsignal.tag.*`. + # Unknown keys pass through as `appsignal.` JSON so nothing is lost. + # Breadcrumbs never reach here (the backend emits them as span events); + # causes ride on the exception event (see #set_error). + def set_sample_data(key, data) + case key + when "request_payload" + @span.set_attribute("appsignal.request.payload", JSON.generate(data)) + when "function_parameters" + @span.set_attribute("appsignal.function.parameters", JSON.generate(data)) + when "query_parameters" + @span.set_attribute("appsignal.request.query_parameters", JSON.generate(data)) + when "session_data" + @span.set_attribute("appsignal.request.session_data", JSON.generate(data)) + when "custom_data" + @span.set_attribute("appsignal.custom_data", JSON.generate(data)) + when "environment" + write_request_headers(data) + when "tags" + write_tags(data) + else + @span.set_attribute("appsignal.#{key}", JSON.generate(data)) + end + end + + # Records the error as an `exception` event on AppSignal's current span -- + # the open event span, or the root -- so it attaches to the operation that + # raised it. `appsignal.alert_this_error` tells the collector to report it + # even on a child span. + # + # Causes ride on one `appsignal.error_causes` JSON attribute, whose keys + # match the processor's `ErrorSubCause`. Separate cause events would each + # become their own incident. Each cause carries only the part of its + # backtrace that is not shared (see `trim_shared_tail`). + def set_error(class_name, message, backtrace, causes, _root_cause_missing) + span = current_span + error_lines = Array(backtrace) + + attributes = { + "exception.type" => class_name, + "exception.message" => message.to_s, + "exception.stacktrace" => error_lines.join("\n"), + "appsignal.alert_this_error" => true + } + + unless causes.empty? + attributes["appsignal.error_causes"] = JSON.generate( + causes.map do |cause| + lines, lines_omitted = trim_shared_tail(Array(cause[:backtrace]), error_lines) + + cause_attributes = { + "name" => cause[:name], + "message" => cause[:message], + "lines" => lines + } + cause_attributes["lines_omitted"] = lines_omitted if lines_omitted.positive? + cause_attributes + end + ) + end + + span.add_event("exception", :attributes => attributes) + # `error.type` is what the semantic conventions read to tell what kind of + # failure ended the operation. It is an attribute of the span, unlike the + # `exception.type` above, which is an attribute of the exception event. + # When a span collects more than one error the last one wins, because a + # span can only say one thing here. + span.add_attributes(Appsignal::OpenTelemetry::ErrorType.attributes_for(class_name)) + span.status = ::OpenTelemetry::Trace::Status.error + end + + # Emits a breadcrumb as an `appsignal.breadcrumb` span event on AppSignal's + # current span -- the open event span, falling back to the root -- rather + # than the OTel current span, which may belong to another instrumentation. + # + # Emitted immediately, because by completion the event span has finished + # and the SDK drops events added to an ended span. The breadcrumb's time + # becomes the event's timestamp, and the metadata Hash is a JSON string, + # because event attributes are flat. + # + # Capped at `BREADCRUMB_LIMIT` per transaction, keeping the first N where + # agent mode keeps the last N: a streamed event cannot be retracted. + def add_breadcrumb(breadcrumb) + return if @breadcrumb_count >= Appsignal::Transaction::BREADCRUMB_LIMIT + + @breadcrumb_count += 1 + current_span.add_event( + "appsignal.breadcrumb", + :timestamp => Time.at(breadcrumb[:time]), + :attributes => { + "category" => breadcrumb[:category], + "action" => breadcrumb[:action], + "message" => breadcrumb[:message], + "metadata" => JSON.generate(breadcrumb[:metadata] || {}) + } + ) + end + + # Returns `true` so `Transaction#complete` runs `sample_data`, flushing the + # params/session/custom-data/tags/etc. onto the still-open root span before + # `complete` finishes it. The OTel SDK makes its own sampling decision; the + # gem always populates the span. + def finish + true + end + + def complete + # `teardown` sets `@completed`, so this guard also makes the body + # idempotent across a double `complete`, and skips it on `discard`. + unless @completed + # Aggregate metrics are only emitted for a transaction that set an + # action to group by. An actionless transaction is never reported in + # agent mode, so it must contribute to no aggregate here either. + emit_queue_duration_metric if @action_set + report_allocation_count + ignore_subtrace_without_action + end + teardown + end + + # Discarding does not mean "don't send" as it does in agent mode. The root + # span is still finished and exported, flagged with + # `appsignal.ignore_subtrace` so the collector drops the whole subtrace. + # The flag has to be written before the span finishes, because attributes + # set on an ended span are dropped. Tearing the span down here also + # detaches the context, so a discarded transaction cannot leave its root + # span current on the thread. + def discard + return if @completed + + @span&.set_attribute("appsignal.ignore_subtrace", true) + teardown + end + + # Each error is recorded eagerly as its own `exception` event on the span + # current when it was added, so a trace holds many errors and the + # Transaction never duplicates itself -- which is why `duplicate` is left + # unimplemented (see BaseBackend). + def supports_multiple_errors? + true + end + + # Returned so `Transaction#to_h` (`JSON.parse(@backend.to_json)`) yields an + # empty Hash. Collector mode asserts on emitted spans, not `to_h`. + def to_json # rubocop:disable Lint/ToJSON + "{}" + end + + private + + # Detaches the OTel context and finishes the root span. Idempotent: the + # Transaction can complete directly and again via a cleanup path, and + # re-detaching/re-finishing an ended span would error. + def teardown + return if @completed + + @completed = true + # Release any event span left unfinished by an aborted flow, so the + # root context can detach in LIFO order. + until @event_stack.empty? + frame = @event_stack.pop + ::OpenTelemetry::Context.detach(frame.token) + frame.span.finish + end + ::OpenTelemetry::Context.detach(@context_token) if @context_token + @span&.finish + end + + # A transaction that never set an action has nothing to group by, and agent + # mode does not report one at all. Collector mode cannot represent "no + # action", so the subtrace is flagged for the collector to drop instead, + # the same way `discard` does. The flag has to be set before `teardown` + # finishes the span, because attributes set on an ended span are dropped. + def ignore_subtrace_without_action + return if @action_set + + @span&.set_attribute("appsignal.ignore_subtrace", true) + end + + # Emits the queue duration as a distribution metric in both the + # per-namespace and per-namespace-and-host series the queue-time graph + # reads. Nothing downstream fans these out, so emit both ourselves. + def emit_queue_duration_metric + return unless @queue_start + + duration_ms = (@start_time.to_f * 1000) - @queue_start + return if duration_ms.negative? + + namespace = display_namespace(@namespace) + Appsignal::Metrics::OpenTelemetryBackend.add_distribution_value( + "transaction_queue_duration", duration_ms, :namespace => namespace + ) + Appsignal::Metrics::OpenTelemetryBackend.add_distribution_value( + "transaction_queue_duration", duration_ms, + :namespace => namespace, :hostname => hostname + ) + end + + # Sets the transaction's allocation counts on the root span, and, when the + # transaction has an action to group by, emits the total as a counter + # metric. The counter is read once so the attributes and the metric share + # the same value. + # + # The root's total is `appsignal.transaction_allocation_count`, named apart + # from an event's `appsignal.allocation_count` because it resets per + # transaction: it is the whole that a span's `self_allocation_count` is a + # part of, including across a distributed trace. + # + # The metric is emitted in both the per-namespace and + # per-namespace-and-action series the allocation graph reads, as a counter, + # never host-tagged. Nothing downstream fans these out. + def report_allocation_count + return unless @allocation_start + + count = Appsignal::Extension.allocation_count - @allocation_start + return if allocation_count_reversed?(count) + + @span&.set_attribute("appsignal.transaction_allocation_count", count) + @span&.set_attribute( + "appsignal.self_allocation_count", + count - @root_child_allocation_count + ) + + return unless @action_set && count.positive? + + namespace = display_namespace(@namespace) + Appsignal::Metrics::OpenTelemetryBackend.increment_counter( + "transaction_allocation_count", count, :namespace => namespace + ) + Appsignal::Metrics::OpenTelemetryBackend.increment_counter( + "transaction_allocation_count", count, + :namespace => namespace, :action => @action + ) + end + + # Sets a finished event's allocation counts and rolls its full count up to + # its parent. `appsignal.allocation_count` covers the event's whole + # subtree; `appsignal.self_allocation_count` excludes its children, so + # allocations can be attributed to a layer without walking the span tree. + # + # Only the immediate parent is updated, because each event's full count + # already includes its whole subtree. Nothing is set when allocation + # tracking is off. + def write_event_allocation_count(frame) + return unless frame.allocation_start + + full = Appsignal::Extension.allocation_count - frame.allocation_start + return if allocation_count_reversed?(full) + + self_count = full - frame.child_allocation_count + # Roll the full count up to the parent so it can compute its own self. + # A top-level event has no parent event; its full count belongs to the + # transaction, so credit the root accumulator instead. + if (parent = @event_stack.last) + parent.child_allocation_count += full + else + @root_child_allocation_count += full + end + frame.span.set_attribute("appsignal.allocation_count", full) + frame.span.set_attribute("appsignal.self_allocation_count", self_count) + end + + # The allocation counter is thread-local and only ever increases, so a + # negative delta means the transaction or event finished on a different + # thread than it started on. The count is then meaningless, so warn and + # tell the caller to drop it rather than report a wrong value. + def allocation_count_reversed?(delta) + return false unless delta.negative? + + Appsignal.internal_logger.warn( + "Not reporting an allocation count in transaction " \ + "'#{@transaction_id}'. The thread-local allocation counter decreased " \ + "between the start and finish, which happens when the work starts and " \ + "finishes on different threads." + ) + true + end + + # The thread's cumulative object allocation count, or nil when allocation + # tracking is off. Callers snapshot this at a start boundary and subtract + # it from a later read to get the allocations made in between; a nil + # snapshot disables allocation reporting for that transaction or event. + def current_allocation_count + Appsignal::Extension.allocation_count if allocation_tracking? + end + + # Allocation tracking runs only when enabled by config and not on JRuby, + # matching the condition under which `Appsignal.start` installs the + # allocation event hook that feeds the counter. + def allocation_tracking? + return false unless Appsignal.config&.[](:enable_allocation_tracking) + + !Appsignal::System.jruby? + end + + def hostname + Appsignal.config&.[](:hostname) || Socket.gethostname + end + + # Resolve the tracer for an instrumentation scope. `scope` is a + # `[name, version]` pair supplied by the integration that created the + # span, or nil. A nil scope, a nil/blank name, or a nil version each fall + # back to the default AppSignal scope, so every span always carries a + # scope (the collector drops scope-less spans). The tracer provider caches + # tracers by `(name, version)`, so this resolves rather than rebuilds. + def tracer_for(scope) + name, version = scope + if name.nil? || name.to_s.empty? + # A nil scope or one with a blank name is unusable, so fall back to + # the default scope entirely rather than pairing the default name with + # a stray version. + name = TRACER_NAME + version = Appsignal::VERSION + else + version ||= Appsignal::VERSION + end + ::OpenTelemetry.tracer_provider.tracer(name, version) + end + + # The open event span, or the root span when no event is open. Not the OTel + # current span, which may belong to another instrumentation. + def current_span + @event_stack.last&.span || @span + end + + # Pushes an open event onto the stack, snapshotting the allocation counter + # so `finish_event` can measure the event's allocations as the delta since. + def push_event(span, token) + @event_stack.push(EventFrame.new(span, token, current_allocation_count, 0)) + end + + def placeholder_span_name(namespace) + "appsignal.transaction #{namespace}" + end + + # Open the transaction's span, relating it to any incoming trace context + # by the requested relationship: + # + # - `:parent`: parent under the remote span so the transaction continues + # the upstream trace. + # - `:link`: start a fresh trace linked back to the remote span. The + # transaction is its own unit of work decoupled from the caller, so it + # gets its own trace, with a link recording the causal relationship. + # - `:both`: parent under the remote span and also link back to it, so the + # transaction continues the trace and keeps the explicit link. + # - `:none`: a plain root span that ignores any incoming context. + # + # With no context or an invalid remote span, every relationship falls back + # to a plain root span, since there is nothing to parent or link to. + def start_transaction_span(namespace, kind, relationship, opentelemetry_context) + name = placeholder_span_name(namespace) + remote = remote_span_context(opentelemetry_context) + tracer = tracer_for(@scope) + + # With no incoming context (or an invalid remote span) there is nothing + # to parent or link to, so any relationship is just a plain root span. + return tracer.start_root_span(name, :kind => kind) unless remote + + # `:parent` and `:both` continue the trace under the remote span; + # `:link` and `:both` record a link back to it; `:none` does neither. + parent = opentelemetry_context if [:parent, :both].include?(relationship) + links = [::OpenTelemetry::Trace::Link.new(remote)] if [:link, :both].include?(relationship) + + if parent + tracer.start_span(name, :with_parent => parent, :kind => kind, :links => links) + else + tracer.start_root_span(name, :kind => kind, :links => links) + end + end + + # Returns the given option when it is one of the allowed values, the + # default when it is nil, or the default with a warning when it is an + # unknown value. Keeps an unexpected `opentelemetry_kind` from raising + # inside span creation, and an unexpected `opentelemetry_relationship` + # from silently dropping the incoming context. + def validated_option(value, allowed, default, name) + return default if value.nil? + return value if allowed.include?(value) + + # A bad value is usually a static mistake passed on every transaction, + # so warn once per process to avoid flooding the log. Dedup on the + # option and value, and build the message -- including walking the + # stack for the caller location -- only when actually warning. + self.class.warn_once("#{name}: #{value.inspect}") do + "Unknown #{name} #{value.inspect} passed at #{option_caller_location}, " \ + "falling back to #{default.inspect}. " \ + "Expected one of: #{allowed.map(&:inspect).join(", ")}." + end + default + end + + # The first caller frame outside the gem: where the invalid value was + # passed to `Transaction.create`, `Appsignal.monitor`, etc. Falls back to + # the immediate caller if every frame is inside the gem. Only walks the + # stack when a warning is actually emitted (see `validated_option`). + def option_caller_location + frames = caller + frames.find { |frame| !frame.include?("/lib/appsignal/") } || frames.first + end + + # The remote parent's SpanContext from an incoming OTel context, or nil + # when there is no context or the remote span is invalid -- in which case + # callers fall back to a plain root span. + def remote_span_context(opentelemetry_context) + return unless opentelemetry_context + + context = ::OpenTelemetry::Trace.current_span(opentelemetry_context).context + context if context.valid? + end + + def display_namespace(namespace) + DISPLAY_NAMESPACE.fetch(namespace, namespace) + end + + # The transaction's "environment" sample data is a Rack/CGI env allowlist + # mixing true HTTP headers (HTTP_*, plus CONTENT_LENGTH/CONTENT_TYPE) with + # non-header CGI vars (REQUEST_METHOD, REQUEST_PATH, PATH_INFO, SERVER_*). + # Only the true headers map to the OTel `http.request.header.*` convention + # the collector and trace UI read, so emit those (normalized to lowercase, + # dashed header names) and drop everything else. + def write_request_headers(headers) + headers.each do |key, value| + name = otel_header_name(key) + @span.set_attribute("http.request.header.#{name}", value.to_s) if name + end + end + + def otel_header_name(env_key) + if env_key.start_with?("HTTP_") + env_key.delete_prefix("HTTP_").downcase.tr("_", "-") + elsif env_key.start_with?("CONTENT_") + env_key.downcase.tr("_", "-") + end + end + + # Each tag becomes its own `appsignal.tag.` attribute, which the + # collector hoists and the trace UI lists under "Tags". `sanitized_tags` + # already restricts values to String/Symbol/Integer/boolean; OTel + # attribute values must be primitives, so coerce the Symbol case to a + # string (the only non-primitive that survives sanitization). + def write_tags(tags) + tags.each do |key, value| + value = value.to_s if value.is_a?(Symbol) + @span.set_attribute("appsignal.tag.#{key}", value) + end + end + + # Returns the leading lines of a cause's backtrace that the reported + # error's backtrace does not already end with, and how many trailing lines + # were dropped to get there. + # + # A cause shares its trailing frames with the error it led to, and those + # are already sent in `exception.stacktrace`. Repeating them for every + # cause makes `appsignal.error_causes` too long for the collector to read. + # + # If every line is shared, one is kept, because a cause with no lines + # leaves the UI nothing to show. That kept line does not count as dropped. + def trim_shared_tail(cause_lines, error_lines) + shared = 0 + while shared < cause_lines.length && shared < error_lines.length && + cause_lines[-1 - shared] == error_lines[-1 - shared] + shared += 1 + end + + return [cause_lines, 0] if shared.zero? + + kept = [cause_lines.length - shared, 1].max + [cause_lines.first(kept), cause_lines.length - kept] + end + + # The OTel span name is what the collector surfaces as the event's + # label in the trace UI. The AS::N `name` (e.g. "sql.active_record") + # always leads the span name so it stays visible. When a formatter + # supplied a human-readable `title` (e.g. "User Load", "GET + # https://example.com"), it follows in parentheses, giving + # "sql.active_record (User Load)". Some integrations pass the event + # name as the title as well; in that case the name is not repeated. + def write_event_span_name(span, name, title) + has_title = title && !title.empty? && title != name + span.name = has_title ? "#{name} (#{title})" : name + end + + def write_event_body_attributes(span, body, body_format, db_system_name_set) + has_body = !body.to_s.empty? + + if body_format == Appsignal::EventFormatter::SQL_BODY_FORMAT + # Name the datastore whether or not there is a query to record with it. + # The semantic conventions require the attribute on every database + # span, and a SQL event with nothing in its body is still a SQL event. + # Only fall back to the sentinel when nothing set a real engine name + # earlier in the event, so an integration's own `db.system.name` + # always wins over it. + span.set_attribute("db.system.name", SQL_DB_SYSTEM) unless db_system_name_set + span.set_attribute("db.query.text", body) if has_body + elsif has_body + span.set_attribute("appsignal.body", body) + end + end + + # Whether a formatted attributes hash names a real `db.system.name`, + # as opposed to merely having the key. `Attributes.format` coerces an + # explicit `nil` (or any other non-primitive) to `""`, so the key can + # be present with a blank value. A blank value must not count as set: + # it would block the SQL sentinel the same way a real value should, + # but leave the span with nothing the collector's sanitizer + # recognizes, instead of the sentinel that keeps sanitization on. + def named_db_system?(formatted_attributes) + !formatted_attributes["db.system.name"].to_s.empty? + end + end + end +end diff --git a/lib/appsignal/utils/stdout_and_logger_message.rb b/lib/appsignal/utils/stdout_and_logger_message.rb index f26bcf2d8..e0c94958c 100644 --- a/lib/appsignal/utils/stdout_and_logger_message.rb +++ b/lib/appsignal/utils/stdout_and_logger_message.rb @@ -8,9 +8,18 @@ def self.warning(message, logger = Appsignal.internal_logger) logger.warn message end + def self.error(message, logger = Appsignal.internal_logger) + Kernel.warn "appsignal ERROR: #{message}" + logger.error message + end + def stdout_and_logger_warning(message, logger = Appsignal.internal_logger) Appsignal::Utils::StdoutAndLoggerMessage.warning(message, logger) end + + def stdout_and_logger_error(message, logger = Appsignal.internal_logger) + Appsignal::Utils::StdoutAndLoggerMessage.error(message, logger) + end end end end diff --git a/mono.yml b/mono.yml index 7999f97e8..d226a70c6 100644 --- a/mono.yml +++ b/mono.yml @@ -1,6 +1,10 @@ --- language: ruby repo: "https://github.com/appsignal/appsignal-ruby" +packages: + appsignal: "." + appsignal-opentelemetry: "packages/opentelemetry" +version_lock: true bootstrap: post: - "rake extension:install" diff --git a/packages/opentelemetry/.changesets/.gitkeep b/packages/opentelemetry/.changesets/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/packages/opentelemetry/.changesets/add-appsignal-opentelemetry-gem.md b/packages/opentelemetry/.changesets/add-appsignal-opentelemetry-gem.md new file mode 100644 index 000000000..05a91c4c3 --- /dev/null +++ b/packages/opentelemetry/.changesets/add-appsignal-opentelemetry-gem.md @@ -0,0 +1,13 @@ +--- +bump: major +type: add +--- + +Add the `appsignal-opentelemetry` gem. It installs the OpenTelemetry gems that AppSignal needs to run in collector mode. Add it alongside `appsignal` to opt into collector mode with a single gem instead of listing each OpenTelemetry gem yourself: + +```ruby +gem "appsignal" +gem "appsignal-opentelemetry" +``` + +Collector mode requires Ruby 3.1 or newer, so this gem does too. Its version stays in lockstep with the `appsignal` gem. diff --git a/packages/opentelemetry/CHANGELOG.md b/packages/opentelemetry/CHANGELOG.md new file mode 100644 index 000000000..d2d231d64 --- /dev/null +++ b/packages/opentelemetry/CHANGELOG.md @@ -0,0 +1 @@ +# AppSignal OpenTelemetry gem Changelog diff --git a/packages/opentelemetry/Gemfile b/packages/opentelemetry/Gemfile new file mode 100644 index 000000000..ae25e99af --- /dev/null +++ b/packages/opentelemetry/Gemfile @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +source "https://rubygems.org" + +# Build against the appsignal gem in this repository, not a released version, +# so the two are always tested together. +gem "appsignal", :path => "../.." + +gemspec + +gem "rake", ">= 12" +gem "rspec", "~> 3.8" +gem "rubocop", "~> 1.87.0" diff --git a/packages/opentelemetry/README.md b/packages/opentelemetry/README.md new file mode 100644 index 000000000..4ab9b693e --- /dev/null +++ b/packages/opentelemetry/README.md @@ -0,0 +1,18 @@ +# AppSignal OpenTelemetry gem + +A companion gem for [`appsignal`](https://github.com/appsignal/appsignal-ruby). +It installs the OpenTelemetry gems that AppSignal needs to run in collector +mode, so you can opt into collector mode by adding a single gem to your +`Gemfile`: + +```ruby +gem "appsignal" +gem "appsignal-opentelemetry" +``` + +Collector mode requires Ruby 3.1 or newer. + +The gem itself has no runtime functionality of its own. Its only job is to depend on `appsignal` and +on the right versions of the OpenTelemetry gems. Its version is kept in lockstep +with the `appsignal` gem, so `appsignal-opentelemetry X.Y.Z` always pairs with +`appsignal X.Y.Z`. diff --git a/packages/opentelemetry/Rakefile b/packages/opentelemetry/Rakefile new file mode 100644 index 000000000..33352d654 --- /dev/null +++ b/packages/opentelemetry/Rakefile @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +require "fileutils" +require_relative "lib/appsignal_opentelemetry/version" + +namespace :build do + desc "Build the gem into pkg/" + task :all do + FileUtils.mkdir_p("pkg") + output = "pkg/appsignal-opentelemetry-#{AppsignalOpentelemetry::VERSION}.gem" + sh "gem build appsignal-opentelemetry.gemspec --output #{output}" + end +end + +begin + require "rspec/core/rake_task" + + desc "Run the gem test suite." + RSpec::Core::RakeTask.new(:test) +rescue LoadError + # RSpec is not available in every environment, such as during a bare install. +end diff --git a/packages/opentelemetry/appsignal-opentelemetry.gemspec b/packages/opentelemetry/appsignal-opentelemetry.gemspec new file mode 100644 index 000000000..1f2327552 --- /dev/null +++ b/packages/opentelemetry/appsignal-opentelemetry.gemspec @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +require_relative "lib/appsignal_opentelemetry/version" + +# The OpenTelemetry gems and their minimum versions live in this +# dependency-free file in the main gem, which is the single source of truth +# for the set collector mode requires. +require_relative "../../lib/appsignal/opentelemetry/dependencies" + +Gem::Specification.new do |gem| + gem.name = "appsignal-opentelemetry" + gem.version = AppsignalOpentelemetry::VERSION + gem.authors = [ + "Robert Beekman", + "Thijs Cadier", + "Tom de Bruijn" + ] + gem.email = ["support@appsignal.com"] + gem.summary = "Installs the OpenTelemetry gems AppSignal collector mode needs" + gem.description = "A companion gem for appsignal that pulls in the OpenTelemetry " \ + "gems required to run AppSignal in collector mode." + gem.homepage = "https://github.com/appsignal/appsignal-ruby" + gem.license = "MIT" + + # Collector mode requires Ruby 3.1 or newer, so this companion gem does too. + # This mirrors `MIN_RUBY_VERSION_FOR_COLLECTOR_MODE` in `Appsignal::Config`, + # which is not cheap to load from a gemspec, so the value is hardcoded here. + gem.required_ruby_version = ">= 3.1" + + # Build the file list from a local glob rather than `git ls-files` so this + # gem only ever packages its own directory. + gem.files = Dir.chdir(__dir__) do + Dir["lib/**/*.rb", "*.gemspec", "CHANGELOG.md", "README.md"] + end + gem.require_paths = ["lib"] + + gem.metadata = { + "rubygems_mfa_required" => "true", + "changelog_uri" => + "https://github.com/appsignal/appsignal-ruby/blob/main/packages/opentelemetry/CHANGELOG.md", + "source_code_uri" => "https://github.com/appsignal/appsignal-ruby" + } + + gem.add_dependency "appsignal", "4.10.1" + + # Add the OpenTelemetry gems by looping over the shared list instead of + # listing them here. This keeps `REQUIRED_GEMS` the single source of truth, + # so this gem's dependencies can never drift from what the runtime requires. + Appsignal::OpenTelemetry::REQUIRED_GEMS.each do |name, constraints| + gem.add_dependency name, *constraints + end +end diff --git a/packages/opentelemetry/lib/appsignal_opentelemetry/version.rb b/packages/opentelemetry/lib/appsignal_opentelemetry/version.rb new file mode 100644 index 000000000..81034baf7 --- /dev/null +++ b/packages/opentelemetry/lib/appsignal_opentelemetry/version.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +module AppsignalOpentelemetry + # @return [String] + VERSION = "4.10.1" +end diff --git a/packages/opentelemetry/spec/gemspec_spec.rb b/packages/opentelemetry/spec/gemspec_spec.rb new file mode 100644 index 000000000..453241231 --- /dev/null +++ b/packages/opentelemetry/spec/gemspec_spec.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +require_relative "../../../lib/appsignal/opentelemetry/dependencies" + +RSpec.describe "appsignal-opentelemetry.gemspec" do + let(:gemspec_path) do + File.expand_path("../appsignal-opentelemetry.gemspec", __dir__) + end + let(:gemspec) { Gem::Specification.load(gemspec_path) } + + it "loads as a valid gemspec named appsignal-opentelemetry" do + expect(gemspec).to be_a(Gem::Specification) + expect(gemspec.name).to eq("appsignal-opentelemetry") + expect { gemspec.validate }.to_not raise_error + end + + it "requires Ruby 3.1 or newer" do + expect(gemspec.required_ruby_version.to_s).to eq(">= 3.1") + end + + it "depends on appsignal plus every required OpenTelemetry gem at its constraints" do + expected = { "appsignal" => "= #{gemspec.version}" } + Appsignal::OpenTelemetry::REQUIRED_GEMS.each do |name, constraints| + expected[name] = Gem::Requirement.new(*constraints).to_s + end + + actual = gemspec.dependencies.to_h do |dependency| + [dependency.name, dependency.requirement.to_s] + end + + expect(actual).to eq(expected) + end +end diff --git a/sig/appsignal.rbi b/sig/appsignal.rbi index 51bf7397a..d58c4fe5d 100644 --- a/sig/appsignal.rbi +++ b/sig/appsignal.rbi @@ -301,6 +301,14 @@ module Appsignal # # _@param_ `action` — The action name for the transaction. The action name is required to be set for the transaction to be reported. The argument can be set to `nil` or `:set_later` if the action is set within the block with {#set_action}. This will not update the active transaction's action if {.monitor} is called when another transaction is already active. # + # _@param_ `opentelemetry_kind` — In collector mode, the OpenTelemetry span kind: one of `:server`, `:consumer`, `:producer` or `:internal`. Defaults to `:server`. + # + # _@param_ `opentelemetry_relationship` — In collector mode, how an incoming `opentelemetry_context` relates to this transaction's span: one of `:parent`, `:link`, `:both` or `:none`. Defaults to `:parent`. + # + # _@param_ `opentelemetry_context` — In collector mode, an incoming OpenTelemetry trace context to relate this transaction's span to. + # + # _@param_ `opentelemetry_scope` — In collector mode, the OpenTelemetry instrumentation scope to record this transaction's spans under, given as a `[name, version]` pair. Defaults to the AppSignal scope. + # # _@return_ — The value of the given block is returned. # Returns `nil` if there already is a transaction active and no block # was given. @@ -391,8 +399,18 @@ module Appsignal # ``` # # _@see_ `https://docs.appsignal.com/ruby/instrumentation/background-jobs.html` — Monitor guide - sig { params(action: T.any(String, Symbol, NilClass), namespace: T.nilable(T.any(String, Symbol)), blk: T.proc.returns(Object)).returns(T.nilable(Object)) } - def self.monitor(action:, namespace: nil, &blk); end + sig do + params( + action: T.any(String, Symbol, NilClass), + namespace: T.nilable(T.any(String, Symbol)), + opentelemetry_context: T.untyped, + opentelemetry_scope: T.nilable([String, String]), + opentelemetry_kind: T.nilable(Symbol), + opentelemetry_relationship: T.nilable(Symbol), + blk: T.proc.returns(Object) + ).returns(T.nilable(Object)) + end + def self.monitor(action:, namespace: nil, opentelemetry_context: nil, opentelemetry_scope: nil, opentelemetry_kind: nil, opentelemetry_relationship: nil, &blk); end # Instrument a block of code and stop AppSignal. # @@ -406,11 +424,29 @@ module Appsignal # # _@param_ `action` — The action name for the transaction. The action name is required to be set for the transaction to be reported. The argument can be set to `nil` or `:set_later` if the action is set within the block with {#set_action}. This will not update the active transaction's action if {.monitor} is called when another transaction is already active. # + # _@param_ `opentelemetry_kind` — In collector mode, the OpenTelemetry span kind: one of `:server`, `:consumer`, `:producer` or `:internal`. Defaults to `:server`. + # + # _@param_ `opentelemetry_relationship` — In collector mode, how an incoming `opentelemetry_context` relates to this transaction's span: one of `:parent`, `:link`, `:both` or `:none`. Defaults to `:parent`. + # + # _@param_ `opentelemetry_context` — In collector mode, an incoming OpenTelemetry trace context to relate this transaction's span to. + # + # _@param_ `opentelemetry_scope` — In collector mode, the OpenTelemetry instrumentation scope to record this transaction's spans under, given as a `[name, version]` pair. Defaults to the AppSignal scope. + # # _@return_ — The value of the given block is returned. # # _@see_ `monitor` - sig { params(action: T.any(String, Symbol, NilClass), namespace: T.nilable(T.any(String, Symbol)), block: T.proc.returns(Object)).returns(T.nilable(Object)) } - def self.monitor_and_stop(action:, namespace: nil, &block); end + sig do + params( + action: T.any(String, Symbol, NilClass), + namespace: T.nilable(T.any(String, Symbol)), + opentelemetry_context: T.untyped, + opentelemetry_scope: T.nilable([String, String]), + opentelemetry_kind: T.nilable(Symbol), + opentelemetry_relationship: T.nilable(Symbol), + block: T.proc.returns(Object) + ).returns(T.nilable(Object)) + end + def self.monitor_and_stop(action:, namespace: nil, opentelemetry_context: nil, opentelemetry_scope: nil, opentelemetry_kind: nil, opentelemetry_relationship: nil, &block); end # Send an error to AppSignal regardless of the context. # @@ -429,6 +465,14 @@ module Appsignal # # _@param_ `error` — The error to send to AppSignal. # + # _@param_ `opentelemetry_kind` — In collector mode, the OpenTelemetry span kind: one of `:server`, `:consumer`, `:producer` or `:internal`. Defaults to `:server`. + # + # _@param_ `opentelemetry_relationship` — In collector mode, how an incoming `opentelemetry_context` relates to this transaction's span: one of `:parent`, `:link`, `:both` or `:none`. Defaults to `:parent`. + # + # _@param_ `opentelemetry_context` — In collector mode, an incoming OpenTelemetry trace context to relate this transaction's span to. + # + # _@param_ `opentelemetry_scope` — In collector mode, the OpenTelemetry instrumentation scope to record this transaction's spans under, given as a `[name, version]` pair. Defaults to the AppSignal scope. + # # Send an exception # ```ruby # begin @@ -449,8 +493,17 @@ module Appsignal # ``` # # _@see_ `https://docs.appsignal.com/ruby/instrumentation/exception-handling.html` — Exception handling guide - sig { params(error: Exception, block: T.proc.params(transaction: Transaction).void).void } - def self.send_error(error, &block); end + sig do + params( + error: Exception, + opentelemetry_context: T.untyped, + opentelemetry_scope: T.nilable([String, String]), + opentelemetry_kind: T.nilable(Symbol), + opentelemetry_relationship: T.nilable(Symbol), + block: T.proc.params(transaction: Transaction).void + ).void + end + def self.send_error(error, opentelemetry_context: nil, opentelemetry_scope: nil, opentelemetry_kind: nil, opentelemetry_relationship: nil, &block); end # Set an error on the current transaction. # @@ -523,6 +576,14 @@ module Appsignal # # _@param_ `exception` — The error to add to the current transaction. # + # _@param_ `opentelemetry_kind` — In collector mode, the OpenTelemetry span kind: one of `:server`, `:consumer`, `:producer` or `:internal`. Defaults to `:server`. Only used when a new transaction is created. + # + # _@param_ `opentelemetry_relationship` — In collector mode, how an incoming `opentelemetry_context` relates to this transaction's span: one of `:parent`, `:link`, `:both` or `:none`. Defaults to `:parent`. Only used when a new transaction is created. + # + # _@param_ `opentelemetry_context` — In collector mode, an incoming OpenTelemetry trace context to relate this transaction's span to. Only used when a new transaction is created. + # + # _@param_ `opentelemetry_scope` — In collector mode, the OpenTelemetry instrumentation scope to record this transaction's spans under, given as a `[name, version]` pair. Defaults to the AppSignal scope. + # # ```ruby # class SomeController < ApplicationController # def create @@ -544,8 +605,17 @@ module Appsignal # ``` # # _@see_ `https://docs.appsignal.com/ruby/instrumentation/exception-handling.html` — Exception handling guide - sig { params(exception: Exception, block: T.proc.params(transaction: Transaction).void).void } - def self.report_error(exception, &block); end + sig do + params( + exception: Exception, + opentelemetry_context: T.untyped, + opentelemetry_scope: T.nilable([String, String]), + opentelemetry_kind: T.nilable(Symbol), + opentelemetry_relationship: T.nilable(Symbol), + block: T.proc.params(transaction: Transaction).void + ).void + end + def self.report_error(exception, opentelemetry_context: nil, opentelemetry_scope: nil, opentelemetry_kind: nil, opentelemetry_relationship: nil, &block); end # Set a custom action name for the current transaction. # @@ -743,16 +813,65 @@ module Appsignal sig { params(params: T.nilable(T.any(T::Hash[String, Object], T::Array[Object])), block: T.proc.returns(T.any(T::Hash[String, Object], T::Array[Object]))).void } def self.add_params(params = nil, &block); end + # Add the request payload to the current transaction. + # + # The request payload is the parameters of an incoming request, such as + # the query string and the request body. In collector mode it maps to its + # own attribute, separate from the function parameters. + # + # Behaves like {#add_params}: merges when called multiple times, and a + # block takes precedence over the argument. + # + # _@param_ `params` — The request payload to add to the transaction. + # + # _@see_ `#add_function_parameters` + sig { params(params: T.nilable(T.any(T::Hash[String, Object], T::Array[Object])), block: T.proc.returns(T.any(T::Hash[String, Object], T::Array[Object]))).void } + def self.add_request_payload(params = nil, &block); end + + # Add the function parameters to the current transaction. + # + # The function parameters are the arguments a background job or function + # was called with. In collector mode they map to their own attribute, + # separate from the request payload. + # + # Behaves like {#add_params}: merges when called multiple times, and a + # block takes precedence over the argument. + # + # _@param_ `params` — The function parameters to add to the transaction. + # + # _@see_ `#add_request_payload` + sig { params(params: T.nilable(T.any(T::Hash[String, Object], T::Array[Object])), block: T.proc.returns(T.any(T::Hash[String, Object], T::Array[Object]))).void } + def self.add_function_parameters(params = nil, &block); end + + # Add the query parameters to the current transaction. + # + # The query parameters are the parameters parsed from an incoming + # request's query string. In collector mode they map to their own + # attribute, separate from the request payload and the function + # parameters. + # + # Behaves like {#add_params}: merges when called multiple times, and a + # block takes precedence over the argument. + # + # _@param_ `params` — The query parameters to add to the transaction. + # + # _@see_ `#add_request_payload` + sig { params(params: T.nilable(T.any(T::Hash[String, Object], T::Array[Object])), block: T.proc.returns(T.any(T::Hash[String, Object], T::Array[Object]))).void } + def self.add_query_parameters(params = nil, &block); end + # Mark the parameters sample data to be set as an empty value. # - # Use this helper to unset request parameters / background job arguments - # and not report any for this transaction. + # Use this helper to report no parameters for this transaction, whatever + # their source. # - # If parameters would normally be added by AppSignal instrumentations of - # libraries, these parameters will not be added to the Transaction. + # This suppresses every params channel. In collector mode, where the + # request payload and the function parameters (a background job's + # arguments) are tracked as separate attributes, it suppresses both, not + # only the request payload. Parameters that an AppSignal integration would + # otherwise add are not added. # - # Calling {#add_params} after this helper will add new parameters to the - # transaction. + # Calling {#add_params}, {#add_request_payload} or + # {#add_function_parameters} after this helper adds parameters again. # # _@see_ `Transaction#set_empty_params!` # @@ -833,7 +952,7 @@ module Appsignal # Breadcrumbs can be used to trace what path a user has taken # before encountering an error. # - # Only the last 20 added breadcrumbs will be saved. + # At most 20 of the added breadcrumbs will be saved. # # _@param_ `category` — category of breadcrumb e.g. "UI", "Network", "Navigation", "Console". # @@ -891,6 +1010,10 @@ module Appsignal # # _@param_ `body_format` — Enum for the type of event that is instrumented. Accepted values are {EventFormatter::DEFAULT} and {EventFormatter::SQL_BODY_FORMAT}, but we recommend you use {.instrument_sql} instead of {EventFormatter::SQL_BODY_FORMAT}. # + # _@param_ `opentelemetry_kind` — In collector mode, the OpenTelemetry span kind for the event's span, such as `:client` for an outgoing HTTP request. Defaults to the OpenTelemetry default of `:internal`. + # + # _@param_ `opentelemetry_scope` — In collector mode, the OpenTelemetry instrumentation scope to record the event's span under, given as a `[name, version]` pair. Defaults to the AppSignal scope. + # # _@return_ — Returns the block's return value. # # Simple instrumentation @@ -922,10 +1045,12 @@ module Appsignal title: T.nilable(String), body: T.nilable(String), body_format: Integer, + opentelemetry_kind: T.nilable(Symbol), + opentelemetry_scope: T.nilable([String, String]), block: T.untyped ).returns(Object) end - def self.instrument(name, title = nil, body = nil, body_format = Appsignal::EventFormatter::DEFAULT, &block); end + def self.instrument(name, title = nil, body = nil, body_format = Appsignal::EventFormatter::DEFAULT, opentelemetry_kind: nil, opentelemetry_scope: nil, &block); end # Instrumentation helper for SQL queries. # @@ -937,6 +1062,10 @@ module Appsignal # # _@param_ `body` — SQL query that's being executed. # + # _@param_ `opentelemetry_kind` — In collector mode, the OpenTelemetry span kind for the event's span. Defaults to `:client`, because a query is an outgoing call to a datastore. Pass `:internal` for a query that is not an outgoing call. + # + # _@param_ `opentelemetry_scope` — In collector mode, the OpenTelemetry instrumentation scope to record the event's span under, given as a `[name, version]` pair. Defaults to the AppSignal scope. + # # _@return_ — Returns the block's return value. # # SQL query instrumentation @@ -965,10 +1094,12 @@ module Appsignal name: String, title: T.nilable(String), body: T.nilable(String), + opentelemetry_kind: Symbol, + opentelemetry_scope: T.nilable([String, String]), block: T.untyped ).returns(Object) end - def self.instrument_sql(name, title = nil, body = nil, &block); end + def self.instrument_sql(name, title = nil, body = nil, opentelemetry_kind: :client, opentelemetry_scope: nil, &block); end # Convenience method for ignoring instrumentation events in a block of # code. @@ -1041,6 +1172,38 @@ module Appsignal sig { returns(T::Boolean) } def active?; end + # Check if collector mode is configured. + # + # Returns true when a non-empty `collector_endpoint` is set and the + # running Ruby version is at least {MIN_RUBY_VERSION_FOR_COLLECTOR_MODE}. + # On older Rubies, `collector_endpoint` is ignored (with a warning) and + # the AppSignal agent is used instead. + # + # This is the *intent* check — it answers "did the user ask for + # collector mode, and could we honor it?". It does not say whether the + # OpenTelemetry SDK actually booted. See {#collector_mode?} for that. + # + # Memoised: the result is cached on first call so hot paths avoid + # re-running the string-strip predicate, and so the unsupported-Ruby + # warning is emitted at most once per `Config` instance. + # + # _@return_ — True if collector mode is configured. + sig { returns(T::Boolean) } + def collector_mode_configured?; end + + # Check if AppSignal is actively running in collector mode. + # + # True only if collector mode is {#collector_mode_configured? configured} + # *and* `Appsignal::OpenTelemetry.configure` has successfully booted the + # SDK in this process. Use this for backend dispatch on hot paths + # (metric and log emits): if the OTel boot failed, callers fall back to + # the agent backend rather than silently dropping data into no-op + # providers. + # + # _@return_ — True if collector mode is configured and started. + sig { returns(T::Boolean) } + def collector_mode?; end + sig { returns(T::Boolean) } def yml_config_file?; end @@ -1662,8 +1825,24 @@ module Appsignal # transaction. # # _@param_ `namespace` — Namespace of the to be created transaction. - sig { params(namespace: String).returns(Transaction) } - def self.create(namespace); end + # + # _@param_ `opentelemetry_kind` — In collector mode, the OpenTelemetry span kind: one of `:server`, `:consumer`, `:producer` or `:internal`. Defaults to `:server`. + # + # _@param_ `opentelemetry_relationship` — In collector mode, how an incoming `opentelemetry_context` relates to this transaction's span: one of `:parent`, `:link`, `:both` or `:none`. Defaults to `:parent`. + # + # _@param_ `opentelemetry_context` — In collector mode, an incoming OpenTelemetry trace context to relate this transaction's span to. + # + # _@param_ `opentelemetry_scope` — In collector mode, the OpenTelemetry instrumentation scope to record this transaction's spans under, given as a `[name, version]` pair. Defaults to the AppSignal scope. + sig do + params( + namespace: String, + opentelemetry_context: T.untyped, + opentelemetry_scope: T.nilable([String, String]), + opentelemetry_kind: T.nilable(Symbol), + opentelemetry_relationship: T.nilable(Symbol) + ).returns(Transaction) + end + def self.create(namespace, opentelemetry_context: nil, opentelemetry_scope: nil, opentelemetry_kind: nil, opentelemetry_relationship: nil); end # Returns currently active transaction or a {NilTransaction} if none is # active. @@ -1699,6 +1878,52 @@ module Appsignal sig { params(given_params: T.nilable(T.any(T::Hash[String, Object], T::Array[Object])), block: T.proc.returns(T.any(T::Hash[String, Object], T::Array[Object]))).void } def add_params(given_params = nil, &block); end + # Add the request payload to the transaction. + # + # These are the parameters of an incoming request, such as the query string + # and the request body. In collector mode they map to the request payload + # attribute. In agent mode they are the transaction's params. + # + # Behaves like {#add_params}: merges when called multiple times, and a + # block takes precedence over the argument. + # + # _@param_ `given_params` — The parameters to add to the transaction. + # + # _@see_ `#add_function_parameters` + sig { params(given_params: T.nilable(T.any(T::Hash[String, Object], T::Array[Object])), block: T.proc.returns(T.any(T::Hash[String, Object], T::Array[Object]))).void } + def add_request_payload(given_params = nil, &block); end + + # Add the function parameters to the transaction. + # + # These are the arguments a background job or function was called with. In + # collector mode they map to the function parameters attribute. In agent + # mode they are the transaction's params. + # + # Behaves like {#add_params}: merges when called multiple times, and a + # block takes precedence over the argument. + # + # _@param_ `given_params` — The parameters to add to the transaction. + # + # _@see_ `#add_request_payload` + sig { params(given_params: T.nilable(T.any(T::Hash[String, Object], T::Array[Object])), block: T.proc.returns(T.any(T::Hash[String, Object], T::Array[Object]))).void } + def add_function_parameters(given_params = nil, &block); end + + # Add the query parameters to the transaction. + # + # These are the parameters parsed from an incoming request's query string. + # In collector mode they map to their own attribute, separate from the + # request payload and the function parameters. In agent mode they are the + # transaction's params. + # + # Behaves like {#add_params}: merges when called multiple times, and a + # block takes precedence over the argument. + # + # _@param_ `given_params` — The parameters to add to the transaction. + # + # _@see_ `#add_request_payload` + sig { params(given_params: T.nilable(T.any(T::Hash[String, Object], T::Array[Object])), block: T.proc.returns(T.any(T::Hash[String, Object], T::Array[Object]))).void } + def add_query_parameters(given_params = nil, &block); end + # Add tags to the transaction. # # When this method is called multiple times, it will merge the tags. @@ -1809,6 +2034,36 @@ module Appsignal # _@param_ `start` — Queue start time in milliseconds. sig { params(start: Integer).void } def set_queue_start(start); end + + # Add OpenTelemetry attributes to the span AppSignal is currently + # recording. + # + # In collector mode, AppSignal records a transaction as an OpenTelemetry + # span, and every instrumented event as a child span. This adds attributes + # to whichever of those spans is open right now: the innermost event + # started by {Appsignal::Helpers::Instrumentation#instrument}, or the + # transaction's own span when no event is open. + # + # Use this to describe what is being instrumented in OpenTelemetry's own + # terms, following the OpenTelemetry semantic conventions where they apply. + # Attributes have no equivalent outside collector mode, so this does + # nothing when collector mode is not active. + # + # _@param_ `attributes` — Attributes to add to the current span. Values that are not a String, Integer, Float or boolean are converted to a String. Nothing is added when this is nil or empty. + # + # Describing a database query + # ```ruby + # Appsignal.instrument("query.my_database") do + # Appsignal::Transaction.current.add_opentelemetry_attributes( + # "db.system.name" => "mysql" + # ) + # run_the_query + # end + # ``` + # + # _@see_ `https://opentelemetry.io/docs/specs/semconv/` — OpenTelemetry semantic conventions + sig { params(attributes: T.nilable(T::Hash[String, Object])).void } + def add_opentelemetry_attributes(attributes = {}); end end # Custom markers are used on AppSignal.com to indicate events in an @@ -1957,6 +2212,14 @@ module Appsignal # # _@param_ `action` — The action name for the transaction. The action name is required to be set for the transaction to be reported. The argument can be set to `nil` or `:set_later` if the action is set within the block with {#set_action}. This will not update the active transaction's action if {.monitor} is called when another transaction is already active. # + # _@param_ `opentelemetry_kind` — In collector mode, the OpenTelemetry span kind: one of `:server`, `:consumer`, `:producer` or `:internal`. Defaults to `:server`. + # + # _@param_ `opentelemetry_relationship` — In collector mode, how an incoming `opentelemetry_context` relates to this transaction's span: one of `:parent`, `:link`, `:both` or `:none`. Defaults to `:parent`. + # + # _@param_ `opentelemetry_context` — In collector mode, an incoming OpenTelemetry trace context to relate this transaction's span to. + # + # _@param_ `opentelemetry_scope` — In collector mode, the OpenTelemetry instrumentation scope to record this transaction's spans under, given as a `[name, version]` pair. Defaults to the AppSignal scope. + # # _@return_ — The value of the given block is returned. # Returns `nil` if there already is a transaction active and no block # was given. @@ -2047,8 +2310,18 @@ module Appsignal # ``` # # _@see_ `https://docs.appsignal.com/ruby/instrumentation/background-jobs.html` — Monitor guide - sig { params(action: T.any(String, Symbol, NilClass), namespace: T.nilable(T.any(String, Symbol)), blk: T.proc.returns(Object)).returns(T.nilable(Object)) } - def monitor(action:, namespace: nil, &blk); end + sig do + params( + action: T.any(String, Symbol, NilClass), + namespace: T.nilable(T.any(String, Symbol)), + opentelemetry_context: T.untyped, + opentelemetry_scope: T.nilable([String, String]), + opentelemetry_kind: T.nilable(Symbol), + opentelemetry_relationship: T.nilable(Symbol), + blk: T.proc.returns(Object) + ).returns(T.nilable(Object)) + end + def monitor(action:, namespace: nil, opentelemetry_context: nil, opentelemetry_scope: nil, opentelemetry_kind: nil, opentelemetry_relationship: nil, &blk); end # Instrument a block of code and stop AppSignal. # @@ -2062,11 +2335,29 @@ module Appsignal # # _@param_ `action` — The action name for the transaction. The action name is required to be set for the transaction to be reported. The argument can be set to `nil` or `:set_later` if the action is set within the block with {#set_action}. This will not update the active transaction's action if {.monitor} is called when another transaction is already active. # + # _@param_ `opentelemetry_kind` — In collector mode, the OpenTelemetry span kind: one of `:server`, `:consumer`, `:producer` or `:internal`. Defaults to `:server`. + # + # _@param_ `opentelemetry_relationship` — In collector mode, how an incoming `opentelemetry_context` relates to this transaction's span: one of `:parent`, `:link`, `:both` or `:none`. Defaults to `:parent`. + # + # _@param_ `opentelemetry_context` — In collector mode, an incoming OpenTelemetry trace context to relate this transaction's span to. + # + # _@param_ `opentelemetry_scope` — In collector mode, the OpenTelemetry instrumentation scope to record this transaction's spans under, given as a `[name, version]` pair. Defaults to the AppSignal scope. + # # _@return_ — The value of the given block is returned. # # _@see_ `monitor` - sig { params(action: T.any(String, Symbol, NilClass), namespace: T.nilable(T.any(String, Symbol)), block: T.proc.returns(Object)).returns(T.nilable(Object)) } - def monitor_and_stop(action:, namespace: nil, &block); end + sig do + params( + action: T.any(String, Symbol, NilClass), + namespace: T.nilable(T.any(String, Symbol)), + opentelemetry_context: T.untyped, + opentelemetry_scope: T.nilable([String, String]), + opentelemetry_kind: T.nilable(Symbol), + opentelemetry_relationship: T.nilable(Symbol), + block: T.proc.returns(Object) + ).returns(T.nilable(Object)) + end + def monitor_and_stop(action:, namespace: nil, opentelemetry_context: nil, opentelemetry_scope: nil, opentelemetry_kind: nil, opentelemetry_relationship: nil, &block); end # Send an error to AppSignal regardless of the context. # @@ -2085,6 +2376,14 @@ module Appsignal # # _@param_ `error` — The error to send to AppSignal. # + # _@param_ `opentelemetry_kind` — In collector mode, the OpenTelemetry span kind: one of `:server`, `:consumer`, `:producer` or `:internal`. Defaults to `:server`. + # + # _@param_ `opentelemetry_relationship` — In collector mode, how an incoming `opentelemetry_context` relates to this transaction's span: one of `:parent`, `:link`, `:both` or `:none`. Defaults to `:parent`. + # + # _@param_ `opentelemetry_context` — In collector mode, an incoming OpenTelemetry trace context to relate this transaction's span to. + # + # _@param_ `opentelemetry_scope` — In collector mode, the OpenTelemetry instrumentation scope to record this transaction's spans under, given as a `[name, version]` pair. Defaults to the AppSignal scope. + # # Send an exception # ```ruby # begin @@ -2105,8 +2404,17 @@ module Appsignal # ``` # # _@see_ `https://docs.appsignal.com/ruby/instrumentation/exception-handling.html` — Exception handling guide - sig { params(error: Exception, block: T.proc.params(transaction: Transaction).void).void } - def send_error(error, &block); end + sig do + params( + error: Exception, + opentelemetry_context: T.untyped, + opentelemetry_scope: T.nilable([String, String]), + opentelemetry_kind: T.nilable(Symbol), + opentelemetry_relationship: T.nilable(Symbol), + block: T.proc.params(transaction: Transaction).void + ).void + end + def send_error(error, opentelemetry_context: nil, opentelemetry_scope: nil, opentelemetry_kind: nil, opentelemetry_relationship: nil, &block); end # Set an error on the current transaction. # @@ -2179,6 +2487,14 @@ module Appsignal # # _@param_ `exception` — The error to add to the current transaction. # + # _@param_ `opentelemetry_kind` — In collector mode, the OpenTelemetry span kind: one of `:server`, `:consumer`, `:producer` or `:internal`. Defaults to `:server`. Only used when a new transaction is created. + # + # _@param_ `opentelemetry_relationship` — In collector mode, how an incoming `opentelemetry_context` relates to this transaction's span: one of `:parent`, `:link`, `:both` or `:none`. Defaults to `:parent`. Only used when a new transaction is created. + # + # _@param_ `opentelemetry_context` — In collector mode, an incoming OpenTelemetry trace context to relate this transaction's span to. Only used when a new transaction is created. + # + # _@param_ `opentelemetry_scope` — In collector mode, the OpenTelemetry instrumentation scope to record this transaction's spans under, given as a `[name, version]` pair. Defaults to the AppSignal scope. + # # ```ruby # class SomeController < ApplicationController # def create @@ -2200,8 +2516,17 @@ module Appsignal # ``` # # _@see_ `https://docs.appsignal.com/ruby/instrumentation/exception-handling.html` — Exception handling guide - sig { params(exception: Exception, block: T.proc.params(transaction: Transaction).void).void } - def report_error(exception, &block); end + sig do + params( + exception: Exception, + opentelemetry_context: T.untyped, + opentelemetry_scope: T.nilable([String, String]), + opentelemetry_kind: T.nilable(Symbol), + opentelemetry_relationship: T.nilable(Symbol), + block: T.proc.params(transaction: Transaction).void + ).void + end + def report_error(exception, opentelemetry_context: nil, opentelemetry_scope: nil, opentelemetry_kind: nil, opentelemetry_relationship: nil, &block); end # Set a custom action name for the current transaction. # @@ -2399,16 +2724,65 @@ module Appsignal sig { params(params: T.nilable(T.any(T::Hash[String, Object], T::Array[Object])), block: T.proc.returns(T.any(T::Hash[String, Object], T::Array[Object]))).void } def add_params(params = nil, &block); end + # Add the request payload to the current transaction. + # + # The request payload is the parameters of an incoming request, such as + # the query string and the request body. In collector mode it maps to its + # own attribute, separate from the function parameters. + # + # Behaves like {#add_params}: merges when called multiple times, and a + # block takes precedence over the argument. + # + # _@param_ `params` — The request payload to add to the transaction. + # + # _@see_ `#add_function_parameters` + sig { params(params: T.nilable(T.any(T::Hash[String, Object], T::Array[Object])), block: T.proc.returns(T.any(T::Hash[String, Object], T::Array[Object]))).void } + def add_request_payload(params = nil, &block); end + + # Add the function parameters to the current transaction. + # + # The function parameters are the arguments a background job or function + # was called with. In collector mode they map to their own attribute, + # separate from the request payload. + # + # Behaves like {#add_params}: merges when called multiple times, and a + # block takes precedence over the argument. + # + # _@param_ `params` — The function parameters to add to the transaction. + # + # _@see_ `#add_request_payload` + sig { params(params: T.nilable(T.any(T::Hash[String, Object], T::Array[Object])), block: T.proc.returns(T.any(T::Hash[String, Object], T::Array[Object]))).void } + def add_function_parameters(params = nil, &block); end + + # Add the query parameters to the current transaction. + # + # The query parameters are the parameters parsed from an incoming + # request's query string. In collector mode they map to their own + # attribute, separate from the request payload and the function + # parameters. + # + # Behaves like {#add_params}: merges when called multiple times, and a + # block takes precedence over the argument. + # + # _@param_ `params` — The query parameters to add to the transaction. + # + # _@see_ `#add_request_payload` + sig { params(params: T.nilable(T.any(T::Hash[String, Object], T::Array[Object])), block: T.proc.returns(T.any(T::Hash[String, Object], T::Array[Object]))).void } + def add_query_parameters(params = nil, &block); end + # Mark the parameters sample data to be set as an empty value. # - # Use this helper to unset request parameters / background job arguments - # and not report any for this transaction. + # Use this helper to report no parameters for this transaction, whatever + # their source. # - # If parameters would normally be added by AppSignal instrumentations of - # libraries, these parameters will not be added to the Transaction. + # This suppresses every params channel. In collector mode, where the + # request payload and the function parameters (a background job's + # arguments) are tracked as separate attributes, it suppresses both, not + # only the request payload. Parameters that an AppSignal integration would + # otherwise add are not added. # - # Calling {#add_params} after this helper will add new parameters to the - # transaction. + # Calling {#add_params}, {#add_request_payload} or + # {#add_function_parameters} after this helper adds parameters again. # # _@see_ `Transaction#set_empty_params!` # @@ -2489,7 +2863,7 @@ module Appsignal # Breadcrumbs can be used to trace what path a user has taken # before encountering an error. # - # Only the last 20 added breadcrumbs will be saved. + # At most 20 of the added breadcrumbs will be saved. # # _@param_ `category` — category of breadcrumb e.g. "UI", "Network", "Navigation", "Console". # @@ -2547,6 +2921,10 @@ module Appsignal # # _@param_ `body_format` — Enum for the type of event that is instrumented. Accepted values are {EventFormatter::DEFAULT} and {EventFormatter::SQL_BODY_FORMAT}, but we recommend you use {.instrument_sql} instead of {EventFormatter::SQL_BODY_FORMAT}. # + # _@param_ `opentelemetry_kind` — In collector mode, the OpenTelemetry span kind for the event's span, such as `:client` for an outgoing HTTP request. Defaults to the OpenTelemetry default of `:internal`. + # + # _@param_ `opentelemetry_scope` — In collector mode, the OpenTelemetry instrumentation scope to record the event's span under, given as a `[name, version]` pair. Defaults to the AppSignal scope. + # # _@return_ — Returns the block's return value. # # Simple instrumentation @@ -2578,10 +2956,12 @@ module Appsignal title: T.nilable(String), body: T.nilable(String), body_format: Integer, + opentelemetry_kind: T.nilable(Symbol), + opentelemetry_scope: T.nilable([String, String]), block: T.untyped ).returns(Object) end - def instrument(name, title = nil, body = nil, body_format = Appsignal::EventFormatter::DEFAULT, &block); end + def instrument(name, title = nil, body = nil, body_format = Appsignal::EventFormatter::DEFAULT, opentelemetry_kind: nil, opentelemetry_scope: nil, &block); end # Instrumentation helper for SQL queries. # @@ -2593,6 +2973,10 @@ module Appsignal # # _@param_ `body` — SQL query that's being executed. # + # _@param_ `opentelemetry_kind` — In collector mode, the OpenTelemetry span kind for the event's span. Defaults to `:client`, because a query is an outgoing call to a datastore. Pass `:internal` for a query that is not an outgoing call. + # + # _@param_ `opentelemetry_scope` — In collector mode, the OpenTelemetry instrumentation scope to record the event's span under, given as a `[name, version]` pair. Defaults to the AppSignal scope. + # # _@return_ — Returns the block's return value. # # SQL query instrumentation @@ -2621,10 +3005,12 @@ module Appsignal name: String, title: T.nilable(String), body: T.nilable(String), + opentelemetry_kind: Symbol, + opentelemetry_scope: T.nilable([String, String]), block: T.untyped ).returns(Object) end - def instrument_sql(name, title = nil, body = nil, &block); end + def instrument_sql(name, title = nil, body = nil, opentelemetry_kind: :client, opentelemetry_scope: nil, &block); end # Convenience method for ignoring instrumentation events in a block of # code. @@ -2665,6 +3051,9 @@ module Appsignal sig { returns(String) } def message; end end + + module Metrics + end end # Extensions to Object for AppSignal method instrumentation. diff --git a/sig/appsignal.rbs b/sig/appsignal.rbs index 7e08531b7..3bcd28158 100644 --- a/sig/appsignal.rbs +++ b/sig/appsignal.rbs @@ -259,6 +259,14 @@ module Appsignal # # _@param_ `action` — The action name for the transaction. The action name is required to be set for the transaction to be reported. The argument can be set to `nil` or `:set_later` if the action is set within the block with {#set_action}. This will not update the active transaction's action if {.monitor} is called when another transaction is already active. # + # _@param_ `opentelemetry_kind` — In collector mode, the OpenTelemetry span kind: one of `:server`, `:consumer`, `:producer` or `:internal`. Defaults to `:server`. + # + # _@param_ `opentelemetry_relationship` — In collector mode, how an incoming `opentelemetry_context` relates to this transaction's span: one of `:parent`, `:link`, `:both` or `:none`. Defaults to `:parent`. + # + # _@param_ `opentelemetry_context` — In collector mode, an incoming OpenTelemetry trace context to relate this transaction's span to. + # + # _@param_ `opentelemetry_scope` — In collector mode, the OpenTelemetry instrumentation scope to record this transaction's spans under, given as a `[name, version]` pair. Defaults to the AppSignal scope. + # # _@return_ — The value of the given block is returned. # Returns `nil` if there already is a transaction active and no block # was given. @@ -349,7 +357,14 @@ module Appsignal # ``` # # _@see_ `https://docs.appsignal.com/ruby/instrumentation/background-jobs.html` — Monitor guide - def self.monitor: (action: (String | Symbol | NilClass), ?namespace: (String | Symbol)?) ?{ () -> Object } -> Object? + def self.monitor: ( + action: (String | Symbol | NilClass), + ?namespace: (String | Symbol)?, + ?opentelemetry_context: untyped, + ?opentelemetry_scope: [String, String]?, + ?opentelemetry_kind: Symbol?, + ?opentelemetry_relationship: Symbol? + ) ?{ () -> Object } -> Object? # Instrument a block of code and stop AppSignal. # @@ -363,10 +378,25 @@ module Appsignal # # _@param_ `action` — The action name for the transaction. The action name is required to be set for the transaction to be reported. The argument can be set to `nil` or `:set_later` if the action is set within the block with {#set_action}. This will not update the active transaction's action if {.monitor} is called when another transaction is already active. # + # _@param_ `opentelemetry_kind` — In collector mode, the OpenTelemetry span kind: one of `:server`, `:consumer`, `:producer` or `:internal`. Defaults to `:server`. + # + # _@param_ `opentelemetry_relationship` — In collector mode, how an incoming `opentelemetry_context` relates to this transaction's span: one of `:parent`, `:link`, `:both` or `:none`. Defaults to `:parent`. + # + # _@param_ `opentelemetry_context` — In collector mode, an incoming OpenTelemetry trace context to relate this transaction's span to. + # + # _@param_ `opentelemetry_scope` — In collector mode, the OpenTelemetry instrumentation scope to record this transaction's spans under, given as a `[name, version]` pair. Defaults to the AppSignal scope. + # # _@return_ — The value of the given block is returned. # # _@see_ `monitor` - def self.monitor_and_stop: (action: (String | Symbol | NilClass), ?namespace: (String | Symbol)?) ?{ () -> Object } -> Object? + def self.monitor_and_stop: ( + action: (String | Symbol | NilClass), + ?namespace: (String | Symbol)?, + ?opentelemetry_context: untyped, + ?opentelemetry_scope: [String, String]?, + ?opentelemetry_kind: Symbol?, + ?opentelemetry_relationship: Symbol? + ) ?{ () -> Object } -> Object? # Send an error to AppSignal regardless of the context. # @@ -385,6 +415,14 @@ module Appsignal # # _@param_ `error` — The error to send to AppSignal. # + # _@param_ `opentelemetry_kind` — In collector mode, the OpenTelemetry span kind: one of `:server`, `:consumer`, `:producer` or `:internal`. Defaults to `:server`. + # + # _@param_ `opentelemetry_relationship` — In collector mode, how an incoming `opentelemetry_context` relates to this transaction's span: one of `:parent`, `:link`, `:both` or `:none`. Defaults to `:parent`. + # + # _@param_ `opentelemetry_context` — In collector mode, an incoming OpenTelemetry trace context to relate this transaction's span to. + # + # _@param_ `opentelemetry_scope` — In collector mode, the OpenTelemetry instrumentation scope to record this transaction's spans under, given as a `[name, version]` pair. Defaults to the AppSignal scope. + # # Send an exception # ```ruby # begin @@ -405,7 +443,13 @@ module Appsignal # ``` # # _@see_ `https://docs.appsignal.com/ruby/instrumentation/exception-handling.html` — Exception handling guide - def self.send_error: (Exception error) ?{ (Transaction transaction) -> void } -> void + def self.send_error: ( + Exception error, + ?opentelemetry_context: untyped, + ?opentelemetry_scope: [String, String]?, + ?opentelemetry_kind: Symbol?, + ?opentelemetry_relationship: Symbol? + ) ?{ (Transaction transaction) -> void } -> void # Set an error on the current transaction. # @@ -477,6 +521,14 @@ module Appsignal # # _@param_ `exception` — The error to add to the current transaction. # + # _@param_ `opentelemetry_kind` — In collector mode, the OpenTelemetry span kind: one of `:server`, `:consumer`, `:producer` or `:internal`. Defaults to `:server`. Only used when a new transaction is created. + # + # _@param_ `opentelemetry_relationship` — In collector mode, how an incoming `opentelemetry_context` relates to this transaction's span: one of `:parent`, `:link`, `:both` or `:none`. Defaults to `:parent`. Only used when a new transaction is created. + # + # _@param_ `opentelemetry_context` — In collector mode, an incoming OpenTelemetry trace context to relate this transaction's span to. Only used when a new transaction is created. + # + # _@param_ `opentelemetry_scope` — In collector mode, the OpenTelemetry instrumentation scope to record this transaction's spans under, given as a `[name, version]` pair. Defaults to the AppSignal scope. + # # ```ruby # class SomeController < ApplicationController # def create @@ -498,7 +550,13 @@ module Appsignal # ``` # # _@see_ `https://docs.appsignal.com/ruby/instrumentation/exception-handling.html` — Exception handling guide - def self.report_error: (Exception exception) ?{ (Transaction transaction) -> void } -> void + def self.report_error: ( + Exception exception, + ?opentelemetry_context: untyped, + ?opentelemetry_scope: [String, String]?, + ?opentelemetry_kind: Symbol?, + ?opentelemetry_relationship: Symbol? + ) ?{ (Transaction transaction) -> void } -> void # Set a custom action name for the current transaction. # @@ -691,16 +749,62 @@ module Appsignal # _@see_ `https://docs.appsignal.com/guides/filter-data/filter-parameters.html` — Parameter filtering guide def self.add_params: (?(::Hash[String, Object] | ::Array[Object])? params) ?{ () -> (::Hash[String, Object] | ::Array[Object]) } -> void + # Add the request payload to the current transaction. + # + # The request payload is the parameters of an incoming request, such as + # the query string and the request body. In collector mode it maps to its + # own attribute, separate from the function parameters. + # + # Behaves like {#add_params}: merges when called multiple times, and a + # block takes precedence over the argument. + # + # _@param_ `params` — The request payload to add to the transaction. + # + # _@see_ `#add_function_parameters` + def self.add_request_payload: (?(::Hash[String, Object] | ::Array[Object])? params) ?{ () -> (::Hash[String, Object] | ::Array[Object]) } -> void + + # Add the function parameters to the current transaction. + # + # The function parameters are the arguments a background job or function + # was called with. In collector mode they map to their own attribute, + # separate from the request payload. + # + # Behaves like {#add_params}: merges when called multiple times, and a + # block takes precedence over the argument. + # + # _@param_ `params` — The function parameters to add to the transaction. + # + # _@see_ `#add_request_payload` + def self.add_function_parameters: (?(::Hash[String, Object] | ::Array[Object])? params) ?{ () -> (::Hash[String, Object] | ::Array[Object]) } -> void + + # Add the query parameters to the current transaction. + # + # The query parameters are the parameters parsed from an incoming + # request's query string. In collector mode they map to their own + # attribute, separate from the request payload and the function + # parameters. + # + # Behaves like {#add_params}: merges when called multiple times, and a + # block takes precedence over the argument. + # + # _@param_ `params` — The query parameters to add to the transaction. + # + # _@see_ `#add_request_payload` + def self.add_query_parameters: (?(::Hash[String, Object] | ::Array[Object])? params) ?{ () -> (::Hash[String, Object] | ::Array[Object]) } -> void + # Mark the parameters sample data to be set as an empty value. # - # Use this helper to unset request parameters / background job arguments - # and not report any for this transaction. + # Use this helper to report no parameters for this transaction, whatever + # their source. # - # If parameters would normally be added by AppSignal instrumentations of - # libraries, these parameters will not be added to the Transaction. + # This suppresses every params channel. In collector mode, where the + # request payload and the function parameters (a background job's + # arguments) are tracked as separate attributes, it suppresses both, not + # only the request payload. Parameters that an AppSignal integration would + # otherwise add are not added. # - # Calling {#add_params} after this helper will add new parameters to the - # transaction. + # Calling {#add_params}, {#add_request_payload} or + # {#add_function_parameters} after this helper adds parameters again. # # _@see_ `Transaction#set_empty_params!` # @@ -778,7 +882,7 @@ module Appsignal # Breadcrumbs can be used to trace what path a user has taken # before encountering an error. # - # Only the last 20 added breadcrumbs will be saved. + # At most 20 of the added breadcrumbs will be saved. # # _@param_ `category` — category of breadcrumb e.g. "UI", "Network", "Navigation", "Console". # @@ -833,6 +937,10 @@ module Appsignal # # _@param_ `body_format` — Enum for the type of event that is instrumented. Accepted values are {EventFormatter::DEFAULT} and {EventFormatter::SQL_BODY_FORMAT}, but we recommend you use {.instrument_sql} instead of {EventFormatter::SQL_BODY_FORMAT}. # + # _@param_ `opentelemetry_kind` — In collector mode, the OpenTelemetry span kind for the event's span, such as `:client` for an outgoing HTTP request. Defaults to the OpenTelemetry default of `:internal`. + # + # _@param_ `opentelemetry_scope` — In collector mode, the OpenTelemetry instrumentation scope to record the event's span under, given as a `[name, version]` pair. Defaults to the AppSignal scope. + # # _@return_ — Returns the block's return value. # # Simple instrumentation @@ -862,7 +970,9 @@ module Appsignal String name, ?String? title, ?String? body, - ?Integer body_format + ?Integer body_format, + ?opentelemetry_kind: Symbol?, + ?opentelemetry_scope: [String, String]? ) -> Object # Instrumentation helper for SQL queries. @@ -875,6 +985,10 @@ module Appsignal # # _@param_ `body` — SQL query that's being executed. # + # _@param_ `opentelemetry_kind` — In collector mode, the OpenTelemetry span kind for the event's span. Defaults to `:client`, because a query is an outgoing call to a datastore. Pass `:internal` for a query that is not an outgoing call. + # + # _@param_ `opentelemetry_scope` — In collector mode, the OpenTelemetry instrumentation scope to record the event's span under, given as a `[name, version]` pair. Defaults to the AppSignal scope. + # # _@return_ — Returns the block's return value. # # SQL query instrumentation @@ -898,7 +1012,13 @@ module Appsignal # _@see_ `https://docs.appsignal.com/ruby/instrumentation/instrumentation.html` — AppSignal custom instrumentation guide # # _@see_ `https://docs.appsignal.com/api/event-names.html` — AppSignal event naming guide - def self.instrument_sql: (String name, ?String? title, ?String? body) -> Object + def self.instrument_sql: ( + String name, + ?String? title, + ?String? body, + ?opentelemetry_kind: Symbol, + ?opentelemetry_scope: [String, String]? + ) -> Object # Convenience method for ignoring instrumentation events in a block of # code. @@ -991,6 +1111,36 @@ module Appsignal # _@return_ — True if valid and active for the current environment. def active?: () -> bool + # Check if collector mode is configured. + # + # Returns true when a non-empty `collector_endpoint` is set and the + # running Ruby version is at least {MIN_RUBY_VERSION_FOR_COLLECTOR_MODE}. + # On older Rubies, `collector_endpoint` is ignored (with a warning) and + # the AppSignal agent is used instead. + # + # This is the *intent* check — it answers "did the user ask for + # collector mode, and could we honor it?". It does not say whether the + # OpenTelemetry SDK actually booted. See {#collector_mode?} for that. + # + # Memoised: the result is cached on first call so hot paths avoid + # re-running the string-strip predicate, and so the unsupported-Ruby + # warning is emitted at most once per `Config` instance. + # + # _@return_ — True if collector mode is configured. + def collector_mode_configured?: () -> bool + + # Check if AppSignal is actively running in collector mode. + # + # True only if collector mode is {#collector_mode_configured? configured} + # *and* `Appsignal::OpenTelemetry.configure` has successfully booted the + # SDK in this process. Use this for backend dispatch on hot paths + # (metric and log emits): if the OTel boot failed, callers fall back to + # the agent backend rather than silently dropping data into no-op + # providers. + # + # _@return_ — True if collector mode is configured and started. + def collector_mode?: () -> bool + def yml_config_file?: () -> bool # Configuration DSL for use in configuration blocks. @@ -1526,7 +1676,21 @@ module Appsignal # transaction. # # _@param_ `namespace` — Namespace of the to be created transaction. - def self.create: (String namespace) -> Transaction + # + # _@param_ `opentelemetry_kind` — In collector mode, the OpenTelemetry span kind: one of `:server`, `:consumer`, `:producer` or `:internal`. Defaults to `:server`. + # + # _@param_ `opentelemetry_relationship` — In collector mode, how an incoming `opentelemetry_context` relates to this transaction's span: one of `:parent`, `:link`, `:both` or `:none`. Defaults to `:parent`. + # + # _@param_ `opentelemetry_context` — In collector mode, an incoming OpenTelemetry trace context to relate this transaction's span to. + # + # _@param_ `opentelemetry_scope` — In collector mode, the OpenTelemetry instrumentation scope to record this transaction's spans under, given as a `[name, version]` pair. Defaults to the AppSignal scope. + def self.create: ( + String namespace, + ?opentelemetry_context: untyped, + ?opentelemetry_scope: [String, String]?, + ?opentelemetry_kind: Symbol?, + ?opentelemetry_relationship: Symbol? + ) -> Transaction # Returns currently active transaction or a {NilTransaction} if none is # active. @@ -1558,6 +1722,49 @@ module Appsignal # _@see_ `https://docs.appsignal.com/guides/custom-data/sample-data.html` — Sample data guide def add_params: (?(::Hash[String, Object] | ::Array[Object])? given_params) ?{ () -> (::Hash[String, Object] | ::Array[Object]) } -> void + # Add the request payload to the transaction. + # + # These are the parameters of an incoming request, such as the query string + # and the request body. In collector mode they map to the request payload + # attribute. In agent mode they are the transaction's params. + # + # Behaves like {#add_params}: merges when called multiple times, and a + # block takes precedence over the argument. + # + # _@param_ `given_params` — The parameters to add to the transaction. + # + # _@see_ `#add_function_parameters` + def add_request_payload: (?(::Hash[String, Object] | ::Array[Object])? given_params) ?{ () -> (::Hash[String, Object] | ::Array[Object]) } -> void + + # Add the function parameters to the transaction. + # + # These are the arguments a background job or function was called with. In + # collector mode they map to the function parameters attribute. In agent + # mode they are the transaction's params. + # + # Behaves like {#add_params}: merges when called multiple times, and a + # block takes precedence over the argument. + # + # _@param_ `given_params` — The parameters to add to the transaction. + # + # _@see_ `#add_request_payload` + def add_function_parameters: (?(::Hash[String, Object] | ::Array[Object])? given_params) ?{ () -> (::Hash[String, Object] | ::Array[Object]) } -> void + + # Add the query parameters to the transaction. + # + # These are the parameters parsed from an incoming request's query string. + # In collector mode they map to their own attribute, separate from the + # request payload and the function parameters. In agent mode they are the + # transaction's params. + # + # Behaves like {#add_params}: merges when called multiple times, and a + # block takes precedence over the argument. + # + # _@param_ `given_params` — The parameters to add to the transaction. + # + # _@see_ `#add_request_payload` + def add_query_parameters: (?(::Hash[String, Object] | ::Array[Object])? given_params) ?{ () -> (::Hash[String, Object] | ::Array[Object]) } -> void + # Add tags to the transaction. # # When this method is called multiple times, it will merge the tags. @@ -1658,6 +1865,35 @@ module Appsignal # # _@param_ `start` — Queue start time in milliseconds. def set_queue_start: (Integer start) -> void + + # Add OpenTelemetry attributes to the span AppSignal is currently + # recording. + # + # In collector mode, AppSignal records a transaction as an OpenTelemetry + # span, and every instrumented event as a child span. This adds attributes + # to whichever of those spans is open right now: the innermost event + # started by {Appsignal::Helpers::Instrumentation#instrument}, or the + # transaction's own span when no event is open. + # + # Use this to describe what is being instrumented in OpenTelemetry's own + # terms, following the OpenTelemetry semantic conventions where they apply. + # Attributes have no equivalent outside collector mode, so this does + # nothing when collector mode is not active. + # + # _@param_ `attributes` — Attributes to add to the current span. Values that are not a String, Integer, Float or boolean are converted to a String. Nothing is added when this is nil or empty. + # + # Describing a database query + # ```ruby + # Appsignal.instrument("query.my_database") do + # Appsignal::Transaction.current.add_opentelemetry_attributes( + # "db.system.name" => "mysql" + # ) + # run_the_query + # end + # ``` + # + # _@see_ `https://opentelemetry.io/docs/specs/semconv/` — OpenTelemetry semantic conventions + def add_opentelemetry_attributes: (?::Hash[String, Object]? attributes) -> void end # Custom markers are used on AppSignal.com to indicate events in an @@ -1799,6 +2035,14 @@ module Appsignal # # _@param_ `action` — The action name for the transaction. The action name is required to be set for the transaction to be reported. The argument can be set to `nil` or `:set_later` if the action is set within the block with {#set_action}. This will not update the active transaction's action if {.monitor} is called when another transaction is already active. # + # _@param_ `opentelemetry_kind` — In collector mode, the OpenTelemetry span kind: one of `:server`, `:consumer`, `:producer` or `:internal`. Defaults to `:server`. + # + # _@param_ `opentelemetry_relationship` — In collector mode, how an incoming `opentelemetry_context` relates to this transaction's span: one of `:parent`, `:link`, `:both` or `:none`. Defaults to `:parent`. + # + # _@param_ `opentelemetry_context` — In collector mode, an incoming OpenTelemetry trace context to relate this transaction's span to. + # + # _@param_ `opentelemetry_scope` — In collector mode, the OpenTelemetry instrumentation scope to record this transaction's spans under, given as a `[name, version]` pair. Defaults to the AppSignal scope. + # # _@return_ — The value of the given block is returned. # Returns `nil` if there already is a transaction active and no block # was given. @@ -1889,7 +2133,14 @@ module Appsignal # ``` # # _@see_ `https://docs.appsignal.com/ruby/instrumentation/background-jobs.html` — Monitor guide - def monitor: (action: (String | Symbol | NilClass), ?namespace: (String | Symbol)?) ?{ () -> Object } -> Object? + def monitor: ( + action: (String | Symbol | NilClass), + ?namespace: (String | Symbol)?, + ?opentelemetry_context: untyped, + ?opentelemetry_scope: [String, String]?, + ?opentelemetry_kind: Symbol?, + ?opentelemetry_relationship: Symbol? + ) ?{ () -> Object } -> Object? # Instrument a block of code and stop AppSignal. # @@ -1903,10 +2154,25 @@ module Appsignal # # _@param_ `action` — The action name for the transaction. The action name is required to be set for the transaction to be reported. The argument can be set to `nil` or `:set_later` if the action is set within the block with {#set_action}. This will not update the active transaction's action if {.monitor} is called when another transaction is already active. # + # _@param_ `opentelemetry_kind` — In collector mode, the OpenTelemetry span kind: one of `:server`, `:consumer`, `:producer` or `:internal`. Defaults to `:server`. + # + # _@param_ `opentelemetry_relationship` — In collector mode, how an incoming `opentelemetry_context` relates to this transaction's span: one of `:parent`, `:link`, `:both` or `:none`. Defaults to `:parent`. + # + # _@param_ `opentelemetry_context` — In collector mode, an incoming OpenTelemetry trace context to relate this transaction's span to. + # + # _@param_ `opentelemetry_scope` — In collector mode, the OpenTelemetry instrumentation scope to record this transaction's spans under, given as a `[name, version]` pair. Defaults to the AppSignal scope. + # # _@return_ — The value of the given block is returned. # # _@see_ `monitor` - def monitor_and_stop: (action: (String | Symbol | NilClass), ?namespace: (String | Symbol)?) ?{ () -> Object } -> Object? + def monitor_and_stop: ( + action: (String | Symbol | NilClass), + ?namespace: (String | Symbol)?, + ?opentelemetry_context: untyped, + ?opentelemetry_scope: [String, String]?, + ?opentelemetry_kind: Symbol?, + ?opentelemetry_relationship: Symbol? + ) ?{ () -> Object } -> Object? # Send an error to AppSignal regardless of the context. # @@ -1925,6 +2191,14 @@ module Appsignal # # _@param_ `error` — The error to send to AppSignal. # + # _@param_ `opentelemetry_kind` — In collector mode, the OpenTelemetry span kind: one of `:server`, `:consumer`, `:producer` or `:internal`. Defaults to `:server`. + # + # _@param_ `opentelemetry_relationship` — In collector mode, how an incoming `opentelemetry_context` relates to this transaction's span: one of `:parent`, `:link`, `:both` or `:none`. Defaults to `:parent`. + # + # _@param_ `opentelemetry_context` — In collector mode, an incoming OpenTelemetry trace context to relate this transaction's span to. + # + # _@param_ `opentelemetry_scope` — In collector mode, the OpenTelemetry instrumentation scope to record this transaction's spans under, given as a `[name, version]` pair. Defaults to the AppSignal scope. + # # Send an exception # ```ruby # begin @@ -1945,7 +2219,13 @@ module Appsignal # ``` # # _@see_ `https://docs.appsignal.com/ruby/instrumentation/exception-handling.html` — Exception handling guide - def send_error: (Exception error) ?{ (Transaction transaction) -> void } -> void + def send_error: ( + Exception error, + ?opentelemetry_context: untyped, + ?opentelemetry_scope: [String, String]?, + ?opentelemetry_kind: Symbol?, + ?opentelemetry_relationship: Symbol? + ) ?{ (Transaction transaction) -> void } -> void # Set an error on the current transaction. # @@ -2017,6 +2297,14 @@ module Appsignal # # _@param_ `exception` — The error to add to the current transaction. # + # _@param_ `opentelemetry_kind` — In collector mode, the OpenTelemetry span kind: one of `:server`, `:consumer`, `:producer` or `:internal`. Defaults to `:server`. Only used when a new transaction is created. + # + # _@param_ `opentelemetry_relationship` — In collector mode, how an incoming `opentelemetry_context` relates to this transaction's span: one of `:parent`, `:link`, `:both` or `:none`. Defaults to `:parent`. Only used when a new transaction is created. + # + # _@param_ `opentelemetry_context` — In collector mode, an incoming OpenTelemetry trace context to relate this transaction's span to. Only used when a new transaction is created. + # + # _@param_ `opentelemetry_scope` — In collector mode, the OpenTelemetry instrumentation scope to record this transaction's spans under, given as a `[name, version]` pair. Defaults to the AppSignal scope. + # # ```ruby # class SomeController < ApplicationController # def create @@ -2038,7 +2326,13 @@ module Appsignal # ``` # # _@see_ `https://docs.appsignal.com/ruby/instrumentation/exception-handling.html` — Exception handling guide - def report_error: (Exception exception) ?{ (Transaction transaction) -> void } -> void + def report_error: ( + Exception exception, + ?opentelemetry_context: untyped, + ?opentelemetry_scope: [String, String]?, + ?opentelemetry_kind: Symbol?, + ?opentelemetry_relationship: Symbol? + ) ?{ (Transaction transaction) -> void } -> void # Set a custom action name for the current transaction. # @@ -2231,16 +2525,62 @@ module Appsignal # _@see_ `https://docs.appsignal.com/guides/filter-data/filter-parameters.html` — Parameter filtering guide def add_params: (?(::Hash[String, Object] | ::Array[Object])? params) ?{ () -> (::Hash[String, Object] | ::Array[Object]) } -> void + # Add the request payload to the current transaction. + # + # The request payload is the parameters of an incoming request, such as + # the query string and the request body. In collector mode it maps to its + # own attribute, separate from the function parameters. + # + # Behaves like {#add_params}: merges when called multiple times, and a + # block takes precedence over the argument. + # + # _@param_ `params` — The request payload to add to the transaction. + # + # _@see_ `#add_function_parameters` + def add_request_payload: (?(::Hash[String, Object] | ::Array[Object])? params) ?{ () -> (::Hash[String, Object] | ::Array[Object]) } -> void + + # Add the function parameters to the current transaction. + # + # The function parameters are the arguments a background job or function + # was called with. In collector mode they map to their own attribute, + # separate from the request payload. + # + # Behaves like {#add_params}: merges when called multiple times, and a + # block takes precedence over the argument. + # + # _@param_ `params` — The function parameters to add to the transaction. + # + # _@see_ `#add_request_payload` + def add_function_parameters: (?(::Hash[String, Object] | ::Array[Object])? params) ?{ () -> (::Hash[String, Object] | ::Array[Object]) } -> void + + # Add the query parameters to the current transaction. + # + # The query parameters are the parameters parsed from an incoming + # request's query string. In collector mode they map to their own + # attribute, separate from the request payload and the function + # parameters. + # + # Behaves like {#add_params}: merges when called multiple times, and a + # block takes precedence over the argument. + # + # _@param_ `params` — The query parameters to add to the transaction. + # + # _@see_ `#add_request_payload` + def add_query_parameters: (?(::Hash[String, Object] | ::Array[Object])? params) ?{ () -> (::Hash[String, Object] | ::Array[Object]) } -> void + # Mark the parameters sample data to be set as an empty value. # - # Use this helper to unset request parameters / background job arguments - # and not report any for this transaction. + # Use this helper to report no parameters for this transaction, whatever + # their source. # - # If parameters would normally be added by AppSignal instrumentations of - # libraries, these parameters will not be added to the Transaction. + # This suppresses every params channel. In collector mode, where the + # request payload and the function parameters (a background job's + # arguments) are tracked as separate attributes, it suppresses both, not + # only the request payload. Parameters that an AppSignal integration would + # otherwise add are not added. # - # Calling {#add_params} after this helper will add new parameters to the - # transaction. + # Calling {#add_params}, {#add_request_payload} or + # {#add_function_parameters} after this helper adds parameters again. # # _@see_ `Transaction#set_empty_params!` # @@ -2318,7 +2658,7 @@ module Appsignal # Breadcrumbs can be used to trace what path a user has taken # before encountering an error. # - # Only the last 20 added breadcrumbs will be saved. + # At most 20 of the added breadcrumbs will be saved. # # _@param_ `category` — category of breadcrumb e.g. "UI", "Network", "Navigation", "Console". # @@ -2373,6 +2713,10 @@ module Appsignal # # _@param_ `body_format` — Enum for the type of event that is instrumented. Accepted values are {EventFormatter::DEFAULT} and {EventFormatter::SQL_BODY_FORMAT}, but we recommend you use {.instrument_sql} instead of {EventFormatter::SQL_BODY_FORMAT}. # + # _@param_ `opentelemetry_kind` — In collector mode, the OpenTelemetry span kind for the event's span, such as `:client` for an outgoing HTTP request. Defaults to the OpenTelemetry default of `:internal`. + # + # _@param_ `opentelemetry_scope` — In collector mode, the OpenTelemetry instrumentation scope to record the event's span under, given as a `[name, version]` pair. Defaults to the AppSignal scope. + # # _@return_ — Returns the block's return value. # # Simple instrumentation @@ -2402,7 +2746,9 @@ module Appsignal String name, ?String? title, ?String? body, - ?Integer body_format + ?Integer body_format, + ?opentelemetry_kind: Symbol?, + ?opentelemetry_scope: [String, String]? ) -> Object # Instrumentation helper for SQL queries. @@ -2415,6 +2761,10 @@ module Appsignal # # _@param_ `body` — SQL query that's being executed. # + # _@param_ `opentelemetry_kind` — In collector mode, the OpenTelemetry span kind for the event's span. Defaults to `:client`, because a query is an outgoing call to a datastore. Pass `:internal` for a query that is not an outgoing call. + # + # _@param_ `opentelemetry_scope` — In collector mode, the OpenTelemetry instrumentation scope to record the event's span under, given as a `[name, version]` pair. Defaults to the AppSignal scope. + # # _@return_ — Returns the block's return value. # # SQL query instrumentation @@ -2438,7 +2788,13 @@ module Appsignal # _@see_ `https://docs.appsignal.com/ruby/instrumentation/instrumentation.html` — AppSignal custom instrumentation guide # # _@see_ `https://docs.appsignal.com/api/event-names.html` — AppSignal event naming guide - def instrument_sql: (String name, ?String? title, ?String? body) -> Object + def instrument_sql: ( + String name, + ?String? title, + ?String? body, + ?opentelemetry_kind: Symbol, + ?opentelemetry_scope: [String, String]? + ) -> Object # Convenience method for ignoring instrumentation events in a block of # code. @@ -2477,6 +2833,9 @@ module Appsignal class NotStartedError < Appsignal::InternalError def message: () -> String end + + module Metrics + end end # Extensions to Object for AppSignal method instrumentation. diff --git a/spec/integration/collector_mode_fork_spec.rb b/spec/integration/collector_mode_fork_spec.rb new file mode 100644 index 000000000..404df3b2d --- /dev/null +++ b/spec/integration/collector_mode_fork_spec.rb @@ -0,0 +1,40 @@ +# Skipped on JRuby because `Process.fork` raises NotImplementedError there, +# so the runner script exits before emitting anything. JRuby's collector +# mode still works for non-forking workloads (covered by the other +# collector_mode_*_spec files). +if DependencyHelper.opentelemetry_present? && !DependencyHelper.running_jruby? + require "opentelemetry/exporter/otlp" + require "opentelemetry/proto/collector/metrics/v1/metrics_service_pb" + + describe "Collector mode under fork" do + before { OTLPCollectorServer.clear } + + it "exports metrics emitted by a forked child without explicit re-init" do + # The OTel metrics SDK ships fork hooks (ForkHooks in + # `opentelemetry-metrics-sdk`) that restart the PeriodicMetricReader + # in the child after a fork. Those hooks are attached when + # `OpenTelemetry::SDK.configure` runs, which happens in + # `Appsignal::OpenTelemetry.configure` at boot. This spec locks + # down that chain: if a future refactor drops the `SDK.configure` + # call (or otherwise disconnects the fork hooks), the child's + # metric would queue inside a dead reader and never arrive. + Runner.new("collector_mode_fork", :env => OTLPCollectorServer.env).run + + metric_names = [] + loop do + req = OTLPCollectorServer.listen_to("/v1/metrics", :timeout => 2) + msg = Opentelemetry::Proto::Collector::Metrics::V1::ExportMetricsServiceRequest + .decode(req[:body]) + metric_names.concat( + msg.resource_metrics.flat_map do |rm| + rm.scope_metrics.flat_map { |sm| sm.metrics.map(&:name) } + end + ) + rescue RuntimeError + break + end + + expect(metric_names).to include("forked_child_counter") + end + end +end diff --git a/spec/integration/collector_mode_log_trace_correlation_spec.rb b/spec/integration/collector_mode_log_trace_correlation_spec.rb new file mode 100644 index 000000000..3fa339794 --- /dev/null +++ b/spec/integration/collector_mode_log_trace_correlation_spec.rb @@ -0,0 +1,46 @@ +if DependencyHelper.opentelemetry_present? + require "opentelemetry/exporter/otlp" + require "opentelemetry/proto/collector/trace/v1/trace_service_pb" + require "opentelemetry/proto/collector/logs/v1/logs_service_pb" + + describe "AppSignal collector mode log/trace correlation" do + before { OTLPCollectorServer.clear } + + it "stamps log records with the trace_id and span_id of the active span" do + runner = Runner.new("collector_mode_log_trace_correlation", + :env => OTLPCollectorServer.env) + runner.run + + trace_req = OTLPCollectorServer.listen_to("/v1/traces") + trace_msg = Opentelemetry::Proto::Collector::Trace::V1::ExportTraceServiceRequest + .decode(trace_req[:body]) + + log_req = OTLPCollectorServer.listen_to("/v1/logs") + log_msg = Opentelemetry::Proto::Collector::Logs::V1::ExportLogsServiceRequest + .decode(log_req[:body]) + + spans = trace_msg.resource_spans.flat_map { |rs| rs.scope_spans.flat_map(&:spans) } + root = spans.find { |s| s.parent_span_id.empty? } + event = spans.find { |s| s.name == "test.event" } + expect(root).not_to be_nil + expect(event).not_to be_nil + expect(event.parent_span_id).to eq(root.span_id) + + logs_by_body = log_msg.resource_logs + .flat_map { |rl| rl.scope_logs.flat_map(&:log_records) } + .to_h { |lr| [lr.body.string_value, lr] } + + expect(logs_by_body.keys).to match_array(["before event", "inside event", "after event"]) + + # Logs emitted outside any event span carry the root span's ids. + expect(logs_by_body["before event"].trace_id).to eq(root.trace_id) + expect(logs_by_body["before event"].span_id).to eq(root.span_id) + expect(logs_by_body["after event"].trace_id).to eq(root.trace_id) + expect(logs_by_body["after event"].span_id).to eq(root.span_id) + + # The log emitted inside `instrument` carries the event span's ids. + expect(logs_by_body["inside event"].trace_id).to eq(event.trace_id) + expect(logs_by_body["inside event"].span_id).to eq(event.span_id) + end + end +end diff --git a/spec/integration/collector_mode_logs_spec.rb b/spec/integration/collector_mode_logs_spec.rb new file mode 100644 index 000000000..ed0a164e0 --- /dev/null +++ b/spec/integration/collector_mode_logs_spec.rb @@ -0,0 +1,45 @@ +if DependencyHelper.opentelemetry_present? + require "opentelemetry/exporter/otlp" + require "opentelemetry/proto/collector/logs/v1/logs_service_pb" + + describe "AppSignal collector mode log helpers" do + before { OTLPCollectorServer.clear } + + it "emits OTLP log records through Appsignal::Logger" do + runner = Runner.new("collector_mode_logs", :env => OTLPCollectorServer.env) + runner.run + + log_req = OTLPCollectorServer.listen_to("/v1/logs") + log_msg = Opentelemetry::Proto::Collector::Logs::V1::ExportLogsServiceRequest + .decode(log_req[:body]) + + scope_logs = log_msg.resource_logs.flat_map(&:scope_logs) + expect(scope_logs.map { |sl| sl.scope.name }).to include("appsignal-logger") + + records = scope_logs.flat_map(&:log_records) + by_body = records.to_h { |record| [record.body.string_value, record] } + + expect(by_body.keys).to include("info line", "warn line", "error line") + + info_record = by_body.fetch("info line") + expect(info_record.severity_number).to eq(:SEVERITY_NUMBER_INFO) + expect(info_record.severity_text).to eq("INFO") + expect(attribute_value(info_record, "appsignal.group").string_value).to eq("my-group") + expect(attribute_value(info_record, "appsignal.format").string_value).to eq("json") + expect(attribute_value(info_record, "service").string_value).to eq("runner") + expect(attribute_value(info_record, "tag").string_value).to eq("value") + + warn_record = by_body.fetch("warn line") + expect(warn_record.severity_number).to eq(:SEVERITY_NUMBER_WARN) + expect(warn_record.severity_text).to eq("WARN") + + error_record = by_body.fetch("error line") + expect(error_record.severity_number).to eq(:SEVERITY_NUMBER_ERROR) + expect(error_record.severity_text).to eq("ERROR") + end + + def attribute_value(record, key) + record.attributes.find { |kv| kv.key == key }&.value + end + end +end diff --git a/spec/integration/collector_mode_metrics_spec.rb b/spec/integration/collector_mode_metrics_spec.rb new file mode 100644 index 000000000..fa40a4be4 --- /dev/null +++ b/spec/integration/collector_mode_metrics_spec.rb @@ -0,0 +1,50 @@ +if DependencyHelper.opentelemetry_present? + require "opentelemetry/exporter/otlp" + require "opentelemetry/proto/collector/metrics/v1/metrics_service_pb" + + describe "AppSignal collector mode metric helpers" do + before { OTLPCollectorServer.clear } + + it "emits OTLP metrics for set_gauge, increment_counter and add_distribution_value" do + runner = Runner.new("collector_mode_metrics", :env => OTLPCollectorServer.env) + runner.run + + metric_req = OTLPCollectorServer.listen_to("/v1/metrics") + metric_msg = Opentelemetry::Proto::Collector::Metrics::V1::ExportMetricsServiceRequest + .decode(metric_req[:body]) + + scope_metrics = metric_msg.resource_metrics.flat_map(&:scope_metrics) + expect(scope_metrics.map { |sm| sm.scope.name }).to include("appsignal-helpers") + + metrics_by_name = scope_metrics + .flat_map(&:metrics) + .to_h { |metric| [metric.name, metric] } + + expect(metrics_by_name.keys).to include("test_counter", "test_gauge", "test_distribution") + + counter = metrics_by_name.fetch("test_counter") + expect(counter.data).to eq(:sum) + counter_point = counter.sum.data_points.first + expect(counter_point.as_double).to eq(1.0) + expect(attribute_value(counter_point, "tag")).to eq("value") + + gauge = metrics_by_name.fetch("test_gauge") + expect(gauge.data).to eq(:gauge) + gauge_point = gauge.gauge.data_points.first + expect(gauge_point.as_double).to eq(42.5) + expect(attribute_value(gauge_point, "tag")).to eq("value") + + histogram = metrics_by_name.fetch("test_distribution") + expect(histogram.data).to eq(:histogram) + histogram_point = histogram.histogram.data_points.first + expect(histogram_point.count).to eq(1) + expect(histogram_point.sum).to be_within(0.0001).of(0.123) + expect(attribute_value(histogram_point, "tag")).to eq("value") + end + + def attribute_value(data_point, key) + pair = data_point.attributes.find { |attr| attr.key == key } + pair&.value&.string_value + end + end +end diff --git a/spec/integration/collector_mode_mixed_api_spec.rb b/spec/integration/collector_mode_mixed_api_spec.rb new file mode 100644 index 000000000..81089d79f --- /dev/null +++ b/spec/integration/collector_mode_mixed_api_spec.rb @@ -0,0 +1,54 @@ +if DependencyHelper.opentelemetry_present? + require "opentelemetry/exporter/otlp" + require "opentelemetry/proto/collector/trace/v1/trace_service_pb" + + describe "AppSignal collector mode mixing OTel and AppSignal APIs" do + before { OTLPCollectorServer.clear } + + it "nests instrument and monitor spans correctly relative to OTel context" do + runner = Runner.new("collector_mode_mixed_api", :env => OTLPCollectorServer.env) + runner.run + + trace_req = OTLPCollectorServer.listen_to("/v1/traces") + trace_msg = Opentelemetry::Proto::Collector::Trace::V1::ExportTraceServiceRequest + .decode(trace_req[:body]) + + spans = trace_msg.resource_spans.flat_map { |rs| rs.scope_spans.flat_map(&:spans) } + by_name = spans.to_h { |s| [s.name, s] } + + outer = by_name.fetch("outer.otel") + # `Appsignal.monitor` renames its root span to the action, so look it + # up by SpanKind (SERVER is the subtrace root the collector keys on) + # rather than by name. + monitor_root = spans.find { |s| s.kind == :SPAN_KIND_SERVER } + expect(monitor_root).not_to be_nil + event_with_otel_child = by_name.fetch("event.with.otel.child") + inner_otel = by_name.fetch("inner.otel.inside_instrument") + manual_otel = by_name.fetch("manual.otel.in_monitor") + event_under_manual = by_name.fetch("event.under.manual.otel") + + # Appsignal.monitor ignores the ambient OTel context and starts a + # fresh trace. + expect(monitor_root.parent_span_id).to be_empty + expect(monitor_root.trace_id).not_to eq(outer.trace_id) + + # Events created via Appsignal.instrument nest under whichever + # span is current at the time -- the monitor root in the simple + # case. + expect(event_with_otel_child.parent_span_id).to eq(monitor_root.span_id) + expect(event_with_otel_child.trace_id).to eq(monitor_root.trace_id) + + # A raw OTel span created inside an Appsignal.instrument block is + # a child of the event span. + expect(inner_otel.parent_span_id).to eq(event_with_otel_child.span_id) + expect(inner_otel.trace_id).to eq(monitor_root.trace_id) + + # In reverse: when a raw OTel span is current, an + # Appsignal.instrument inside it nests under that OTel span + # (not under the monitor root directly). + expect(manual_otel.parent_span_id).to eq(monitor_root.span_id) + expect(event_under_manual.parent_span_id).to eq(manual_otel.span_id) + expect(event_under_manual.trace_id).to eq(monitor_root.trace_id) + end + end +end diff --git a/spec/integration/collector_mode_spec.rb b/spec/integration/collector_mode_spec.rb new file mode 100644 index 000000000..03f42e127 --- /dev/null +++ b/spec/integration/collector_mode_spec.rb @@ -0,0 +1,105 @@ +# Collector mode is gated on Ruby 3.1+ (see +# `Appsignal::Config::MIN_RUBY_VERSION_FOR_COLLECTOR_MODE`). On older +# Rubies the config gate forces collector_mode? to false; that path is +# covered by unit tests in `spec/lib/appsignal/config_spec.rb`. +if DependencyHelper.opentelemetry_present? + # Use the OTLP proto Ruby stubs shipped inside the + # `opentelemetry-exporter-otlp` gem to decode the bodies that the runner + # script posts to the mock collector server. + require "opentelemetry/exporter/otlp" + require "opentelemetry/proto/collector/trace/v1/trace_service_pb" + require "opentelemetry/proto/collector/metrics/v1/metrics_service_pb" + require "opentelemetry/proto/collector/logs/v1/logs_service_pb" + + describe "AppSignal collector mode" do + before { OTLPCollectorServer.clear } + + # Asserts that the OTLP Resource (proto message) carries every AppSignal + # config attribute the runner script sets, with the right types, plus the + # `telemetry.sdk.*` attributes from the OTel SDK's default resource. Used + # for traces, metrics and logs alike so all three signal types are checked. + def expect_appsignal_resource(resource) + attrs = resource.attributes.to_h { |kv| [kv.key, kv.value] } + defaults = Runner::DEFAULT_ENV + + expect(attrs["service.name"].string_value).to eq("collector-mode-test-service") + expect(attrs["host.name"].string_value).to eq("test-host") + expect(attrs["appsignal.config.name"].string_value) + .to eq(defaults.fetch("APPSIGNAL_APP_NAME")) + expect(attrs["appsignal.config.environment"].string_value) + .to eq(defaults.fetch("APPSIGNAL_APP_ENV")) + expect(attrs["appsignal.config.push_api_key"].string_value) + .to eq(defaults.fetch("APPSIGNAL_PUSH_API_KEY")) + expect(attrs["appsignal.config.revision"].string_value).to eq("abc1234") + expect(attrs["appsignal.config.language_integration"].string_value).to eq("ruby") + + expect(attrs["appsignal.config.filter_attributes"].array_value.values.map(&:string_value)) + .to eq(["password", "secret"]) + expect( + attrs["appsignal.config.filter_request_payload"].array_value.values.map(&:string_value) + ).to eq(["payload-key"]) + expect(attrs["appsignal.config.ignore_actions"].array_value.values.map(&:string_value)) + .to eq(["IgnoredController#action"]) + expect(attrs["appsignal.config.ignore_namespaces"].array_value.values.map(&:string_value)) + .to eq(["background"]) + expect(attrs["appsignal.config.send_request_payload"].bool_value).to eq(false) + + # AppSignal defaults that still route into the resource. + expect(attrs["appsignal.config.request_headers"].array_value.values.map(&:string_value)) + .to include("HTTP_ACCEPT") + expect(attrs["appsignal.config.send_request_session_data"].bool_value).to eq(true) + + # OTel SDK metadata, kept by merging the AppSignal resource with `Resource.default`. + expect(attrs["telemetry.sdk.name"].string_value).to eq("opentelemetry") + expect(attrs["telemetry.sdk.language"].string_value).to eq("ruby") + + # Attributes that default to nil or [] are omitted so the collector applies defaults. + %w[ + appsignal.config.filter_function_parameters + appsignal.config.filter_request_query_parameters + appsignal.config.filter_request_session_data + appsignal.config.ignore_errors + appsignal.config.response_headers + appsignal.config.send_function_parameters + appsignal.config.send_request_query_parameters + ].each do |key| + expect(attrs).to_not have_key(key), + "expected #{key.inspect} to be omitted from the resource, got #{attrs[key].inspect}" + end + end + + it "configures collector mode and emits OTLP traces, metrics, and logs" do + runner = Runner.new("collector_mode_emit", :env => OTLPCollectorServer.env) + runner.run + + # Config wiring: the child process saw the value and computed the predicate. + expect(runner.output).to include("collector_endpoint=#{OTLPCollectorServer.endpoint}") + expect(runner.output).to include("collector_mode?=true") + + trace_req = OTLPCollectorServer.listen_to("/v1/traces") + trace_msg = Opentelemetry::Proto::Collector::Trace::V1::ExportTraceServiceRequest + .decode(trace_req[:body]) + span_names = trace_msg.resource_spans + .flat_map { |rs| rs.scope_spans.flat_map { |ss| ss.spans.map(&:name) } } + expect(span_names).to include("test-span") + expect_appsignal_resource(trace_msg.resource_spans.first.resource) + + metric_req = OTLPCollectorServer.listen_to("/v1/metrics") + metric_msg = Opentelemetry::Proto::Collector::Metrics::V1::ExportMetricsServiceRequest + .decode(metric_req[:body]) + metric_names = metric_msg.resource_metrics + .flat_map { |rm| rm.scope_metrics.flat_map { |sm| sm.metrics.map(&:name) } } + expect(metric_names).to include("test_counter") + expect_appsignal_resource(metric_msg.resource_metrics.first.resource) + + log_req = OTLPCollectorServer.listen_to("/v1/logs") + log_msg = Opentelemetry::Proto::Collector::Logs::V1::ExportLogsServiceRequest + .decode(log_req[:body]) + log_bodies = log_msg.resource_logs.flat_map do |rl| + rl.scope_logs.flat_map { |sl| sl.log_records.map { |lr| lr.body.string_value } } + end + expect(log_bodies).to include("test-log-line") + expect_appsignal_resource(log_msg.resource_logs.first.resource) + end + end +end diff --git a/spec/integration/collector_mode_stop_flush_spec.rb b/spec/integration/collector_mode_stop_flush_spec.rb new file mode 100644 index 000000000..0a384820d --- /dev/null +++ b/spec/integration/collector_mode_stop_flush_spec.rb @@ -0,0 +1,31 @@ +if DependencyHelper.opentelemetry_present? + require "opentelemetry/exporter/otlp" + require "opentelemetry/proto/collector/metrics/v1/metrics_service_pb" + require "opentelemetry/proto/collector/logs/v1/logs_service_pb" + + describe "AppSignal.stop in collector mode" do + before { OTLPCollectorServer.clear } + + it "flushes buffered OTel telemetry by shutting the providers down" do + runner = Runner.new("collector_mode_stop_flush", :env => OTLPCollectorServer.env) + runner.run + + metric_req = OTLPCollectorServer.listen_to("/v1/metrics") + metric_msg = Opentelemetry::Proto::Collector::Metrics::V1::ExportMetricsServiceRequest + .decode(metric_req[:body]) + + metric_names = metric_msg.resource_metrics + .flat_map { |rm| rm.scope_metrics.flat_map { |sm| sm.metrics.map(&:name) } } + expect(metric_names).to include("stop_counter") + + log_req = OTLPCollectorServer.listen_to("/v1/logs") + log_msg = Opentelemetry::Proto::Collector::Logs::V1::ExportLogsServiceRequest + .decode(log_req[:body]) + + log_bodies = log_msg.resource_logs.flat_map do |rl| + rl.scope_logs.flat_map { |sl| sl.log_records.map { |lr| lr.body.string_value } } + end + expect(log_bodies).to include("stop log line") + end + end +end diff --git a/spec/integration/collector_mode_traces_spec.rb b/spec/integration/collector_mode_traces_spec.rb new file mode 100644 index 000000000..32e341903 --- /dev/null +++ b/spec/integration/collector_mode_traces_spec.rb @@ -0,0 +1,72 @@ +if DependencyHelper.opentelemetry_present? + require "opentelemetry/exporter/otlp" + require "opentelemetry/proto/collector/trace/v1/trace_service_pb" + + describe "AppSignal collector mode trace API" do + before { OTLPCollectorServer.clear } + + it "emits OTLP spans for Appsignal.monitor with nested Appsignal.instrument" do + runner = Runner.new("collector_mode_traces", :env => OTLPCollectorServer.env) + runner.run + + trace_req = OTLPCollectorServer.listen_to("/v1/traces") + trace_msg = Opentelemetry::Proto::Collector::Trace::V1::ExportTraceServiceRequest + .decode(trace_req[:body]) + + spans = trace_msg.resource_spans.flat_map { |rs| rs.scope_spans.flat_map(&:spans) } + by_name = spans.to_h { |s| [s.name, s] } + + # Root span: SERVER kind from `monitor` (http_request namespace), no parent. + root = spans.find { |s| s.parent_span_id.empty? } + expect(root).not_to be_nil + expect(root.kind).to eq(:SPAN_KIND_SERVER) + + # The "http_request" namespace is converted to "web" on the way out. + expect(attribute_value(root, "appsignal.namespace")).to eq("web") + + # Event spans for each instrumented block are present. Every event span + # name leads with the event name. The title-less events keep just that + # name. The SQL event has a human-readable title ("Find user"), so its + # name adds the title in parentheses after the event name. + expect(by_name.keys).to include("template.render", "partial.render") + sql = by_name["active_record.sql (Find user)"] + expect(sql).not_to be_nil + + # Nested instrument calls produce a parent/child chain rooted at the monitor span. + expect(by_name["partial.render"].parent_span_id).to eq(by_name["template.render"].span_id) + expect(by_name["template.render"].parent_span_id).to eq(root.span_id) + expect(sql.parent_span_id).to eq(root.span_id) + + # All spans share one trace id. + expect(spans.map(&:trace_id).uniq.size).to eq(1) + + # SQL formatter applied at the OTel backend: body becomes `db.query.text` and + # `db.system.name` is set so the collector can sanitize. + expect(attribute_value(sql, "db.query.text")).to eq("SELECT * FROM users") + expect(attribute_value(sql, "db.system.name")).to eq("other_sql") + + # A query is an outgoing call to a datastore, so its span is a client + # span. The runner does not pass a span kind, so this is the default. + expect(sql.kind).to eq(:SPAN_KIND_CLIENT) + + # Allocation counts: the transaction total on the root span and a per-event + # count on each event span. The values are real allocations, so assert the + # wiring (present and non-negative) rather than exact counts. The monitored + # block always allocates, so the transaction total is positive. + expect(int_attribute_value(root, "appsignal.transaction_allocation_count")).to be > 0 + expect(int_attribute_value(root, "appsignal.self_allocation_count")).to be >= 0 + expect(int_attribute_value(sql, "appsignal.allocation_count")).to be >= 0 + expect(int_attribute_value(sql, "appsignal.self_allocation_count")).to be >= 0 + end + + def attribute_value(span, key) + pair = span.attributes.find { |attr| attr.key == key } + pair&.value&.string_value + end + + def int_attribute_value(span, key) + pair = span.attributes.find { |attr| attr.key == key } + pair&.value&.int_value + end + end +end diff --git a/spec/integration/diagnose b/spec/integration/diagnose index 8a4bcb7f7..a30de805d 160000 --- a/spec/integration/diagnose +++ b/spec/integration/diagnose @@ -1 +1 @@ -Subproject commit 8a4bcb7f70090cf8439ad204d366389b88593350 +Subproject commit a30de805d4ff158afc4252b46211e81da238a12e diff --git a/spec/integration/runner.rb b/spec/integration/runner.rb index aa1d9d7be..18bf1c08d 100644 --- a/spec/integration/runner.rb +++ b/spec/integration/runner.rb @@ -1,9 +1,36 @@ +require "fileutils" +require "tmpdir" + class Runner + # Env key the Runner sets itself (see `run`). Callers can't pass it via + # `env:` — the Runner owns the per-run working directory. + WORKING_DIRECTORY_ENV = "APPSIGNAL_WORKING_DIRECTORY_PATH".freeze + + # Config every runner script needs, supplied as env vars so the scripts + # don't each hardcode it; `Appsignal.start` reads it from the environment. + # Specs assert against these values via this constant instead of repeating + # the literals. Overridable per run by passing the same key in `env:`. + DEFAULT_ENV = { + "APPSIGNAL_APP_NAME" => "integration-runner", + "APPSIGNAL_APP_ENV" => "test", + "APPSIGNAL_PUSH_API_KEY" => "abc" + }.freeze + attr_reader :pid, :output, :status - def initialize(name) + # @param env [Hash] Extra environment variables to set in the spawned + # child process, e.g. `"APPSIGNAL_COLLECTOR_ENDPOINT"` to run against the + # mock collector. Merged over {DEFAULT_ENV}; must not overlap with the + # Runner-managed keys. + def initialize(name, env: {}) + if env.key?(WORKING_DIRECTORY_ENV) + raise ArgumentError, + "#{WORKING_DIRECTORY_ENV} is managed by Runner and can't be passed via `env:`" + end + @script_name = name @script_file = "#{@script_name}.rb" + @env = DEFAULT_ENV.merge(env) @pid = nil @output = nil @status = nil @@ -12,6 +39,14 @@ def initialize(name) @read, @write = IO.pipe @has_run = false @finished = false + # Per-run working directory. Passed to the subprocess via + # `APPSIGNAL_WORKING_DIRECTORY_PATH` so runner scripts don't have to + # manage one themselves. Created under `/tmp` rather than the default + # `$TMPDIR` because macOS's default tmpdir lives under + # `/var/folders/...` and the resulting agent socket path exceeds the + # 104-char macOS unix-socket limit, which would hang + # `Appsignal::Extension.stop`. Cleaned up after the process exits. + @working_dir = Dir.mktmpdir("appsignal-runner-", "/tmp") end def has_run? @@ -29,6 +64,7 @@ def run executable = jruby? ? "jruby" : "ruby" directory = File.join(__dir__, "runners") @pid = spawn( + @env.merge(WORKING_DIRECTORY_ENV => @working_dir), "#{executable} #{@script_file}", { [:out, :err] => @write, @@ -48,6 +84,16 @@ def run end read_output @finished = true + + # Asked of the status rather than the exit code, because a process killed by + # a signal has no exit code. Reading one would raise a NoMethodError on nil + # and hide both the signal and the output below. + return if @status.success? + + raise "Runner '#{@script_file}' did not exit successfully (#{@status}).\n" \ + "Output:\n#{@output}" + ensure + FileUtils.remove_entry(@working_dir) if @working_dir && File.exist?(@working_dir) end private diff --git a/spec/integration/runners/collector_mode_emit.rb b/spec/integration/runners/collector_mode_emit.rb new file mode 100644 index 000000000..c07be6cf8 --- /dev/null +++ b/spec/integration/runners/collector_mode_emit.rb @@ -0,0 +1,42 @@ +PROJECT_ROOT = "../../../".freeze +$LOAD_PATH.unshift(File.expand_path("ext", PROJECT_ROOT)) +$LOAD_PATH.unshift(File.expand_path("lib", PROJECT_ROOT)) + +require "appsignal" + +# Name, environment and push API key come from the env vars the Runner +# injects (see `Runner::DEFAULT_ENV`). The options below are specific to +# this test's resource-attribute assertions. +Appsignal.configure do |config| + config.service_name = "collector-mode-test-service" + config.hostname = "test-host" + config.revision = "abc1234" + config.filter_attributes = ["password", "secret"] + config.filter_request_payload = ["payload-key"] + config.send_request_payload = false + config.ignore_actions = ["IgnoredController#action"] + config.ignore_namespaces = ["background"] +end + +Appsignal.start + +# Print config state so the spec can verify the option round-trips end-to-end. +puts "collector_endpoint=#{Appsignal.config[:collector_endpoint]}" +puts "collector_mode?=#{Appsignal.config.collector_mode?}" + +# Emit one of each OTLP signal through the OpenTelemetry SDK that +# `Appsignal::OpenTelemetry.configure` has just set up. +tracer = OpenTelemetry.tracer_provider.tracer("collector-mode-runner") +tracer.in_span("test-span") { |span| span.set_attribute("test.key", "test.value") } + +meter = OpenTelemetry.meter_provider.meter("collector-mode-runner") +meter.create_counter("test_counter").add(1) + +logger = OpenTelemetry.logger_provider.logger(:name => "collector-mode-runner") +logger.on_emit(:severity_text => "INFO", :body => "test-log-line") + +# Shut AppSignal down so the OTel providers drain their buffers and the +# spec sees the queued requests deterministically. +Appsignal.stop("integration test") + +puts "DONE" diff --git a/spec/integration/runners/collector_mode_fork.rb b/spec/integration/runners/collector_mode_fork.rb new file mode 100644 index 000000000..f12364ff1 --- /dev/null +++ b/spec/integration/runners/collector_mode_fork.rb @@ -0,0 +1,33 @@ +# A short export interval so the spec can wait a couple of seconds and +# see the periodic export tick after fork. The OTel SDK reads this env +# var when constructing the `PeriodicMetricReader`, so it has to be set +# before `Appsignal.start` boots the OTel providers. +ENV["OTEL_METRIC_EXPORT_INTERVAL"] = "500" # ms + +PROJECT_ROOT = "../../../".freeze +$LOAD_PATH.unshift(File.expand_path("ext", PROJECT_ROOT)) +$LOAD_PATH.unshift(File.expand_path("lib", PROJECT_ROOT)) + +require "appsignal" + +# Name, environment and push API key come from the env vars the Runner +# injects (see `Runner::DEFAULT_ENV`); `Appsignal.start` loads them. +Appsignal.start + +# In the child: emit a metric and wait long enough for the periodic +# exporter to tick. We deliberately do NOT call any fork-aware code +# (no `Appsignal.forked`, no `force_flush`, no `Appsignal.stop`) — the +# OTel SDK's built-in fork hooks should restart the background reader +# thread on its own, triggered by `Appsignal::OpenTelemetry.configure` +# having called `OpenTelemetry::SDK.configure` at boot time. +child_pid = Process.fork do + Appsignal.increment_counter("forked_child_counter", 1) + sleep 2 +rescue => e + warn "child failed: #{e.class}: #{e.message}" + warn e.backtrace + exit!(1) +end + +_, status = Process.waitpid2(child_pid) +exit(status.exitstatus || 1) diff --git a/spec/integration/runners/collector_mode_log_trace_correlation.rb b/spec/integration/runners/collector_mode_log_trace_correlation.rb new file mode 100644 index 000000000..3e140385c --- /dev/null +++ b/spec/integration/runners/collector_mode_log_trace_correlation.rb @@ -0,0 +1,27 @@ +PROJECT_ROOT = "../../../".freeze +$LOAD_PATH.unshift(File.expand_path("ext", PROJECT_ROOT)) +$LOAD_PATH.unshift(File.expand_path("lib", PROJECT_ROOT)) + +require "appsignal" + +# Name, environment and push API key come from the env vars the Runner +# injects (see `Runner::DEFAULT_ENV`); `Appsignal.start` loads them. +Appsignal.start + +logger = Appsignal::Logger.new("correlation-group") + +# Emit one log under the root span (no active event), one nested inside +# an `Appsignal.instrument` event, and one after the event closes. Each +# should carry the trace_id/span_id of whichever span was current at +# emit time. +Appsignal.monitor(:action => "TestAction") do + logger.info("before event") + Appsignal.instrument("test.event") do + logger.info("inside event") + end + logger.info("after event") +end + +Appsignal.stop("integration test") + +puts "DONE" diff --git a/spec/integration/runners/collector_mode_logs.rb b/spec/integration/runners/collector_mode_logs.rb new file mode 100644 index 000000000..61dbb0850 --- /dev/null +++ b/spec/integration/runners/collector_mode_logs.rb @@ -0,0 +1,29 @@ +PROJECT_ROOT = "../../../".freeze +$LOAD_PATH.unshift(File.expand_path("ext", PROJECT_ROOT)) +$LOAD_PATH.unshift(File.expand_path("lib", PROJECT_ROOT)) + +require "appsignal" + +# Name, environment and push API key come from the env vars the Runner +# injects (see `Runner::DEFAULT_ENV`); `Appsignal.start` loads them. +Appsignal.start + +# Exercise Appsignal::Logger under collector mode: in this mode each emit +# should route through Appsignal::Logger::OpenTelemetryBackend and reach +# the mock collector at the configured `/v1/logs` endpoint. +logger = Appsignal::Logger.new( + "my-group", + :level => ::Logger::DEBUG, + :format => Appsignal::Logger::JSON, + :attributes => { "service" => "runner" } +) + +logger.info("info line", :tag => "value") +logger.warn("warn line") +logger.error("error line") + +# Shut AppSignal down so the OTel providers drain their buffers and the +# spec sees the queued request deterministically. +Appsignal.stop("integration test") + +puts "DONE" diff --git a/spec/integration/runners/collector_mode_metrics.rb b/spec/integration/runners/collector_mode_metrics.rb new file mode 100644 index 000000000..7ec06e79b --- /dev/null +++ b/spec/integration/runners/collector_mode_metrics.rb @@ -0,0 +1,22 @@ +PROJECT_ROOT = "../../../".freeze +$LOAD_PATH.unshift(File.expand_path("ext", PROJECT_ROOT)) +$LOAD_PATH.unshift(File.expand_path("lib", PROJECT_ROOT)) + +require "appsignal" + +# Name, environment and push API key come from the env vars the Runner +# injects (see `Runner::DEFAULT_ENV`); `Appsignal.start` loads them. +Appsignal.start + +# Exercise the public AppSignal metric helpers; in collector mode these +# should route through the OpenTelemetry backend and reach the mock +# collector at the configured `/v1/metrics` endpoint. +Appsignal.increment_counter("test_counter", 1, :tag => "value") +Appsignal.set_gauge("test_gauge", 42.5, :tag => "value") +Appsignal.add_distribution_value("test_distribution", 0.123, :tag => "value") + +# Shut AppSignal down so the OTel providers drain their buffers and the +# spec sees the queued request deterministically. +Appsignal.stop("integration test") + +puts "DONE" diff --git a/spec/integration/runners/collector_mode_mixed_api.rb b/spec/integration/runners/collector_mode_mixed_api.rb new file mode 100644 index 000000000..9ef3eaaca --- /dev/null +++ b/spec/integration/runners/collector_mode_mixed_api.rb @@ -0,0 +1,38 @@ +PROJECT_ROOT = "../../../".freeze +$LOAD_PATH.unshift(File.expand_path("ext", PROJECT_ROOT)) +$LOAD_PATH.unshift(File.expand_path("lib", PROJECT_ROOT)) + +require "appsignal" + +# Name, environment and push API key come from the env vars the Runner +# injects (see `Runner::DEFAULT_ENV`); `Appsignal.start` loads them. +Appsignal.start + +tracer = OpenTelemetry.tracer_provider.tracer("integration-test") + +# Outer raw OTel span. `Appsignal.monitor` is called while this span is +# current; the monitor's root must NOT inherit this as its parent. +tracer.in_span("outer.otel") do + Appsignal.monitor(:action => "MonitoredAction") do + # A raw OTel span created inside an `Appsignal.instrument` block + # should be a child of the event span. + Appsignal.instrument("event.with.otel.child") do + tracer.in_span("inner.otel.inside_instrument") do + # No body; the span's parentage is what the spec asserts on. + end + end + + # In reverse: when a raw OTel span is current, an + # `Appsignal.instrument` invoked inside it should produce an event + # span that is a child of that OTel span (not of the monitor root). + tracer.in_span("manual.otel.in_monitor") do + Appsignal.instrument("event.under.manual.otel") do + # No body; the span's parentage is what the spec asserts on. + end + end + end +end + +Appsignal.stop("integration test") + +puts "DONE" diff --git a/spec/integration/runners/collector_mode_stop_flush.rb b/spec/integration/runners/collector_mode_stop_flush.rb new file mode 100644 index 000000000..94db756a9 --- /dev/null +++ b/spec/integration/runners/collector_mode_stop_flush.rb @@ -0,0 +1,23 @@ +PROJECT_ROOT = "../../../".freeze +$LOAD_PATH.unshift(File.expand_path("ext", PROJECT_ROOT)) +$LOAD_PATH.unshift(File.expand_path("lib", PROJECT_ROOT)) + +require "appsignal" + +# Name, environment and push API key come from the env vars the Runner +# injects (see `Runner::DEFAULT_ENV`); `Appsignal.start` loads them. +Appsignal.start + +# Emit one of each signal but deliberately do NOT call `force_flush` +# anywhere. The PeriodicMetricReader and BatchLogRecordProcessor buffer +# data for export at their configured interval, so anything arriving at +# the mock collector before the runner exits has to be because +# `Appsignal.stop` shut the OTel providers down (which flushes them). +Appsignal.increment_counter("stop_counter", 1) + +logger = Appsignal::Logger.new("stop-group") +logger.info("stop log line") + +Appsignal.stop("integration test") + +puts "DONE" diff --git a/spec/integration/runners/collector_mode_traces.rb b/spec/integration/runners/collector_mode_traces.rb new file mode 100644 index 000000000..d19ca88c7 --- /dev/null +++ b/spec/integration/runners/collector_mode_traces.rb @@ -0,0 +1,30 @@ +PROJECT_ROOT = "../../../".freeze +$LOAD_PATH.unshift(File.expand_path("ext", PROJECT_ROOT)) +$LOAD_PATH.unshift(File.expand_path("lib", PROJECT_ROOT)) + +require "appsignal" + +# Name, environment and push API key come from the env vars the Runner +# injects (see `Runner::DEFAULT_ENV`); `Appsignal.start` loads them. +Appsignal.start + +# Exercise the public AppSignal tracing helpers; in collector mode these +# should route through the OpenTelemetry transaction backend and produce +# a root span plus nested event spans reaching the mock collector at +# `/v1/traces`. +Appsignal.monitor(:action => "MyController#index") do + Appsignal.instrument_sql("active_record.sql", "Find user", "SELECT * FROM users") do + # No body; the SQL event span itself is what the spec asserts on. + end + Appsignal.instrument("template.render") do + Appsignal.instrument("partial.render") do + # No body; the nested event span itself is what the spec asserts on. + end + end +end + +# Shut AppSignal down so the OTel providers drain their buffers and the +# spec sees the queued request deterministically. +Appsignal.stop("integration test") + +puts "DONE" diff --git a/spec/integration/runners/stop_with_trap.rb b/spec/integration/runners/stop_with_trap.rb index adb9e5938..73aaa3c6b 100644 --- a/spec/integration/runners/stop_with_trap.rb +++ b/spec/integration/runners/stop_with_trap.rb @@ -2,7 +2,6 @@ $LOAD_PATH.unshift(File.expand_path("ext", PROJECT_ROOT)) $LOAD_PATH.unshift(File.expand_path("lib", PROJECT_ROOT)) -require "fileutils" require "appsignal" Signal.trap("USR1") do @@ -12,20 +11,8 @@ exit 0 end -# Dummy config -Appsignal.configure(:test) do |config| - config.active = true - config.push_api_key = "abc" - config.name = "Signal app" - - # Use a working directory in the runner's tmp dir to avoid conflicts with the - # host's /tmp dir - working_directory = "tmp/appsignal" - FileUtils.rm_f(working_directory) - FileUtils.mkdir_p(working_directory) - config.working_directory_path = File.join(__dir__, working_directory) -end - +# Name, environment and push API key come from the env vars the Runner +# injects (see `Runner::DEFAULT_ENV`); `Appsignal.start` loads them. Appsignal.start puts "Waiting for USR1 signal..." diff --git a/spec/integration/stop_spec.rb b/spec/integration/stop_spec.rb index 9b66410d0..b40a9e079 100644 --- a/spec/integration/stop_spec.rb +++ b/spec/integration/stop_spec.rb @@ -15,9 +15,6 @@ output = runner.output - # Make sure the app exited properly - expect(runner.status.exitstatus).to eq(0) - # Assert the output has no errors expect(output).to_not include("ERROR: ") # Assert the app has started as expected diff --git a/spec/lib/appsignal/backends_spec.rb b/spec/lib/appsignal/backends_spec.rb new file mode 100644 index 000000000..41f582dc2 --- /dev/null +++ b/spec/lib/appsignal/backends_spec.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +describe Appsignal::Backends do + describe ".metrics" do + context "when no config is loaded" do + before { allow(Appsignal).to receive(:config).and_return(nil) } + + it "returns the extension backend" do + expect(described_class.metrics).to eq(Appsignal::Metrics::ExtensionBackend) + end + end + + context "when collector mode is not active" do + before do + config = instance_double(Appsignal::Config, :collector_mode? => false) + allow(Appsignal).to receive(:config).and_return(config) + end + + it "returns the extension backend" do + expect(described_class.metrics).to eq(Appsignal::Metrics::ExtensionBackend) + end + end + + context "when collector mode is active" do + before do + config = instance_double(Appsignal::Config, :collector_mode? => true) + allow(Appsignal).to receive(:config).and_return(config) + end + + it "returns the OpenTelemetry backend" do + expect(described_class.metrics).to eq(Appsignal::Metrics::OpenTelemetryBackend) + end + end + end + + describe ".logger" do + context "when no config is loaded" do + before { allow(Appsignal).to receive(:config).and_return(nil) } + + it "returns the extension backend" do + expect(described_class.logger).to eq(Appsignal::Logger::ExtensionBackend) + end + end + + context "when collector mode is not active" do + before do + config = instance_double(Appsignal::Config, :collector_mode? => false) + allow(Appsignal).to receive(:config).and_return(config) + end + + it "returns the extension backend" do + expect(described_class.logger).to eq(Appsignal::Logger::ExtensionBackend) + end + end + + context "when collector mode is active" do + before do + config = instance_double(Appsignal::Config, :collector_mode? => true) + allow(Appsignal).to receive(:config).and_return(config) + end + + it "returns the OpenTelemetry backend" do + expect(described_class.logger).to eq(Appsignal::Logger::OpenTelemetryBackend) + end + end + end + + describe ".transaction" do + context "when no config is loaded" do + before { allow(Appsignal).to receive(:config).and_return(nil) } + + it "returns the extension backend" do + expect(described_class.transaction).to eq(Appsignal::Transaction::ExtensionBackend) + end + end + + context "when collector mode is not active" do + before do + config = instance_double(Appsignal::Config, :collector_mode? => false) + allow(Appsignal).to receive(:config).and_return(config) + end + + it "returns the extension backend" do + expect(described_class.transaction).to eq(Appsignal::Transaction::ExtensionBackend) + end + end + + context "when collector mode is active" do + before do + config = instance_double(Appsignal::Config, :collector_mode? => true) + allow(Appsignal).to receive(:config).and_return(config) + end + + it "returns the OpenTelemetry backend" do + expect(described_class.transaction).to eq(Appsignal::Transaction::OpenTelemetryBackend) + end + end + end +end diff --git a/spec/lib/appsignal/config_spec.rb b/spec/lib/appsignal/config_spec.rb index 27d3fa446..38e164d22 100644 --- a/spec/lib/appsignal/config_spec.rb +++ b/spec/lib/appsignal/config_spec.rb @@ -709,6 +709,7 @@ def on_load :activejob_report_errors => "all", :bind_address => "0.0.0.0", :ca_file_path => "/some/path", + :collector_endpoint => "http://collector.example.test:4318", :cpu_count => 1.5, :dns_servers => ["8.8.8.8", "8.8.4.4"], :enable_allocation_tracking => false, @@ -726,8 +727,12 @@ def on_load :enable_statsd => false, :endpoint => "https://test.appsignal.com", :files_world_accessible => false, + :filter_attributes => ["attr1", "attr2"], + :filter_function_parameters => ["fn1", "fn2"], :filter_metadata => ["key1", "key2"], :filter_parameters => ["param1", "param2"], + :filter_request_payload => ["payload1", "payload2"], + :filter_request_query_parameters => ["query1", "query2"], :filter_session_data => ["session1", "session2"], :host_role => "my host role", :hostname => "my hostname", @@ -759,11 +764,16 @@ def on_load :ownership_set_namespace => true, :push_api_key => "aaa-bbb-ccc", :request_headers => ["accept", "accept-charset"], + :response_headers => ["x-response-1", "x-response-2"], :revision => "v2.5.1", :running_in_container => true, :send_environment_metadata => false, + :send_function_parameters => true, :send_params => false, + :send_request_payload => true, + :send_request_query_parameters => true, :send_session_data => false, + :service_name => "my-service", :sidekiq_report_errors => "all", :statsd_port => "7890", :working_directory_path => working_directory_path, @@ -777,6 +787,7 @@ def on_load "APPSIGNAL_APP_NAME" => "App name", "APPSIGNAL_BIND_ADDRESS" => "0.0.0.0", "APPSIGNAL_CA_FILE_PATH" => "/some/path", + "APPSIGNAL_COLLECTOR_ENDPOINT" => "http://collector.example.test:4318", "APPSIGNAL_ENABLE_AT_EXIT_HOOK" => "never", "APPSIGNAL_HOSTNAME" => "my hostname", "APPSIGNAL_HOST_ROLE" => "my host role", @@ -787,6 +798,7 @@ def on_load "APPSIGNAL_LOG_PATH" => "/tmp/something", "APPSIGNAL_PUSH_API_ENDPOINT" => "https://test.appsignal.com", "APPSIGNAL_PUSH_API_KEY" => "aaa-bbb-ccc", + "APPSIGNAL_SERVICE_NAME" => "my-service", "APPSIGNAL_SIDEKIQ_REPORT_ERRORS" => "all", "APPSIGNAL_STATSD_PORT" => "7890", "APPSIGNAL_NGINX_PORT" => "4321", @@ -826,19 +838,27 @@ def on_load "APPSIGNAL_OWNERSHIP_SET_NAMESPACE" => "true", "APPSIGNAL_RUNNING_IN_CONTAINER" => "true", "APPSIGNAL_SEND_ENVIRONMENT_METADATA" => "false", + "APPSIGNAL_SEND_FUNCTION_PARAMETERS" => "true", "APPSIGNAL_SEND_PARAMS" => "false", + "APPSIGNAL_SEND_REQUEST_PAYLOAD" => "true", + "APPSIGNAL_SEND_REQUEST_QUERY_PARAMETERS" => "true", "APPSIGNAL_SEND_SESSION_DATA" => "false", # Arrays "APPSIGNAL_DNS_SERVERS" => "8.8.8.8,8.8.4.4", + "APPSIGNAL_FILTER_ATTRIBUTES" => "attr1,attr2", + "APPSIGNAL_FILTER_FUNCTION_PARAMETERS" => "fn1,fn2", "APPSIGNAL_FILTER_METADATA" => "key1,key2", "APPSIGNAL_FILTER_PARAMETERS" => "param1,param2", + "APPSIGNAL_FILTER_REQUEST_PAYLOAD" => "payload1,payload2", + "APPSIGNAL_FILTER_REQUEST_QUERY_PARAMETERS" => "query1,query2", "APPSIGNAL_FILTER_SESSION_DATA" => "session1,session2", "APPSIGNAL_IGNORE_ACTIONS" => "action1,action2", "APPSIGNAL_IGNORE_ERRORS" => "ExampleStandardError,AnotherError", "APPSIGNAL_IGNORE_LOGS" => "^start$,^Completed 2.* in .*ms (.*)", "APPSIGNAL_IGNORE_NAMESPACES" => "admin,private_namespace", "APPSIGNAL_REQUEST_HEADERS" => "accept,accept-charset", + "APPSIGNAL_RESPONSE_HEADERS" => "x-response-1,x-response-2", # Floats "APPSIGNAL_CPU_COUNT" => "1.5" @@ -937,6 +957,7 @@ def on_load :active => true, :activejob_report_errors => "all", :ca_file_path => File.join(resources_dir, "cacert.pem"), + :collector_endpoint => nil, :dns_servers => [], :enable_allocation_tracking => true, :enable_at_exit_hook => "on_error", @@ -953,8 +974,12 @@ def on_load :enable_rake_performance_instrumentation => false, :endpoint => "https://push.appsignal.com", :files_world_accessible => true, + :filter_attributes => [], + :filter_function_parameters => [], :filter_metadata => [], :filter_parameters => [], + :filter_request_payload => [], + :filter_request_query_parameters => [], :filter_session_data => [], :ignore_actions => [], :ignore_errors => [], @@ -981,10 +1006,15 @@ def on_load :ownership_set_namespace => false, :push_api_key => "abc", :request_headers => [], + :response_headers => [], :revision => "v2.5.1", :send_environment_metadata => true, + :send_function_parameters => nil, :send_params => true, + :send_request_payload => nil, + :send_request_query_parameters => nil, :send_session_data => true, + :service_name => nil, :sidekiq_report_errors => "all", :default_tags => {} ) @@ -1636,6 +1666,197 @@ def log_file_path end end + describe "#collector_mode_configured?" do + let(:options) { {} } + let(:config) { build_config(:root_path => "", :env => nil, :options => options) } + subject { config.collector_mode_configured? } + # Stub to the gate's minimum so the "happy path" contexts pass on Ruby < 3.1. + # The "when running on Ruby older..." context below stubs to an older version + # to exercise the gate path on every CI Ruby. + before { stub_const("RUBY_VERSION", Appsignal::Config::MIN_RUBY_VERSION_FOR_COLLECTOR_MODE) } + + context "when :collector_endpoint is not set" do + it { is_expected.to be(false) } + end + + context "when :collector_endpoint is nil" do + let(:options) { { :collector_endpoint => nil } } + it { is_expected.to be(false) } + end + + context "when :collector_endpoint is an empty string" do + let(:options) { { :collector_endpoint => "" } } + it { is_expected.to be(false) } + end + + context "when :collector_endpoint is whitespace only" do + let(:options) { { :collector_endpoint => " " } } + it { is_expected.to be(false) } + end + + context "when :collector_endpoint is set" do + let(:options) { { :collector_endpoint => "http://127.0.0.1:9090" } } + it { is_expected.to be(true) } + end + + context "when :collector_endpoint is set via APPSIGNAL_COLLECTOR_ENDPOINT" do + let(:config) do + ENV["APPSIGNAL_COLLECTOR_ENDPOINT"] = "http://127.0.0.1:9090" + build_config(:root_path => "", :env => nil, :options => {}) + end + + it { is_expected.to be(true) } + end + + context "when running on Ruby older than the minimum supported version" do + let(:options) { { :collector_endpoint => "http://127.0.0.1:9090" } } + let(:err_stream) { std_stream } + before { stub_const("RUBY_VERSION", "3.0.7") } + + it "forces collector mode off and warns the user" do + logs = + capture_logs do + capture_std_streams(std_stream, err_stream) do + expect(config.collector_mode_configured?).to be(false) + end + end + + message = + "Collector mode requires Ruby " \ + "#{Appsignal::Config::MIN_RUBY_VERSION_FOR_COLLECTOR_MODE} or higher " \ + "(running Ruby 3.0.7)" + expect(logs).to include(message) + expect(err_stream.read).to include("appsignal WARNING: #{message}") + end + + it "memoizes the result so the warning is emitted at most once" do + logs = + capture_logs do + capture_std_streams(std_stream, err_stream) do + 3.times { config.collector_mode_configured? } + end + end + + expect(logs.scan("Collector mode requires").length).to eq(1) + end + end + end + + describe "#collector_mode?" do + let(:options) { { :collector_endpoint => "http://127.0.0.1:9090" } } + let(:config) { build_config(:root_path => "", :env => nil, :options => options) } + subject { config.collector_mode? } + before { stub_const("RUBY_VERSION", Appsignal::Config::MIN_RUBY_VERSION_FOR_COLLECTOR_MODE) } + + context "when collector mode is configured and OpenTelemetry has started" do + before { allow(Appsignal::OpenTelemetry).to receive(:started?).and_return(true) } + + it { is_expected.to be(true) } + end + + context "when collector mode is configured but OpenTelemetry hasn't started" do + before { allow(Appsignal::OpenTelemetry).to receive(:started?).and_return(false) } + + it { is_expected.to be(false) } + end + + context "when collector mode is not configured" do + let(:options) { {} } + it { is_expected.to be(false) } + end + end + + describe "#warn_for_mode_mismatch" do + let(:options) { {} } + let(:config) { build_config(:options => options) } + + context "when in collector mode" do + let(:collector_options) do + { :collector_endpoint => "http://127.0.0.1:9090" } + end + before { stub_const("RUBY_VERSION", Appsignal::Config::MIN_RUBY_VERSION_FOR_COLLECTOR_MODE) } + + it "warns when filter_parameters is set" do + logs = + capture_logs do + build_config(:options => collector_options.merge(:filter_parameters => ["password"])) + end + expect(logs).to include("filter_parameters") + expect(logs).to include("only used by the agent") + end + + it "warns when send_params is set" do + logs = + capture_logs do + build_config(:options => collector_options.merge(:send_params => false)) + end + expect(logs).to include("send_params") + expect(logs).to include("only used by the agent") + end + + it "does not warn when only filter_attributes is set" do + logs = + capture_logs do + build_config(:options => collector_options.merge(:filter_attributes => ["password"])) + end + expect(logs).to_not include("only used by the agent") + expect(logs).to_not include("only used by the collector") + end + + it "does not warn when an agent-only option is explicitly set to its default" do + # send_params defaults to true; setting it to true is a no-op and + # shouldn't trigger a mode-mismatch warning. + logs = + capture_logs do + build_config(:options => collector_options.merge(:send_params => true)) + end + expect(logs).to_not include("only used by the agent") + expect(logs).to_not include("only used by the collector") + end + end + + context "when not in collector mode" do + it "warns when a collector-only option is set" do + logs = + capture_logs do + build_config(:options => { :filter_attributes => ["password"] }) + end + expect(logs).to include("filter_attributes") + expect(logs).to include("only used by the collector") + end + + it "warns when service_name is set" do + logs = + capture_logs do + build_config(:options => { :service_name => "my-service" }) + end + expect(logs).to include("service_name") + expect(logs).to include("only used by the collector") + end + + it "does not warn when only filter_parameters is set" do + logs = + capture_logs do + build_config(:options => { :filter_parameters => ["password"] }) + end + expect(logs).to_not include("only used by the agent") + expect(logs).to_not include("only used by the collector") + end + + it "does not warn when a collector-only option is explicitly set to its default" do + # filter_attributes defaults to []; setting it to [] is a no-op and + # shouldn't trigger a mode-mismatch warning even though the source + # dictionary recorded the assignment. + logs = + capture_logs do + build_config(:options => { :filter_attributes => [] }) + end + expect(logs).to_not include("only used by the agent") + expect(logs).to_not include("only used by the collector") + end + end + end + describe Appsignal::Config::ConfigDSL do let(:env) { :production } let(:options) { {} } diff --git a/spec/lib/appsignal/event_formatter/action_view/render_formatter_spec.rb b/spec/lib/appsignal/event_formatter/action_view/render_formatter_spec.rb index 52c4590ae..1d016dc2f 100644 --- a/spec/lib/appsignal/event_formatter/action_view/render_formatter_spec.rb +++ b/spec/lib/appsignal/event_formatter/action_view/render_formatter_spec.rb @@ -19,6 +19,12 @@ klass)).to be_truthy end + describe "#opentelemetry_attributes" do + subject { formatter.opentelemetry_attributes({}) } + + it { is_expected.to eq("appsignal.group" => "render") } + end + describe "#root_path" do subject { formatter.root_path } @@ -53,15 +59,34 @@ end else context "when not in a Rails app" do - it "does not register the event formatter" do + let(:formatter) { klass.new } + + it "registers every event that renders a template" do expect(Appsignal::EventFormatter.registered?("render_partial.action_view", - klass)).to be_falsy + klass)).to be_truthy expect(Appsignal::EventFormatter.registered?("render_template.action_view", - klass)).to be_falsy + klass)).to be_truthy expect(Appsignal::EventFormatter.registered?("render_collection.action_view", - klass)).to be_falsy + klass)).to be_truthy expect(Appsignal::EventFormatter.registered?("render_layout.action_view", - klass)).to be_falsy + klass)).to be_truthy + end + + describe "#opentelemetry_attributes" do + subject { formatter.opentelemetry_attributes({}) } + + it "still says the event is template rendering" do + is_expected.to eq("appsignal.group" => "render") + end + end + + describe "#format" do + subject { formatter.format(payload) } + + let(:payload) { { :identifier => "/var/www/app/20130101/app/views/home/index/html.erb" } } + + # There is no application root to make the template's path relative to. + it { is_expected.to be_nil } end end end diff --git a/spec/lib/appsignal/event_formatter/active_job/perform_formatter_spec.rb b/spec/lib/appsignal/event_formatter/active_job/perform_formatter_spec.rb new file mode 100644 index 000000000..6d8c8ef51 --- /dev/null +++ b/spec/lib/appsignal/event_formatter/active_job/perform_formatter_spec.rb @@ -0,0 +1,45 @@ +describe Appsignal::EventFormatter::ActiveJob::PerformFormatter do + let(:klass) { described_class } + let(:formatter) { klass.new } + + it "registers perform.active_job" do + expect(Appsignal::EventFormatter.registered?("perform.active_job", klass)).to be_truthy + end + + describe "#opentelemetry_attributes" do + subject { formatter.opentelemetry_attributes(payload) } + + context "with a job that names its queue" do + let(:job) { double(:queue_name => "default") } + let(:payload) { { :job => job } } + + it "describes performing a job on that queue" do + is_expected.to eq( + "messaging.system" => "active_job", + "messaging.operation.name" => "perform", + "messaging.operation.type" => "process", + "messaging.destination.name" => "default" + ) + end + end + + context "without a job" do + let(:payload) { {} } + + it "describes performing a job without naming a queue" do + is_expected.to eq( + "messaging.system" => "active_job", + "messaging.operation.name" => "perform", + "messaging.operation.type" => "process" + ) + end + end + end + + describe "#format" do + subject { formatter.format(:job => double(:queue_name => "default")) } + + # The job's own transaction carries its title, so this event needs none. + it { is_expected.to be_nil } + end +end diff --git a/spec/lib/appsignal/event_formatter/active_record/sql_formatter_spec.rb b/spec/lib/appsignal/event_formatter/active_record/sql_formatter_spec.rb index be7ab6089..9e5da152a 100644 --- a/spec/lib/appsignal/event_formatter/active_record/sql_formatter_spec.rb +++ b/spec/lib/appsignal/event_formatter/active_record/sql_formatter_spec.rb @@ -6,6 +6,12 @@ expect(Appsignal::EventFormatter.registered?("sql.active_record", klass)).to be_truthy end + describe "#opentelemetry_kind" do + subject { formatter.opentelemetry_kind } + + it { is_expected.to eq :client } + end + describe "#format" do let(:payload) do { @@ -18,4 +24,48 @@ it { is_expected.to eq ["User load", "SELECT * FROM users", 1] } end + + describe "#opentelemetry_attributes" do + subject { formatter.opentelemetry_attributes(payload) } + + context "with a connection whose adapter the mapping recognises" do + let(:payload) { { :connection => double(:adapter_name => "PostgreSQL") } } + + it "names the engine" do + is_expected.to eq("db.system.name" => "postgresql") + end + end + + context "with a connection whose adapter is activerecord-sqlserver-adapter's" do + let(:payload) { { :connection => double(:adapter_name => "SQLServer") } } + + it "names the engine with the older semantic conventions value" do + is_expected.to eq("db.system.name" => "mssql") + end + end + + context "with a connection whose adapter is activerecord-oracle_enhanced-adapter's" do + let(:payload) { { :connection => double(:adapter_name => "OracleEnhanced") } } + + it "names the engine" do + is_expected.to eq("db.system.name" => "oracle") + end + end + + context "with a connection whose adapter the mapping does not recognise" do + let(:payload) { { :connection => double(:adapter_name => "DB2") } } + + it "names nothing, leaving the SQL sentinel to apply" do + is_expected.to be_nil + end + end + + context "without a connection (Rails versions that do not report one)" do + let(:payload) { {} } + + it "names nothing, leaving the SQL sentinel to apply" do + is_expected.to be_nil + end + end + end end diff --git a/spec/lib/appsignal/event_formatter/elastic_search/search_formatter_spec.rb b/spec/lib/appsignal/event_formatter/elastic_search/search_formatter_spec.rb index d3913e341..aa5f8ddcb 100644 --- a/spec/lib/appsignal/event_formatter/elastic_search/search_formatter_spec.rb +++ b/spec/lib/appsignal/event_formatter/elastic_search/search_formatter_spec.rb @@ -8,6 +8,50 @@ ).to be_truthy end + describe "#opentelemetry_kind" do + subject { formatter.opentelemetry_kind } + + it { is_expected.to eq :client } + end + + describe "#opentelemetry_attributes" do + subject { formatter.opentelemetry_attributes(payload) } + + context "with a search naming one index" do + let(:payload) { { :search => { :index => "users" } } } + + it "names the index it searched" do + is_expected.to eq( + "db.system.name" => "elasticsearch", + "db.operation.name" => "search", + "db.collection.name" => "users" + ) + end + end + + context "with a search naming more than one index" do + let(:payload) { { :search => { :index => ["users", "accounts"] } } } + + it "names no index rather than a value that is not one" do + is_expected.to eq( + "db.system.name" => "elasticsearch", + "db.operation.name" => "search" + ) + end + end + + context "without a search" do + let(:payload) { {} } + + it "still describes the span as an Elasticsearch search" do + is_expected.to eq( + "db.system.name" => "elasticsearch", + "db.operation.name" => "search" + ) + end + end + end + describe "#format" do let(:payload) do { diff --git a/spec/lib/appsignal/event_formatter/recorded_elsewhere_spec.rb b/spec/lib/appsignal/event_formatter/recorded_elsewhere_spec.rb new file mode 100644 index 000000000..25a9736a4 --- /dev/null +++ b/spec/lib/appsignal/event_formatter/recorded_elsewhere_spec.rb @@ -0,0 +1,26 @@ +describe Appsignal::EventFormatter::RecordedElsewhere do + let(:klass) { described_class } + let(:formatter) { klass.new } + + # Which events are claimed with this, and when, is up to the integration that + # records each of them. Those are covered by that integration's own specs. + describe "#record?" do + subject { formatter.record? } + + it { is_expected.to be(false) } + end + + it "keeps the generic paths from recording the event it is registered for" do + Appsignal::EventFormatter.register("mock.claimed", klass) + + expect(Appsignal::EventFormatter.record?("mock.claimed")).to be(false) + ensure + Appsignal::EventFormatter.unregister("mock.claimed", klass) + end + + describe "#format" do + subject { formatter.format(:name => "claimed") } + + it { is_expected.to be_nil } + end +end diff --git a/spec/lib/appsignal/event_formatter/rom/sql_formatter_spec.rb b/spec/lib/appsignal/event_formatter/rom/sql_formatter_spec.rb index cf512f163..43482dbc5 100644 --- a/spec/lib/appsignal/event_formatter/rom/sql_formatter_spec.rb +++ b/spec/lib/appsignal/event_formatter/rom/sql_formatter_spec.rb @@ -8,6 +8,20 @@ expect(Appsignal::EventFormatter.registered?("sql.dry", klass)).to be_truthy end + describe "#opentelemetry_kind" do + subject { formatter.opentelemetry_kind } + + it { is_expected.to eq :client } + end + + describe "#opentelemetry_scope" do + subject { formatter.opentelemetry_scope } + + # These events arrive over dry-monitor, but ROM is what emits them, and the + # scope names the library the instrumentation is for. + it { is_expected.to eq ["appsignal-ruby/rom", Appsignal::VERSION] } + end + describe "#format" do subject { formatter.format(payload) } @@ -26,4 +40,24 @@ it { is_expected.to eq ["query.rom", "SELECT * FROM users", 1] } end end + + describe "#opentelemetry_attributes" do + subject { formatter.opentelemetry_attributes(payload) } + + context "with a database type the mapping recognises" do + let(:payload) { { :name => :postgres, :query => "SELECT * FROM users" } } + + it "names the engine" do + is_expected.to eq("db.system.name" => "postgresql") + end + end + + context "with a database type the mapping does not recognise" do + let(:payload) { { :name => :db2, :query => "SELECT * FROM users" } } + + it "names nothing, leaving the SQL sentinel to apply" do + is_expected.to be_nil + end + end + end end diff --git a/spec/lib/appsignal/event_formatter/sequel/sql_formatter_spec.rb b/spec/lib/appsignal/event_formatter/sequel/sql_formatter_spec.rb index db2cd78cf..7cd756bb3 100644 --- a/spec/lib/appsignal/event_formatter/sequel/sql_formatter_spec.rb +++ b/spec/lib/appsignal/event_formatter/sequel/sql_formatter_spec.rb @@ -6,6 +6,12 @@ expect(Appsignal::EventFormatter.registered?("sql.sequel", klass)).to be_truthy end + describe "#opentelemetry_kind" do + subject { formatter.opentelemetry_kind } + + it { is_expected.to eq :client } + end + describe "#format" do before do stub_const( diff --git a/spec/lib/appsignal/event_formatter/view_component/render_formatter_spec.rb b/spec/lib/appsignal/event_formatter/view_component/render_formatter_spec.rb index 51bc2bad5..a95afda74 100644 --- a/spec/lib/appsignal/event_formatter/view_component/render_formatter_spec.rb +++ b/spec/lib/appsignal/event_formatter/view_component/render_formatter_spec.rb @@ -6,11 +6,15 @@ let(:formatter) { klass.new } before { allow(Rails.root).to receive(:to_s).and_return("/var/www/app/20130101") } - it "registers render.view_component and (deprecated) !render.view_component" do + it "registers render.view_component" do expect(Appsignal::EventFormatter.registered?("render.view_component", klass)).to be_truthy - expect(Appsignal::EventFormatter.registered?("!render.view_component", - klass)).to be_truthy + end + + describe "#opentelemetry_attributes" do + subject { formatter.opentelemetry_attributes({}) } + + it { is_expected.to eq("appsignal.group" => "render") } end describe "#format" do @@ -30,11 +34,33 @@ end else context "when not in a Rails app" do - it "does not register the event formatter" do + let(:formatter) { klass.new } + + it "registers render.view_component" do expect(Appsignal::EventFormatter.registered?("render.view_component", - klass)).to be_falsy - expect(Appsignal::EventFormatter.registered?("!render.view_component", - klass)).to be_falsy + klass)).to be_truthy + end + + describe "#opentelemetry_attributes" do + subject { formatter.opentelemetry_attributes({}) } + + it "still says the event is template rendering" do + is_expected.to eq("appsignal.group" => "render") + end + end + + describe "#format" do + subject { formatter.format(payload) } + + let(:payload) do + { + :name => "WhateverComponent", + :identifier => "/var/www/app/20130101/app/components/whatever_component.rb" + } + end + + # There is no application root to make the component's path relative to. + it { is_expected.to be_nil } end end end diff --git a/spec/lib/appsignal/event_formatter_spec.rb b/spec/lib/appsignal/event_formatter_spec.rb index 7eb9e44b7..ae477fd5c 100644 --- a/spec/lib/appsignal/event_formatter_spec.rb +++ b/spec/lib/appsignal/event_formatter_spec.rb @@ -17,6 +17,33 @@ class MockFormatterDouble < MockFormatter class MissingFormatMockFormatter end +class UntitledMockFormatter < Appsignal::EventFormatter +end + +class ClientMockFormatter < Appsignal::EventFormatter + def opentelemetry_kind + :client + end +end + +class DescribedMockFormatter < Appsignal::EventFormatter + def opentelemetry_attributes(payload) + { "mock.attribute" => payload[:value] } + end +end + +class UnrecordedMockFormatter < Appsignal::EventFormatter + def record? + false + end +end + +class ScopedMockFormatter < Appsignal::EventFormatter + def opentelemetry_scope + ["appsignal-ruby/mock", "1.2.3"] + end +end + class IncorrectFormatMockFormatter < Appsignal::EventFormatter def format end @@ -98,6 +125,15 @@ def format(_payload) end end + context "when the formatter does not implement format" do + it "registers the formatter, which gives the event no title or body" do + klass.register("mock.untitled", UntitledMockFormatter) + + expect(klass.registered?("mock.untitled")).to be_truthy + expect(klass.format("mock.untitled", {})).to be_nil + end + end + context "when there is an error initializing the formatter" do it "does not register the formatter and logs an error" do logs = capture_logs do @@ -218,6 +254,133 @@ def format(_payload) end end + describe ".opentelemetry_kind" do + context "when the formatter declares a kind" do + it "returns it" do + klass.register("mock.client", ClientMockFormatter) + expect(klass.opentelemetry_kind("mock.client")).to eq(:client) + end + end + + context "when the formatter declares no kind" do + it "returns nil" do + klass.register("mock", MockFormatter) + expect(klass.opentelemetry_kind("mock")).to be_nil + end + end + + # An event can be instrumented under a Symbol name, while every formatter + # is registered under a String one. + context "when the event name is a Symbol" do + it "returns the kind the formatter registered under the String declares" do + klass.register("mock.symbol_kind", ClientMockFormatter) + + expect(klass.opentelemetry_kind(:"mock.symbol_kind")).to eq(:client) + end + end + + context "when no formatter with the name is registered" do + it "returns nil" do + expect(klass.opentelemetry_kind("nonsense")).to be_nil + end + end + end + + describe ".opentelemetry_attributes" do + context "when the formatter describes the event" do + it "returns the attributes it reads from the payload" do + klass.register("mock.described", DescribedMockFormatter) + expect(klass.opentelemetry_attributes("mock.described", :value => "read")) + .to eq("mock.attribute" => "read") + end + end + + context "when the formatter describes nothing" do + it "returns nil" do + klass.register("mock", MockFormatter) + expect(klass.opentelemetry_attributes("mock", {})).to be_nil + end + end + + context "when the event name is a Symbol" do + it "returns what the formatter registered under the String describes" do + klass.register("mock.symbol_described", DescribedMockFormatter) + + expect(klass.opentelemetry_attributes(:"mock.symbol_described", :value => "read")) + .to eq("mock.attribute" => "read") + end + end + + context "when no formatter with the name is registered" do + it "returns nil" do + expect(klass.opentelemetry_attributes("nonsense", {})).to be_nil + end + end + end + + describe ".opentelemetry_scope" do + context "when the formatter declares a scope" do + it "returns it" do + klass.register("mock.scoped", ScopedMockFormatter) + + expect(klass.opentelemetry_scope("mock.scoped")).to eq(["appsignal-ruby/mock", "1.2.3"]) + end + end + + context "when the formatter declares no scope" do + it "returns nil" do + klass.register("mock", MockFormatter) + + expect(klass.opentelemetry_scope("mock")).to be_nil + end + end + + context "when the event name is a Symbol" do + it "returns what the formatter registered under the String declares" do + klass.register("mock.symbol_scoped", ScopedMockFormatter) + + expect(klass.opentelemetry_scope(:"mock.symbol_scoped")) + .to eq(["appsignal-ruby/mock", "1.2.3"]) + end + end + + context "when no formatter with the name is registered" do + it "returns nil" do + expect(klass.opentelemetry_scope("nonsense")).to be_nil + end + end + end + + describe ".record?" do + context "when the formatter says another integration records the event" do + it "returns false" do + klass.register("mock.elsewhere", UnrecordedMockFormatter) + expect(klass.record?("mock.elsewhere")).to be(false) + end + end + + context "when the formatter says nothing about it" do + it "returns true" do + klass.register("mock", MockFormatter) + expect(klass.record?("mock")).to be(true) + end + end + + context "when the event name is a Symbol" do + it "returns what the formatter registered under the String says" do + klass.register("mock.symbol_claimed", UnrecordedMockFormatter) + + expect(klass.record?(:"mock.symbol_claimed")).to be(false) + end + end + + context "when no formatter with the name is registered" do + it "returns true" do + expect(klass.record?("nonsense")).to be(true) + end + end + end + describe ".format" do context "when no formatter with the name is registered" do it "returns nil" do diff --git a/spec/lib/appsignal/extension_spec.rb b/spec/lib/appsignal/extension_spec.rb index b75baffeb..3c9e4b410 100644 --- a/spec/lib/appsignal/extension_spec.rb +++ b/spec/lib/appsignal/extension_spec.rb @@ -12,6 +12,18 @@ it { is_expected.to be_kind_of(String) } end + # Allocation tracking is MRI-only: the JRuby extension defines neither the + # allocation event hook nor this counter, so the method only exists here. + describe ".allocation_count", :if => !DependencyHelper.running_jruby? do + subject { Appsignal::Extension.allocation_count } + + # The counter only climbs once the allocation event hook is installed, which + # is a process-wide side effect avoided here. This checks the getter is + # wired; the climbing behavior is covered end-to-end by the collector-mode + # trace integration spec. + it { is_expected.to be_kind_of(Integer) } + end + context "when the extension library can be loaded" do subject { Appsignal::Extension } diff --git a/spec/lib/appsignal/hooks/action_cable_spec.rb b/spec/lib/appsignal/hooks/action_cable_spec.rb index a1b67e936..ea423e964 100644 --- a/spec/lib/appsignal/hooks/action_cable_spec.rb +++ b/spec/lib/appsignal/hooks/action_cable_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + describe Appsignal::Hooks::ActionCableHook do if DependencyHelper.action_cable_present? context "with ActionCable" do @@ -52,8 +54,6 @@ def self.to_s let(:request_id) { SecureRandom.uuid } let(:instance) { channel.new(connection, identifier, params) } before do - start_agent - # Stub transmit call for subscribe/unsubscribe tests allow(connection).to receive(:websocket) .and_return(instance_double("ActionCable::Connection::WebSocket", :transmit => nil)) @@ -61,132 +61,105 @@ def self.to_s around { |example| keep_transactions { example.run } } describe "#perform_action" do - it "creates a transaction for an action" do - instance.perform_action("message" => "foo", "action" => "speak") - - transaction = last_transaction - expect(transaction).to have_id - expect(transaction).to have_namespace(Appsignal::Transaction::ACTION_CABLE) - expect(transaction).to have_action("MyChannel#speak") - expect(transaction).to_not have_error - expect(transaction).to include_metadata( - "method" => "websocket", - "path" => "/blog" - ) - expect(transaction).to include_event( - "body" => "", - "body_format" => Appsignal::EventFormatter::DEFAULT, - "count" => 1, - "name" => "perform_action.action_cable", - "title" => "" - ) - expect(transaction).to include_params( - "action" => "speak", - "message" => "foo" - ) - expect(transaction).to include_session_data( - "user_id" => "123", - "session" => "yes" - ) - expect(transaction).to include_tags("request_id" => request_id) - expect(transaction).to_not have_queue_start - expect(transaction).to be_completed - end - - context "without request_id (standalone server)" do - let(:request_id) { nil } - - it "sets a generated request ID" do - # Subscribe action, sets the request_id - instance.subscribe_to_channel - + describe "creates a transaction for an action" do + def perform instance.perform_action("message" => "foo", "action" => "speak") - expect(last_transaction).to include_tags("request_id" => kind_of(String)) end - end - context "with an error in the action" do - let(:channel) do - Class.new(ActionCable::Channel::Base) do - def speak(_data) - raise ExampleException, "oh no!" - end - - def self.to_s - "MyChannel" - end - end - end - - it "registers an error on the transaction" do - expect do - instance.perform_action("message" => "foo", "action" => "speak") - end.to raise_error(ExampleException) + it "in agent mode", :agent_mode do + start_agent + perform transaction = last_transaction expect(transaction).to have_id - expect(transaction).to have_action("MyChannel#speak") expect(transaction).to have_namespace(Appsignal::Transaction::ACTION_CABLE) - expect(transaction).to have_error("ExampleException", "oh no!") + expect(transaction).to have_action("MyChannel#speak") + expect(transaction).to_not have_error expect(transaction).to include_metadata( "method" => "websocket", "path" => "/blog" ) + expect(transaction).to include_event( + "body" => "", + "body_format" => Appsignal::EventFormatter::DEFAULT, + "count" => 1, + "name" => "perform_action.action_cable", + "title" => "" + ) expect(transaction).to include_params( "action" => "speak", "message" => "foo" ) + expect(transaction).to include_session_data( + "user_id" => "123", + "session" => "yes" + ) + expect(transaction).to include_tags("request_id" => request_id) expect(transaction).to_not have_queue_start expect(transaction).to be_completed end - end - end - - describe "subscribe callback" do - let(:params) { { "internal" => true } } - it "creates a transaction for a subscription" do - instance.subscribe_to_channel - - transaction = last_transaction - expect(transaction).to have_id - expect(transaction).to have_action("MyChannel#subscribed") - expect(transaction).to have_namespace(Appsignal::Transaction::ACTION_CABLE) - expect(transaction).to_not have_error - expect(transaction).to include_metadata( - "method" => "websocket", - "path" => "/blog" - ) - expect(transaction).to include_params("internal" => "true") - expect(transaction).to include_event( - "body" => "", - "body_format" => Appsignal::EventFormatter::DEFAULT, - "count" => 1, - "name" => "subscribed.action_cable", - "title" => "" - ) - expect(transaction).to include_session_data( - "user_id" => "123", - "session" => "yes" - ) - expect(transaction).to include_tags("request_id" => request_id) - expect(transaction).to_not have_queue_start - expect(transaction).to be_completed + it "in collector mode", :collector_mode do + start_collector_agent + perform + + expect(root_span.kind).to eq(:server) + expect(root_span.name).to eq("MyChannel#speak") + expect(root_span.attributes["appsignal.namespace"]) + .to eq(Appsignal::Transaction::ACTION_CABLE) + expect(root_span.attributes["appsignal.action_name"]).to eq("MyChannel#speak") + expect(scope_of(root_span)).to eq(["appsignal-ruby/action_cable", Appsignal::VERSION]) + expect(exception_events).to be_empty + expect(root_span.attributes["appsignal.tag.method"]).to eq("websocket") + expect(root_span.attributes["appsignal.tag.path"]).to eq("/blog") + span = event_spans.find { |s| s.name == "perform_action.action_cable" } + expect(span).not_to be_nil + expect(span.parent_span_id).to eq(root_span.span_id) + expect(JSON.parse(root_span.attributes["appsignal.request.payload"])) + .to eq("action" => "speak", "message" => "foo") + expect(JSON.parse(root_span.attributes["appsignal.request.session_data"])) + .to eq("user_id" => "123", "session" => "yes") + expect(root_span.attributes["appsignal.tag.request_id"]).to eq(request_id) + expect(root_span.attributes).not_to have_key("queue_start") + expect(last_transaction).to be_completed + end end context "without request_id (standalone server)" do let(:request_id) { nil } - before { instance.subscribe_to_channel } - it "sets a generated request ID" do - expect(last_transaction).to include_tags("request_id" => kind_of(String)) + describe "sets a generated request ID" do + def perform + # Subscribe action sets the request_id in the env + instance.subscribe_to_channel + instance.perform_action("message" => "foo", "action" => "speak") + end + + it "in agent mode", :agent_mode do + start_agent + perform + expect(last_transaction).to include_tags("request_id" => kind_of(String)) + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + + # Two server spans: one for subscribe, one for perform_action. + # The last one is the perform_action span. + perform_span = span_exporter.finished_spans + .select { |s| [:server, :consumer].include?(s.kind) } + .last + expect(perform_span.attributes["appsignal.tag.request_id"]) + .to be_a(String) + end end end - context "with an error in the callback" do + context "with an error in the action" do let(:channel) do Class.new(ActionCable::Channel::Base) do - def subscribed + def speak(_data) raise ExampleException, "oh no!" end @@ -196,124 +169,291 @@ def self.to_s end end - it "registers an error on the transaction" do - expect do - instance.subscribe_to_channel - end.to raise_error(ExampleException) + describe "registers an error on the transaction" do + def perform + expect do + instance.perform_action("message" => "foo", "action" => "speak") + end.to raise_error(ExampleException) + end + + it "in agent mode", :agent_mode do + start_agent + perform + + transaction = last_transaction + expect(transaction).to have_id + expect(transaction).to have_action("MyChannel#speak") + expect(transaction).to have_namespace(Appsignal::Transaction::ACTION_CABLE) + expect(transaction).to have_error("ExampleException", "oh no!") + expect(transaction).to include_metadata( + "method" => "websocket", + "path" => "/blog" + ) + expect(transaction).to include_params( + "action" => "speak", + "message" => "foo" + ) + expect(transaction).to_not have_queue_start + expect(transaction).to be_completed + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + + expect(root_span.attributes["appsignal.action_name"]).to eq("MyChannel#speak") + expect(root_span.attributes["appsignal.namespace"]) + .to eq(Appsignal::Transaction::ACTION_CABLE) + event = root_span.events.find { |e| e.name == "exception" } + expect(event).not_to be_nil + expect(event.attributes["exception.type"]).to eq("ExampleException") + expect(event.attributes["exception.message"]).to eq("oh no!") + expect(event.attributes["exception.stacktrace"]).to be_a(String) + expect(event.attributes["appsignal.alert_this_error"]).to eq(true) + expect(root_span.status.code).to eq(::OpenTelemetry::Trace::Status::ERROR) + expect(root_span.attributes["appsignal.tag.method"]).to eq("websocket") + expect(root_span.attributes["appsignal.tag.path"]).to eq("/blog") + expect(JSON.parse(root_span.attributes["appsignal.request.payload"])) + .to eq("action" => "speak", "message" => "foo") + expect(root_span.attributes).not_to have_key("queue_start") + expect(last_transaction).to be_completed + end + end + end + end + + describe "subscribe callback" do + let(:params) { { "internal" => true } } + + describe "creates a transaction for a subscription" do + def perform + instance.subscribe_to_channel + end + + it "in agent mode", :agent_mode do + start_agent + perform transaction = last_transaction expect(transaction).to have_id expect(transaction).to have_action("MyChannel#subscribed") expect(transaction).to have_namespace(Appsignal::Transaction::ACTION_CABLE) - expect(transaction).to have_error("ExampleException", "oh no!") + expect(transaction).to_not have_error expect(transaction).to include_metadata( "method" => "websocket", "path" => "/blog" ) expect(transaction).to include_params("internal" => "true") + expect(transaction).to include_event( + "body" => "", + "body_format" => Appsignal::EventFormatter::DEFAULT, + "count" => 1, + "name" => "subscribed.action_cable", + "title" => "" + ) expect(transaction).to include_session_data( "user_id" => "123", "session" => "yes" ) + expect(transaction).to include_tags("request_id" => request_id) expect(transaction).to_not have_queue_start expect(transaction).to be_completed end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + + expect(root_span.name).to eq("MyChannel#subscribed") + expect(root_span.attributes["appsignal.action_name"]) + .to eq("MyChannel#subscribed") + expect(root_span.attributes["appsignal.namespace"]) + .to eq(Appsignal::Transaction::ACTION_CABLE) + expect(exception_events).to be_empty + expect(root_span.attributes["appsignal.tag.method"]).to eq("websocket") + expect(root_span.attributes["appsignal.tag.path"]).to eq("/blog") + expect(JSON.parse(root_span.attributes["appsignal.request.payload"])) + .to eq("internal" => "true") + span = event_spans.find { |s| s.name == "subscribed.action_cable" } + expect(span).not_to be_nil + expect(span.parent_span_id).to eq(root_span.span_id) + expect(scope_of(root_span)).to eq(["appsignal-ruby/action_cable", Appsignal::VERSION]) + expect(scope_of(span)).to eq(["appsignal-ruby/action_cable", Appsignal::VERSION]) + expect(JSON.parse(root_span.attributes["appsignal.request.session_data"])) + .to eq("user_id" => "123", "session" => "yes") + expect(root_span.attributes["appsignal.tag.request_id"]).to eq(request_id) + expect(root_span.attributes).not_to have_key("queue_start") + expect(last_transaction).to be_completed + end end - if DependencyHelper.rails6_present? - context "with ConnectionStub" do - let(:connection) { ActionCable::Channel::ConnectionStub.new } + context "without request_id (standalone server)" do + let(:request_id) { nil } - it "does not fail on missing `#env` method on `ConnectionStub`" do + describe "sets a generated request ID" do + def perform instance.subscribe_to_channel + end + + it "in agent mode", :agent_mode do + start_agent + perform + expect(last_transaction).to include_tags("request_id" => kind_of(String)) + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + expect(root_span.attributes["appsignal.tag.request_id"]).to be_a(String) + end + end + end + + context "with an error in the callback" do + let(:channel) do + Class.new(ActionCable::Channel::Base) do + def subscribed + raise ExampleException, "oh no!" + end + + def self.to_s + "MyChannel" + end + end + end + + describe "registers an error on the transaction" do + def perform + expect do + instance.subscribe_to_channel + end.to raise_error(ExampleException) + end + + it "in agent mode", :agent_mode do + start_agent + perform transaction = last_transaction expect(transaction).to have_id expect(transaction).to have_action("MyChannel#subscribed") expect(transaction).to have_namespace(Appsignal::Transaction::ACTION_CABLE) - expect(transaction).to_not have_error + expect(transaction).to have_error("ExampleException", "oh no!") expect(transaction).to include_metadata( "method" => "websocket", - "path" => "" # No path as the ConnectionStub doesn't have the real request env + "path" => "/blog" ) - expect(transaction).to_not include_params - expect(transaction).to include_event( - "body" => "", - "body_format" => Appsignal::EventFormatter::DEFAULT, - "count" => 1, - "name" => "subscribed.action_cable", - "title" => "" + expect(transaction).to include_params("internal" => "true") + expect(transaction).to include_session_data( + "user_id" => "123", + "session" => "yes" ) expect(transaction).to_not have_queue_start expect(transaction).to be_completed end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + + expect(root_span.attributes["appsignal.action_name"]) + .to eq("MyChannel#subscribed") + expect(root_span.attributes["appsignal.namespace"]) + .to eq(Appsignal::Transaction::ACTION_CABLE) + event = root_span.events.find { |e| e.name == "exception" } + expect(event).not_to be_nil + expect(event.attributes["exception.type"]).to eq("ExampleException") + expect(event.attributes["exception.message"]).to eq("oh no!") + expect(event.attributes["exception.stacktrace"]).to be_a(String) + expect(event.attributes["appsignal.alert_this_error"]).to eq(true) + expect(root_span.status.code).to eq(::OpenTelemetry::Trace::Status::ERROR) + expect(root_span.attributes["appsignal.tag.method"]).to eq("websocket") + expect(root_span.attributes["appsignal.tag.path"]).to eq("/blog") + expect(JSON.parse(root_span.attributes["appsignal.request.payload"])) + .to eq("internal" => "true") + expect(JSON.parse(root_span.attributes["appsignal.request.session_data"])) + .to eq("user_id" => "123", "session" => "yes") + expect(root_span.attributes).not_to have_key("queue_start") + expect(last_transaction).to be_completed + end end end - end - describe "unsubscribe callback" do - let(:params) { { "internal" => true } } + if DependencyHelper.rails6_present? + context "with ConnectionStub" do + let(:connection) { ActionCable::Channel::ConnectionStub.new } - it "creates a transaction for a subscription" do - instance.unsubscribe_from_channel - - transaction = last_transaction - expect(transaction).to have_id - expect(transaction).to have_action("MyChannel#unsubscribed") - expect(transaction).to have_namespace(Appsignal::Transaction::ACTION_CABLE) - expect(transaction).to_not have_error - expect(transaction).to include_metadata( - "method" => "websocket", - "path" => "/blog" - ) - expect(transaction).to include_params("internal" => "true") - expect(transaction).to include_session_data( - "user_id" => "123", - "session" => "yes" - ) - expect(transaction).to include_event( - "body" => "", - "body_format" => Appsignal::EventFormatter::DEFAULT, - "count" => 1, - "name" => "unsubscribed.action_cable", - "title" => "" - ) - expect(transaction).to_not have_queue_start - expect(transaction).to be_completed - end + describe "does not fail on missing `#env` on `ConnectionStub`" do + def perform + instance.subscribe_to_channel + end - context "without request_id (standalone server)" do - let(:request_id) { nil } - before { instance.unsubscribe_from_channel } + it "in agent mode", :agent_mode do + start_agent + perform + + transaction = last_transaction + expect(transaction).to have_id + expect(transaction).to have_action("MyChannel#subscribed") + expect(transaction).to have_namespace(Appsignal::Transaction::ACTION_CABLE) + expect(transaction).to_not have_error + expect(transaction).to include_metadata( + "method" => "websocket", + "path" => "" # No path as the ConnectionStub doesn't have the real request env + ) + expect(transaction).to_not include_params + expect(transaction).to include_event( + "body" => "", + "body_format" => Appsignal::EventFormatter::DEFAULT, + "count" => 1, + "name" => "subscribed.action_cable", + "title" => "" + ) + expect(transaction).to_not have_queue_start + expect(transaction).to be_completed + end - it "sets a generated request ID" do - expect(last_transaction).to include_tags("request_id" => kind_of(String)) + it "in collector mode", :collector_mode do + start_collector_agent + perform + + expect(root_span.attributes["appsignal.action_name"]) + .to eq("MyChannel#subscribed") + expect(root_span.attributes["appsignal.namespace"]) + .to eq(Appsignal::Transaction::ACTION_CABLE) + expect(exception_events).to be_empty + expect(root_span.attributes["appsignal.tag.method"]).to eq("websocket") + expect(root_span.attributes["appsignal.tag.path"]).to eq("") + # ConnectionStub has no request env; params are empty in OTel + expect(JSON.parse(root_span.attributes.fetch("appsignal.request.payload", "{}"))) + .to eq({}) + span = event_spans.find { |s| s.name == "subscribed.action_cable" } + expect(span).not_to be_nil + expect(span.parent_span_id).to eq(root_span.span_id) + expect(root_span.attributes).not_to have_key("queue_start") + expect(last_transaction).to be_completed + end + end end end + end - context "with an error in the callback" do - let(:channel) do - Class.new(ActionCable::Channel::Base) do - def unsubscribed - raise ExampleException, "oh no!" - end + describe "unsubscribe callback" do + let(:params) { { "internal" => true } } - def self.to_s - "MyChannel" - end - end + describe "creates a transaction for an unsubscription" do + def perform + instance.unsubscribe_from_channel end - it "registers an error on the transaction" do - expect do - instance.unsubscribe_from_channel - end.to raise_error(ExampleException) + it "in agent mode", :agent_mode do + start_agent + perform transaction = last_transaction expect(transaction).to have_id expect(transaction).to have_action("MyChannel#unsubscribed") expect(transaction).to have_namespace(Appsignal::Transaction::ACTION_CABLE) - expect(transaction).to have_error("ExampleException", "oh no!") + expect(transaction).to_not have_error expect(transaction).to include_metadata( "method" => "websocket", "path" => "/blog" @@ -323,38 +463,186 @@ def self.to_s "user_id" => "123", "session" => "yes" ) + expect(transaction).to include_event( + "body" => "", + "body_format" => Appsignal::EventFormatter::DEFAULT, + "count" => 1, + "name" => "unsubscribed.action_cable", + "title" => "" + ) expect(transaction).to_not have_queue_start expect(transaction).to be_completed end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + + expect(root_span.attributes["appsignal.action_name"]) + .to eq("MyChannel#unsubscribed") + expect(root_span.attributes["appsignal.namespace"]) + .to eq(Appsignal::Transaction::ACTION_CABLE) + expect(exception_events).to be_empty + expect(root_span.attributes["appsignal.tag.method"]).to eq("websocket") + expect(root_span.attributes["appsignal.tag.path"]).to eq("/blog") + expect(JSON.parse(root_span.attributes["appsignal.request.payload"])) + .to eq("internal" => "true") + expect(JSON.parse(root_span.attributes["appsignal.request.session_data"])) + .to eq("user_id" => "123", "session" => "yes") + span = event_spans.find { |s| s.name == "unsubscribed.action_cable" } + expect(span).not_to be_nil + expect(span.parent_span_id).to eq(root_span.span_id) + expect(root_span.attributes).not_to have_key("queue_start") + expect(last_transaction).to be_completed + end end - if DependencyHelper.rails6_present? - context "with ConnectionStub" do - let(:connection) { ActionCable::Channel::ConnectionStub.new } + context "without request_id (standalone server)" do + let(:request_id) { nil } - it "does not fail on missing `#env` method on `ConnectionStub`" do + describe "sets a generated request ID" do + def perform instance.unsubscribe_from_channel + end + + it "in agent mode", :agent_mode do + start_agent + perform + expect(last_transaction).to include_tags("request_id" => kind_of(String)) + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + expect(root_span.attributes["appsignal.tag.request_id"]).to be_a(String) + end + end + end + + context "with an error in the callback" do + let(:channel) do + Class.new(ActionCable::Channel::Base) do + def unsubscribed + raise ExampleException, "oh no!" + end + + def self.to_s + "MyChannel" + end + end + end + + describe "registers an error on the transaction" do + def perform + expect do + instance.unsubscribe_from_channel + end.to raise_error(ExampleException) + end + + it "in agent mode", :agent_mode do + start_agent + perform transaction = last_transaction expect(transaction).to have_id expect(transaction).to have_action("MyChannel#unsubscribed") expect(transaction).to have_namespace(Appsignal::Transaction::ACTION_CABLE) - expect(transaction).to_not have_error + expect(transaction).to have_error("ExampleException", "oh no!") expect(transaction).to include_metadata( "method" => "websocket", - "path" => "" # No path as the ConnectionStub doesn't have the real request env + "path" => "/blog" ) - expect(transaction).to_not include_params - expect(transaction).to include_event( - "body" => "", - "body_format" => Appsignal::EventFormatter::DEFAULT, - "count" => 1, - "name" => "unsubscribed.action_cable", - "title" => "" + expect(transaction).to include_params("internal" => "true") + expect(transaction).to include_session_data( + "user_id" => "123", + "session" => "yes" ) expect(transaction).to_not have_queue_start expect(transaction).to be_completed end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + + expect(root_span.attributes["appsignal.action_name"]) + .to eq("MyChannel#unsubscribed") + expect(root_span.attributes["appsignal.namespace"]) + .to eq(Appsignal::Transaction::ACTION_CABLE) + event = root_span.events.find { |e| e.name == "exception" } + expect(event).not_to be_nil + expect(event.attributes["exception.type"]).to eq("ExampleException") + expect(event.attributes["exception.message"]).to eq("oh no!") + expect(event.attributes["exception.stacktrace"]).to be_a(String) + expect(event.attributes["appsignal.alert_this_error"]).to eq(true) + expect(root_span.status.code).to eq(::OpenTelemetry::Trace::Status::ERROR) + expect(root_span.attributes["appsignal.tag.method"]).to eq("websocket") + expect(root_span.attributes["appsignal.tag.path"]).to eq("/blog") + expect(JSON.parse(root_span.attributes["appsignal.request.payload"])) + .to eq("internal" => "true") + expect(JSON.parse(root_span.attributes["appsignal.request.session_data"])) + .to eq("user_id" => "123", "session" => "yes") + expect(root_span.attributes).not_to have_key("queue_start") + expect(last_transaction).to be_completed + end + end + end + + if DependencyHelper.rails6_present? + context "with ConnectionStub" do + let(:connection) { ActionCable::Channel::ConnectionStub.new } + + describe "does not fail on missing `#env` on `ConnectionStub`" do + def perform + instance.unsubscribe_from_channel + end + + it "in agent mode", :agent_mode do + start_agent + perform + + transaction = last_transaction + expect(transaction).to have_id + expect(transaction).to have_action("MyChannel#unsubscribed") + expect(transaction).to have_namespace(Appsignal::Transaction::ACTION_CABLE) + expect(transaction).to_not have_error + expect(transaction).to include_metadata( + "method" => "websocket", + "path" => "" # No path as the ConnectionStub doesn't have the real request env + ) + expect(transaction).to_not include_params + expect(transaction).to include_event( + "body" => "", + "body_format" => Appsignal::EventFormatter::DEFAULT, + "count" => 1, + "name" => "unsubscribed.action_cable", + "title" => "" + ) + expect(transaction).to_not have_queue_start + expect(transaction).to be_completed + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + + expect(root_span.attributes["appsignal.action_name"]) + .to eq("MyChannel#unsubscribed") + expect(root_span.attributes["appsignal.namespace"]) + .to eq(Appsignal::Transaction::ACTION_CABLE) + expect(exception_events).to be_empty + expect(root_span.attributes["appsignal.tag.method"]).to eq("websocket") + expect(root_span.attributes["appsignal.tag.path"]).to eq("") + # ConnectionStub has no request env; params are empty in OTel + expect(JSON.parse(root_span.attributes.fetch("appsignal.request.payload", "{}"))) + .to eq({}) + span = event_spans.find { |s| s.name == "unsubscribed.action_cable" } + expect(span).not_to be_nil + expect(span.parent_span_id).to eq(root_span.span_id) + expect(root_span.attributes).not_to have_key("queue_start") + expect(last_transaction).to be_completed + end + end end end end diff --git a/spec/lib/appsignal/hooks/action_mailer_spec.rb b/spec/lib/appsignal/hooks/action_mailer_spec.rb index 299f760b2..4e08f7aab 100644 --- a/spec/lib/appsignal/hooks/action_mailer_spec.rb +++ b/spec/lib/appsignal/hooks/action_mailer_spec.rb @@ -25,12 +25,10 @@ def welcome end describe ".install" do - before do + it "in agent mode", :agent_mode do start_agent - expect(Appsignal.active?).to be_truthy - end - it "is subscribed to 'process.action_mailer' and processes instrumentation" do + expect(Appsignal.active?).to be_truthy expect(Appsignal).to receive(:increment_counter).with( :action_mailer_process, 1, @@ -39,6 +37,20 @@ def welcome UserMailer.welcome.deliver_now end + + it "in collector mode", :collector_mode do + start_collector_agent + + expect(Appsignal.active?).to be_truthy + UserMailer.welcome.deliver_now + + snapshot = metric_snapshot("action_mailer_process") + expect(snapshot).not_to be_nil + expect(snapshot.data_points.first.value).to eq(1.0) + expect(snapshot.data_points.first.attributes).to eq( + "mailer" => "UserMailer", "action" => "welcome" + ) + end end end else diff --git a/spec/lib/appsignal/hooks/active_support_notifications/finish_with_state_shared_examples.rb b/spec/lib/appsignal/hooks/active_support_notifications/finish_with_state_shared_examples.rb index 1c8c0098c..aba435cd8 100644 --- a/spec/lib/appsignal/hooks/active_support_notifications/finish_with_state_shared_examples.rb +++ b/spec/lib/appsignal/hooks/active_support_notifications/finish_with_state_shared_examples.rb @@ -1,23 +1,76 @@ shared_examples "activesupport finish_with_state override" do let(:instrumenter) { as.instrumenter } - it "instruments an ActiveSupport::Notifications.start/finish event with payload on finish" do - listeners_state = instrumenter.start("sql.active_record", {}) - instrumenter.finish_with_state(listeners_state, "sql.active_record", :sql => "SQL") - - expect(transaction).to include_event( - "body" => "SQL", - "body_format" => Appsignal::EventFormatter::SQL_BODY_FORMAT, - "count" => 1, - "name" => "sql.active_record", - "title" => "" - ) + describe "a finish_with_state event" do + def perform + listeners_state = instrumenter.start("sql.active_record", {}) + instrumenter.finish_with_state(listeners_state, "sql.active_record", :sql => "SQL") + end + + it "in agent mode", :agent_mode do + start_agent + transaction = http_request_transaction + set_current_transaction(transaction) + as.notifier = notifier + + perform + + expect(transaction).to include_event( + "body" => "SQL", + "body_format" => Appsignal::EventFormatter::SQL_BODY_FORMAT, + "count" => 1, + "name" => "sql.active_record", + "title" => "" + ) + end + + it "in collector mode", :collector_mode do + start_collector_agent + transaction = http_request_transaction + set_current_transaction(transaction) + as.notifier = notifier + + perform + Appsignal::Transaction.complete_current! + + expect(event_spans.size).to eq(1) + span = event_spans.find { |s| s.name == "sql.active_record" } + expect(span).not_to be_nil + expect(span.parent_span_id).to eq(root_span.span_id) + # A database query is an outgoing call, so it carries CLIENT kind. + expect(span.kind).to eq(:client) + expect(span.attributes["db.query.text"]).to eq("SQL") + expect(span.attributes["db.system.name"]).to eq("other_sql") + end end - it "does not instrument events whose name starts with a bang" do - listeners_state = instrumenter.start("!sql.active_record", {}) - instrumenter.finish_with_state(listeners_state, "!sql.active_record", :sql => "SQL") + describe "an event whose name starts with a bang" do + def perform + listeners_state = instrumenter.start("!sql.active_record", {}) + instrumenter.finish_with_state(listeners_state, "!sql.active_record", :sql => "SQL") + end + + it "in agent mode", :agent_mode do + start_agent + transaction = http_request_transaction + set_current_transaction(transaction) + as.notifier = notifier + + perform + + expect(transaction).to_not include_events + end + + it "in collector mode", :collector_mode do + start_collector_agent + transaction = http_request_transaction + set_current_transaction(transaction) + as.notifier = notifier + + perform + Appsignal::Transaction.complete_current! - expect(transaction).to_not include_events + expect(event_spans).to be_empty + end end end diff --git a/spec/lib/appsignal/hooks/active_support_notifications/instrument_shared_examples.rb b/spec/lib/appsignal/hooks/active_support_notifications/instrument_shared_examples.rb index 5cddcaf12..f34185847 100644 --- a/spec/lib/appsignal/hooks/active_support_notifications/instrument_shared_examples.rb +++ b/spec/lib/appsignal/hooks/active_support_notifications/instrument_shared_examples.rb @@ -1,72 +1,436 @@ shared_examples "activesupport instrument override" do - it "instruments an ActiveSupport::Notifications.instrument event" do - return_value = as.instrument("sql.active_record", :sql => "SQL") do - "value" - end - - expect(return_value).to eq "value" - expect(transaction).to include_event( - "body" => "SQL", - "body_format" => Appsignal::EventFormatter::SQL_BODY_FORMAT, - "count" => 1, - "name" => "sql.active_record", - "title" => "" - ) + describe "an event with a registered formatter" do + def perform + as.instrument("sql.active_record", :sql => "SQL") { "value" } + end + + it "in agent mode", :agent_mode do + start_agent + transaction = http_request_transaction + set_current_transaction(transaction) + as.notifier = notifier + + expect(perform).to eq "value" + expect(transaction).to include_event( + "body" => "SQL", + "body_format" => Appsignal::EventFormatter::SQL_BODY_FORMAT, + "count" => 1, + "name" => "sql.active_record", + "title" => "" + ) + end + + it "in collector mode", :collector_mode do + start_collector_agent + transaction = http_request_transaction + set_current_transaction(transaction) + as.notifier = notifier + + expect(perform).to eq "value" + Appsignal::Transaction.complete_current! + + expect(event_spans.size).to eq(1) + span = event_spans.find { |s| s.name == "sql.active_record" } + expect(span).not_to be_nil + expect(span.parent_span_id).to eq(root_span.span_id) + # A database query is an outgoing call, so it carries CLIENT kind. + expect(span.kind).to eq(:client) + expect(span.attributes["db.query.text"]).to eq("SQL") + expect(span.attributes["db.system.name"]).to eq("other_sql") + expect(event_category(span)).to eq("sql.active_record") + # The scope is derived from the event group (the part after the last dot). + expect(scope_of(span)).to eq(["appsignal-ruby/active_record", Appsignal::VERSION]) + expect(span.attributes).not_to have_key("appsignal.body") + expect(span.attributes).not_to have_key("error.type") + end end - it "instruments an ActiveSupport::Notifications.instrument event with no registered formatter" do - return_value = as.instrument("no-registered.formatter", :key => "something") do - "value" + describe "an ActiveRecord SQL query event with a connection" do + let(:connection) { double(:adapter_name => "PostgreSQL") } + + def perform + as.instrument("sql.active_record", :sql => "SQL", :connection => connection) { "value" } end - expect(return_value).to eq "value" - expect(transaction).to include_event( - "body" => "", - "body_format" => Appsignal::EventFormatter::DEFAULT, - "count" => 1, - "name" => "no-registered.formatter", - "title" => "" - ) + it "in collector mode", :collector_mode do + start_collector_agent + transaction = http_request_transaction + set_current_transaction(transaction) + as.notifier = notifier + + expect(perform).to eq "value" + Appsignal::Transaction.complete_current! + + expect(event_spans.size).to eq(1) + span = event_spans.find { |s| s.name == "sql.active_record" } + expect(span).not_to be_nil + # The connection's own adapter name names the engine, rather than the + # SQL sentinel every other unrecognized adapter falls back to. + expect(span.attributes["db.system.name"]).to eq("postgresql") + end end - it "converts non-string names to strings" do - as.instrument(:not_a_string) {} # rubocop:disable Lint/EmptyBlock - expect(transaction).to include_event( - "body" => "", - "body_format" => Appsignal::EventFormatter::DEFAULT, - "count" => 1, - "name" => "not_a_string", - "title" => "" - ) + describe "a Sequel query event (emitted by sequel-rails)" do + def perform + as.instrument( + "sql.sequel", + :name => "Sequel::Postgres::Database", + :sql => "SQL" + ) { "value" } + end + + it "in agent mode", :agent_mode do + start_agent + transaction = http_request_transaction + set_current_transaction(transaction) + as.notifier = notifier + + expect(perform).to eq "value" + expect(transaction).to include_event( + "body" => "SQL", + "body_format" => Appsignal::EventFormatter::SQL_BODY_FORMAT, + "count" => 1, + "name" => "sql.sequel", + "title" => "Sequel::Postgres::Database" + ) + end + + it "in collector mode", :collector_mode do + start_collector_agent + transaction = http_request_transaction + set_current_transaction(transaction) + as.notifier = notifier + + expect(perform).to eq "value" + Appsignal::Transaction.complete_current! + + expect(event_spans.size).to eq(1) + span = event_span_for("sql.sequel") + expect(span).not_to be_nil + expect(span.parent_span_id).to eq(root_span.span_id) + # A database query is an outgoing call, so it carries CLIENT kind. + expect(span.kind).to eq(:client) + expect(span.attributes["db.query.text"]).to eq("SQL") + expect(span.attributes["db.system.name"]).to eq("other_sql") + expect(event_category(span)).to eq("sql.sequel") + expect(scope_of(span)).to eq(["appsignal-ruby/sequel", Appsignal::VERSION]) + expect(span.attributes).not_to have_key("appsignal.body") + end + end + + describe "an Elasticsearch search event" do + def perform + as.instrument( + "search.elasticsearch", + :name => "Search", + :klass => "User", + :search => { :index => "users" } + ) { "value" } + end + + it "in agent mode", :agent_mode do + start_agent + transaction = http_request_transaction + set_current_transaction(transaction) + as.notifier = notifier + + expect(perform).to eq "value" + expect(transaction).to include_event( + "name" => "search.elasticsearch", + "title" => "Search: User", + # The formatter inspects the sanitized search, and Hash#inspect changed + # format in Ruby 3.4, so match on the content rather than the layout. + "body" => a_string_including("users") + ) + end + + it "in collector mode", :collector_mode do + start_collector_agent + transaction = http_request_transaction + set_current_transaction(transaction) + as.notifier = notifier + + expect(perform).to eq "value" + Appsignal::Transaction.complete_current! + + span = event_span_for("search.elasticsearch") + expect(span).not_to be_nil + expect(span.parent_span_id).to eq(root_span.span_id) + # A search is an outgoing call to the cluster, so it carries CLIENT kind. + expect(span.kind).to eq(:client) + # There is no SQL body to infer the datastore from, so it is named + # explicitly. Without it the trace timeline cannot tell this span apart + # from any other kind of work. + expect(span.attributes["db.system.name"]).to eq("elasticsearch") + # This notification is only emitted for a search, so that is the + # operation. The index comes off the search itself. + expect(span.attributes["db.operation.name"]).to eq("search") + expect(span.attributes["db.collection.name"]).to eq("users") + expect(event_category(span)).to eq("search.elasticsearch") + expect(scope_of(span)).to eq(["appsignal-ruby/elasticsearch", Appsignal::VERSION]) + end end - it "does not instrument events whose name starts with a bang" do - return_value = as.instrument("!sql.active_record", :sql => "SQL") do - "value" + describe "an Elasticsearch search event across more than one index" do + def perform + as.instrument( + "search.elasticsearch", + :name => "Search", + :klass => "User", + :search => { :index => ["users", "admins"] } + ) { "value" } end - expect(return_value).to eq "value" + it "in agent mode", :agent_mode do + start_agent + transaction = http_request_transaction + set_current_transaction(transaction) + as.notifier = notifier - expect(transaction).to_not include_events + expect(perform).to eq "value" + expect(transaction).to include_event( + "name" => "search.elasticsearch", + "title" => "Search: User", + "body" => a_string_including("users") + ) + end + + it "in collector mode", :collector_mode do + start_collector_agent + transaction = http_request_transaction + set_current_transaction(transaction) + as.notifier = notifier + + expect(perform).to eq "value" + Appsignal::Transaction.complete_current! + + span = event_span_for("search.elasticsearch") + expect(span).not_to be_nil + expect(span.attributes["db.operation.name"]).to eq("search") + # The attribute names one index, so a search across several is left + # without it rather than described with a value that is not an index name. + expect(span.attributes).to_not have_key("db.collection.name") + end end - it "does not instrument suppressed events, recorded by a dedicated integration" do - return_value = as.instrument("request.faraday", :method => :get) do - "value" + describe "a template render event" do + def perform + as.instrument("render_template.action_view", :identifier => "/app/views/a.erb") { "value" } end - expect(return_value).to eq "value" + it "in agent mode", :agent_mode do + start_agent + transaction = http_request_transaction + set_current_transaction(transaction) + as.notifier = notifier - expect(transaction).to_not include_events + expect(perform).to eq "value" + # The title comes from the Action View formatter, which only registers + # when Rails is loaded. These shared examples also run under gemfiles + # that have ActiveSupport without Rails, where the event has no title, + # so match any String rather than one particular value. + expect(transaction).to include_event( + "name" => "render_template.action_view", + "title" => kind_of(String) + ) + # The render group only means something to the trace timeline, so + # nothing is recorded for it here. + expect(transaction).to_not include_tags("appsignal.group" => "render") + end + + it "in collector mode", :collector_mode do + start_collector_agent + transaction = http_request_transaction + set_current_transaction(transaction) + as.notifier = notifier + + expect(perform).to eq "value" + Appsignal::Transaction.complete_current! + + span = event_span_for("render_template.action_view") + expect(span).not_to be_nil + expect(span.parent_span_id).to eq(root_span.span_id) + # There is no OpenTelemetry convention for template rendering, so the + # span says which group it belongs to directly. + expect(span.attributes["appsignal.group"]).to eq("render") + expect(scope_of(span)).to eq(["appsignal-ruby/action_view", Appsignal::VERSION]) + end end - context "when an error is raised in an instrumented block" do - it "instruments an ActiveSupport::Notifications.instrument event" do + describe "an event with no registered formatter" do + def perform + as.instrument("no-registered.formatter", :key => "something") { "value" } + end + + it "in agent mode", :agent_mode do + start_agent + transaction = http_request_transaction + set_current_transaction(transaction) + as.notifier = notifier + + expect(perform).to eq "value" + expect(transaction).to include_event( + "body" => "", + "body_format" => Appsignal::EventFormatter::DEFAULT, + "count" => 1, + "name" => "no-registered.formatter", + "title" => "" + ) + end + + it "in collector mode", :collector_mode do + start_collector_agent + transaction = http_request_transaction + set_current_transaction(transaction) + as.notifier = notifier + + expect(perform).to eq "value" + Appsignal::Transaction.complete_current! + + expect(event_spans.size).to eq(1) + span = event_spans.find { |s| s.name == "no-registered.formatter" } + expect(span).not_to be_nil + expect(span.parent_span_id).to eq(root_span.span_id) + # A plain event is not an outgoing call, so it keeps the default kind. + expect(span.kind).to eq(:internal) + expect(span.attributes).not_to have_key("appsignal.body") + expect(event_category(span)).to eq("no-registered.formatter") + expect(scope_of(span)).to eq(["appsignal-ruby/formatter", Appsignal::VERSION]) + expect(span.attributes).not_to have_key("db.query.text") + expect(span.attributes).not_to have_key("db.system.name") + end + end + + describe "an event with a non-string name" do + def perform + as.instrument(:not_a_string) {} # rubocop:disable Lint/EmptyBlock + end + + it "in agent mode", :agent_mode do + start_agent + transaction = http_request_transaction + set_current_transaction(transaction) + as.notifier = notifier + + perform + + expect(transaction).to include_event( + "body" => "", + "body_format" => Appsignal::EventFormatter::DEFAULT, + "count" => 1, + "name" => "not_a_string", + "title" => "" + ) + end + + it "in collector mode", :collector_mode do + start_collector_agent + transaction = http_request_transaction + set_current_transaction(transaction) + as.notifier = notifier + + perform + Appsignal::Transaction.complete_current! + + expect(event_spans.size).to eq(1) + expect(event_spans.map(&:name)).to include("not_a_string") + span = event_spans.find { |s| s.name == "not_a_string" } + expect(event_category(span)).to eq("not_a_string") + # No group (no dot) in the name, so it falls back to the default scope. + expect(scope_of(span)).to eq(["appsignal-ruby", Appsignal::VERSION]) + end + end + + describe "an event whose name starts with a bang" do + def perform + as.instrument("!sql.active_record", :sql => "SQL") { "value" } + end + + it "in agent mode", :agent_mode do + start_agent + transaction = http_request_transaction + set_current_transaction(transaction) + as.notifier = notifier + + expect(perform).to eq "value" + expect(transaction).to_not include_events + end + + it "in collector mode", :collector_mode do + start_collector_agent + transaction = http_request_transaction + set_current_transaction(transaction) + as.notifier = notifier + + expect(perform).to eq "value" + Appsignal::Transaction.complete_current! + + expect(event_spans).to be_empty + end + end + + describe "an event claimed by a dedicated integration" do + # An integration claims its event as it installs, so which events are + # claimed depends on which integrations are running. Claim one here, so + # that this covers the generic path leaving a claimed event alone whichever + # integrations this gemfile happens to have. + before do + Appsignal::EventFormatter.register( + "claimed.example", + Appsignal::EventFormatter::RecordedElsewhere + ) + end + + after do + Appsignal::EventFormatter.unregister( + "claimed.example", + Appsignal::EventFormatter::RecordedElsewhere + ) + end + + def perform + as.instrument("claimed.example", :method => :get) { "value" } + end + + it "in agent mode", :agent_mode do + start_agent + transaction = http_request_transaction + set_current_transaction(transaction) + as.notifier = notifier + + expect(perform).to eq "value" + expect(transaction).to_not include_events + end + + it "in collector mode", :collector_mode do + start_collector_agent + transaction = http_request_transaction + set_current_transaction(transaction) + as.notifier = notifier + + expect(perform).to eq "value" + Appsignal::Transaction.complete_current! + + expect(event_spans).to be_empty + end + end + + describe "when an error is raised in an instrumented block" do + def perform expect do as.instrument("sql.active_record", :sql => "SQL") do raise ExampleException, "foo" end end.to raise_error(ExampleException, "foo") + end + + it "in agent mode", :agent_mode do + start_agent + transaction = http_request_transaction + set_current_transaction(transaction) + as.notifier = notifier + + perform expect(transaction).to include_event( "body" => "SQL", @@ -76,15 +440,43 @@ "title" => "" ) end + + it "in collector mode", :collector_mode do + start_collector_agent + transaction = http_request_transaction + set_current_transaction(transaction) + as.notifier = notifier + + perform + Appsignal::Transaction.complete_current! + + expect(event_spans.size).to eq(1) + span = event_spans.find { |s| s.name == "sql.active_record" } + expect(span).not_to be_nil + expect(span.parent_span_id).to eq(root_span.span_id) + # A database query is an outgoing call, so it carries CLIENT kind. + expect(span.kind).to eq(:client) + expect(span.attributes["db.query.text"]).to eq("SQL") + expect(span.attributes["db.system.name"]).to eq("other_sql") + # The block raised, so the operation the span describes failed. + expect(span.attributes["error.type"]).to eq("ExampleException") + end end - context "when a message is thrown in an instrumented block" do - it "instruments an ActiveSupport::Notifications.instrument event" do + describe "when a message is thrown in an instrumented block" do + def perform expect do - as.instrument("sql.active_record", :sql => "SQL") do - throw :foo - end + as.instrument("sql.active_record", :sql => "SQL") { throw :foo } end.to throw_symbol(:foo) + end + + it "in agent mode", :agent_mode do + start_agent + transaction = http_request_transaction + set_current_transaction(transaction) + as.notifier = notifier + + perform expect(transaction).to include_event( "body" => "SQL", @@ -94,16 +486,56 @@ "title" => "" ) end + + it "in collector mode", :collector_mode do + start_collector_agent + transaction = http_request_transaction + set_current_transaction(transaction) + as.notifier = notifier + + perform + Appsignal::Transaction.complete_current! + + expect(event_spans.size).to eq(1) + span = event_spans.find { |s| s.name == "sql.active_record" } + expect(span).not_to be_nil + expect(span.parent_span_id).to eq(root_span.span_id) + # A database query is an outgoing call, so it carries CLIENT kind. + expect(span.kind).to eq(:client) + expect(span.attributes["db.query.text"]).to eq("SQL") + expect(span.attributes["db.system.name"]).to eq("other_sql") + end end - context "when a transaction is completed in an instrumented block" do - it "does not complete the ActiveSupport::Notifications.instrument event" do + describe "when the transaction is completed inside an instrumented block" do + def perform as.instrument("sql.active_record", :sql => "SQL") do Appsignal::Transaction.complete_current! end + end + + it "in agent mode", :agent_mode do + start_agent + transaction = http_request_transaction + set_current_transaction(transaction) + as.notifier = notifier + + perform expect(transaction).to_not include_events expect(transaction).to be_completed end + + it "in collector mode", :collector_mode do + start_collector_agent + transaction = http_request_transaction + set_current_transaction(transaction) + as.notifier = notifier + + perform + + expect(transaction).to be_completed + expect(event_spans.map(&:name)).not_to include("sql.active_record") + end end end diff --git a/spec/lib/appsignal/hooks/active_support_notifications/start_finish_shared_examples.rb b/spec/lib/appsignal/hooks/active_support_notifications/start_finish_shared_examples.rb index 4342e228a..231b7fd58 100644 --- a/spec/lib/appsignal/hooks/active_support_notifications/start_finish_shared_examples.rb +++ b/spec/lib/appsignal/hooks/active_support_notifications/start_finish_shared_examples.rb @@ -1,47 +1,154 @@ shared_examples "activesupport start finish override" do let(:instrumenter) { as.instrumenter } - it "instruments start/finish events with payload on start ignores payload" do - instrumenter.start("sql.active_record", :sql => "SQL") - instrumenter.finish("sql.active_record", {}) - - expect(transaction).to include_event( - "body" => "", - "body_format" => Appsignal::EventFormatter::SQL_BODY_FORMAT, - "count" => 1, - "name" => "sql.active_record", - "title" => "" - ) + describe "a start/finish event whose payload is provided at start" do + def perform + instrumenter.start("sql.active_record", :sql => "SQL") + instrumenter.finish("sql.active_record", {}) + end + + it "in agent mode", :agent_mode do + start_agent + transaction = http_request_transaction + set_current_transaction(transaction) + as.notifier = notifier + + perform + + expect(transaction).to include_event( + "body" => "", + "body_format" => Appsignal::EventFormatter::SQL_BODY_FORMAT, + "count" => 1, + "name" => "sql.active_record", + "title" => "" + ) + end + + it "in collector mode", :collector_mode do + start_collector_agent + transaction = http_request_transaction + set_current_transaction(transaction) + as.notifier = notifier + + perform + Appsignal::Transaction.complete_current! + + expect(event_spans.size).to eq(1) + span = event_spans.find { |s| s.name == "sql.active_record" } + expect(span).not_to be_nil + expect(span.parent_span_id).to eq(root_span.span_id) + # A database query is an outgoing call, so it carries CLIENT kind. + expect(span.kind).to eq(:client) + # The formatter received an empty finish payload, so there is no query to + # record. The datastore is named anyway, because this is a database query + # either way. + expect(span.attributes).not_to have_key("db.query.text") + expect(span.attributes["db.system.name"]).to eq("other_sql") + end end - it "instruments an ActiveSupport::Notifications.start/finish event with payload on finish" do - instrumenter.start("sql.active_record", {}) - instrumenter.finish("sql.active_record", :sql => "SQL") - - expect(transaction).to include_event( - "body" => "SQL", - "body_format" => Appsignal::EventFormatter::SQL_BODY_FORMAT, - "count" => 1, - "name" => "sql.active_record", - "title" => "" - ) + describe "a start/finish event whose payload is provided at finish" do + def perform + instrumenter.start("sql.active_record", {}) + instrumenter.finish("sql.active_record", :sql => "SQL") + end + + it "in agent mode", :agent_mode do + start_agent + transaction = http_request_transaction + set_current_transaction(transaction) + as.notifier = notifier + + perform + + expect(transaction).to include_event( + "body" => "SQL", + "body_format" => Appsignal::EventFormatter::SQL_BODY_FORMAT, + "count" => 1, + "name" => "sql.active_record", + "title" => "" + ) + end + + it "in collector mode", :collector_mode do + start_collector_agent + transaction = http_request_transaction + set_current_transaction(transaction) + as.notifier = notifier + + perform + Appsignal::Transaction.complete_current! + + expect(event_spans.size).to eq(1) + span = event_spans.find { |s| s.name == "sql.active_record" } + expect(span).not_to be_nil + expect(span.parent_span_id).to eq(root_span.span_id) + # A database query is an outgoing call, so it carries CLIENT kind. + expect(span.kind).to eq(:client) + expect(span.attributes["db.query.text"]).to eq("SQL") + expect(span.attributes["db.system.name"]).to eq("other_sql") + end end - it "does not instrument events whose name starts with a bang" do - instrumenter.start("!sql.active_record", {}) - instrumenter.finish("!sql.active_record", {}) + describe "an event whose name starts with a bang" do + def perform + instrumenter.start("!sql.active_record", {}) + instrumenter.finish("!sql.active_record", {}) + end + + it "in agent mode", :agent_mode do + start_agent + transaction = http_request_transaction + set_current_transaction(transaction) + as.notifier = notifier + + perform + + expect(transaction).to_not include_events + end + + it "in collector mode", :collector_mode do + start_collector_agent + transaction = http_request_transaction + set_current_transaction(transaction) + as.notifier = notifier + + perform + Appsignal::Transaction.complete_current! - expect(transaction).to_not include_events + expect(event_spans).to be_empty + end end - context "when a transaction is completed in an instrumented block" do - it "does not complete the ActiveSupport::Notifications.instrument event" do + describe "when the transaction is completed between start and finish" do + def perform instrumenter.start("sql.active_record", {}) Appsignal::Transaction.complete_current! instrumenter.finish("sql.active_record", {}) + end + + it "in agent mode", :agent_mode do + start_agent + transaction = http_request_transaction + set_current_transaction(transaction) + as.notifier = notifier + + perform expect(transaction).to_not include_events expect(transaction).to be_completed end + + it "in collector mode", :collector_mode do + start_collector_agent + transaction = http_request_transaction + set_current_transaction(transaction) + as.notifier = notifier + + perform + + expect(transaction).to be_completed + expect(event_spans.map(&:name)).not_to include("sql.active_record") + end end end diff --git a/spec/lib/appsignal/hooks/active_support_notifications_spec.rb b/spec/lib/appsignal/hooks/active_support_notifications_spec.rb index 7161c4d97..505ff55ba 100644 --- a/spec/lib/appsignal/hooks/active_support_notifications_spec.rb +++ b/spec/lib/appsignal/hooks/active_support_notifications_spec.rb @@ -4,13 +4,18 @@ if active_support_present? let(:notifier) { ActiveSupport::Notifications::Fanout.new } let(:as) { ActiveSupport::Notifications } - let(:transaction) { http_request_transaction } - before do - start_agent - set_current_transaction(transaction) - as.notifier = notifier + + # The shared examples swap in a fresh notifier (`as.notifier = notifier`) to + # control which subscriptions are active. Restore the original afterwards so + # the swap doesn't leak into later specs -- e.g. ActionMailer's + # instrumentation, which subscribes on the default notifier and would + # otherwise fire into the stale, subscription-less notifier left behind. + around do |example| + original_notifier = ActiveSupport::Notifications.notifier + example.run + ensure + ActiveSupport::Notifications.notifier = original_notifier end - around { |example| keep_transactions { example.run } } # The before hook swaps in a fresh notifier (`as.notifier = notifier`) to # control which subscriptions are active. Restore the original afterwards so diff --git a/spec/lib/appsignal/hooks/activejob_spec.rb b/spec/lib/appsignal/hooks/activejob_spec.rb index 6a092ac87..6471f980f 100644 --- a/spec/lib/appsignal/hooks/activejob_spec.rb +++ b/spec/lib/appsignal/hooks/activejob_spec.rb @@ -37,6 +37,51 @@ expect(path).to end_with("/lib/appsignal/hooks/active_job.rb") end end + + describe "claiming the enqueue.active_job event" do + # The event formatter registry is shared by the whole test suite, so + # put back whatever this example changes. Reloading the hook file + # below also re-registers it in the hooks registry, so put that back + # too. + around do |example| + formatters = Appsignal::EventFormatter.formatters.dup + formatter_classes = Appsignal::EventFormatter.formatter_classes.dup + hooks = Appsignal::Hooks.hooks.dup + example.run + ensure + Appsignal::EventFormatter.formatters.replace(formatters) + Appsignal::EventFormatter.formatter_classes.replace(formatter_classes) + Appsignal::Hooks.hooks.replace(hooks) + end + + # This integration records the enqueue itself, so the generic + # notification paths must not record it a second time. The hook file + # claims the event as soon as it is required, rather than when + # `install` runs, so the claim holds even when Active Job + # instrumentation ends up disabled and `install` never runs. A + # customer who turns the instrumentation off should not see the + # native notification reported instead. + # + # By this point in the suite, the hook file has already been + # required once, and `require` does not run a file's body again. Force + # the event back to unclaimed, then load the file with `load` instead + # of `require` so its body runs again. That proves the claim comes + # from loading the file, since `install` is never called here. + it "stays claimed when Active Job instrumentation is disabled" do + configure(:options => { :instrument_active_job => false }) + expect(described_class.new.dependencies_present?).to be(false) + + Appsignal::EventFormatter.unregister( + "enqueue.active_job", + Appsignal::EventFormatter::RecordedElsewhere + ) + expect(Appsignal::EventFormatter.record?("enqueue.active_job")).to be(true) + + load "appsignal/hooks/active_job.rb" + + expect(Appsignal::EventFormatter.record?("enqueue.active_job")).to be(false) + end + end end describe Appsignal::Hooks::ActiveJobHook::ActiveJobClassInstrumentation do @@ -83,10 +128,10 @@ end end let(:options) { {} } + let(:start_agent_args) { { :options => options } } before do ActiveJob::Base.queue_adapter = :inline - start_agent(:options => options) stub_const("ActiveJobTestJob", Class.new(ActiveJob::Base) do def perform(*_args) end @@ -113,41 +158,95 @@ def perform(*_args) end end) end - around { |example| keep_transactions { example.run } } - it "reports the name from the ActiveJob integration" do - tags = { :queue => queue } - expect(Appsignal).to receive(:increment_counter) - .with("active_job_queue_job_count", 1, tags.merge(:status => :processed)) - - queue_job(ActiveJobTestJob) - - transaction = last_transaction - expect(transaction).to have_namespace(namespace) - expect(transaction).to have_action("ActiveJobTestJob#perform") - expect(transaction).to_not have_error - expect(transaction).to_not include_metadata - expect(transaction).to include_params([]) - expect(transaction).to include_tags( - "active_job_id" => kind_of(String), - "request_id" => kind_of(String), - "queue" => queue, - "executions" => 1 - ) - events = transaction.to_h["events"] - .sort_by { |e| e["start"] } - .map { |event| event["name"] } - expect(events).to eq(expected_perform_events) + describe "reports action, namespace, tags and params" do + def perform + queue_job(ActiveJobTestJob) + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + + allow(Appsignal).to receive(:increment_counter) + + perform + + transaction = last_transaction + transaction._sample + expect(transaction).to have_namespace(namespace) + expect(transaction).to have_action("ActiveJobTestJob#perform") + expect(transaction).to_not have_error + expect(transaction).to_not include_metadata + expect(transaction).to include_params([]) + expect(transaction).to include_tags( + "active_job_id" => kind_of(String), + "request_id" => kind_of(String), + "queue" => queue, + "executions" => 1 + ) + events = transaction.to_h["events"] + .sort_by { |e| e["start"] } + .map { |event| event["name"] } + expect(events).to eq(expected_perform_events) + end + + it "in collector mode", :collector_mode do + start_collector_agent + + allow(Appsignal).to receive(:increment_counter) + + perform + last_transaction.complete + + expect(root_span.kind).to eq(:consumer) + expect(root_span.attributes["messaging.system"]).to eq("active_job") + expect(root_span.attributes["messaging.operation.name"]).to eq("perform") + expect(root_span.attributes["messaging.operation.type"]).to eq("process") + expect(root_span.attributes["messaging.destination.name"]).to eq("default") + expect(scope_of(root_span)).to eq(["appsignal-ruby/active_job", Appsignal::VERSION]) + expect(root_span.attributes["appsignal.namespace"]).to eq("background") + expect(root_span.attributes["appsignal.action_name"]).to eq("ActiveJobTestJob#perform") + expect(exception_events).to be_empty + expect(root_span.attributes).to_not have_key("appsignal.metadata") + expect(JSON.parse(root_span.attributes["appsignal.function.parameters"])).to eq([]) + expect(root_span.attributes["appsignal.tag.active_job_id"]).to be_a(String) + expect(root_span.attributes["appsignal.tag.request_id"]).to be_a(String) + expect(root_span.attributes["appsignal.tag.queue"]).to eq(queue) + expect(root_span.attributes["appsignal.tag.executions"]).to eq(1) + # The agent sibling asserts the perform_*.active_job events; mirror that + # here by checking the event spans exist and nest under the root span. + expect(event_spans.map(&:name)).to include(*expected_perform_events) + perform_span = event_spans.find { |s| s.name == "perform.active_job" } + expect(perform_span).not_to be_nil + expect(perform_span.parent_span_id).to eq(root_span.span_id) + end end context "with custom queue" do - it "reports the custom queue as tag on the transaction" do - tags = { :queue => "custom_queue" } - expect(Appsignal).to receive(:increment_counter) - .with("active_job_queue_job_count", 1, tags.merge(:status => :processed)) - queue_job(ActiveJobCustomQueueTestJob) + describe "reports the custom queue as tag" do + def perform + queue_job(ActiveJobCustomQueueTestJob) + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + + allow(Appsignal).to receive(:increment_counter) + + perform + expect(last_transaction).to include_tags("queue" => "custom_queue") + end + + it "in collector mode", :collector_mode do + start_collector_agent + + allow(Appsignal).to receive(:increment_counter) - expect(last_transaction).to include_tags("queue" => "custom_queue") + perform + last_transaction.complete + + expect(root_span.attributes["appsignal.tag.queue"]).to eq("custom_queue") + end end end @@ -162,67 +261,121 @@ def perform(*_args) end) end - it "reports the priority as tag on the transaction" do - tags = { :queue => queue } - expect(Appsignal).to receive(:increment_counter) - .with("active_job_queue_job_count", 1, tags.merge(:status => :processed)) - expect(Appsignal).to receive(:increment_counter) - .with("active_job_queue_priority_job_count", 1, tags.merge(:priority => 10, - :status => :processed)) + describe "reports the priority as tag" do + def perform + queue_job(ActiveJobPriorityTestJob) + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) - queue_job(ActiveJobPriorityTestJob) + allow(Appsignal).to receive(:increment_counter) + + perform + expect(last_transaction).to include_tags("queue" => queue, "priority" => 10) + end - expect(last_transaction).to include_tags("queue" => queue, "priority" => 10) + it "in collector mode", :collector_mode do + start_collector_agent + + allow(Appsignal).to receive(:increment_counter) + + perform + last_transaction.complete + + expect(root_span.attributes["appsignal.tag.queue"]).to eq(queue) + expect(root_span.attributes["appsignal.tag.priority"]).to eq(10) + end end end end context "with error" do - it "reports the error on the transaction from the ActiveRecord integration" do - allow(Appsignal).to receive(:increment_counter) # Other calls we're testing in another test - tags = { :queue => queue } - expect(Appsignal).to receive(:increment_counter) - .with("active_job_queue_job_count", 1, tags.merge(:status => :failed)) - expect(Appsignal).to receive(:increment_counter) - .with("active_job_queue_job_count", 1, tags.merge(:status => :processed)) - - expect do + describe "reports the error on the transaction" do + def perform queue_job(ActiveJobErrorTestJob) - end.to raise_error(RuntimeError, "uh oh") + end - transaction = last_transaction - expect(transaction).to have_namespace(namespace) - expect(transaction).to have_action("ActiveJobErrorTestJob#perform") - expect(transaction).to have_error("RuntimeError", "uh oh") - expect(transaction).to_not include_metadata - expect(transaction).to include_params([]) - expect(transaction).to include_tags( - "active_job_id" => kind_of(String), - "request_id" => kind_of(String), - "queue" => queue, - "executions" => 1 - ) + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) - events = transaction.to_h["events"] - .sort_by { |e| e["start"] } - .map { |event| event["name"] } - expect(events).to eq(expected_perform_events) + allow(Appsignal).to receive(:increment_counter) + + expect { perform }.to raise_error(RuntimeError, "uh oh") + + transaction = last_transaction + transaction._sample + expect(transaction).to have_namespace(namespace) + expect(transaction).to have_action("ActiveJobErrorTestJob#perform") + expect(transaction).to have_error("RuntimeError", "uh oh") + expect(transaction).to_not include_metadata + expect(transaction).to include_params([]) + expect(transaction).to include_tags( + "active_job_id" => kind_of(String), + "request_id" => kind_of(String), + "queue" => queue, + "executions" => 1 + ) + events = transaction.to_h["events"] + .sort_by { |e| e["start"] } + .map { |event| event["name"] } + expect(events).to eq(expected_perform_events) + end + + it "in collector mode", :collector_mode do + start_collector_agent + + allow(Appsignal).to receive(:increment_counter) + + expect { perform }.to raise_error(RuntimeError, "uh oh") + last_transaction.complete + + expect(root_span.attributes["appsignal.namespace"]).to eq("background") + expect(root_span.attributes["appsignal.action_name"]) + .to eq("ActiveJobErrorTestJob#perform") + event = root_span.events.find { |e| e.name == "exception" } + expect(event).not_to be_nil + expect(event.attributes["exception.type"]).to eq("RuntimeError") + expect(event.attributes["exception.message"]).to eq("uh oh") + expect(event.attributes["exception.stacktrace"]).to be_a(String) + expect(event.attributes["appsignal.alert_this_error"]).to eq(true) + expect(root_span.status.code).to eq(::OpenTelemetry::Trace::Status::ERROR) + expect(root_span.attributes).to_not have_key("appsignal.metadata") + expect(JSON.parse(root_span.attributes["appsignal.function.parameters"])).to eq([]) + expect(root_span.attributes["appsignal.tag.active_job_id"]).to be_a(String) + expect(root_span.attributes["appsignal.tag.request_id"]).to be_a(String) + expect(root_span.attributes["appsignal.tag.queue"]).to eq(queue) + expect(root_span.attributes["appsignal.tag.executions"]).to eq(1) + end end context "with activejob_report_errors set to none" do let(:options) { { :activejob_report_errors => "none" } } - it "does not report the error" do - allow(Appsignal).to receive(:increment_counter) - tags = { :queue => queue } - expect(Appsignal).to receive(:increment_counter) - .with("active_job_queue_job_count", 1, tags.merge(:status => :failed)) - - expect do + describe "does not report the error" do + def perform queue_job(ActiveJobErrorTestJob) - end.to raise_error(RuntimeError, "uh oh") + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + + allow(Appsignal).to receive(:increment_counter) - expect(last_transaction).to_not have_error + expect { perform }.to raise_error(RuntimeError, "uh oh") + expect(last_transaction).to_not have_error + end + + it "in collector mode", :collector_mode do + start_collector_agent + + allow(Appsignal).to receive(:increment_counter) + + expect { perform }.to raise_error(RuntimeError, "uh oh") + last_transaction.complete + + expect(exception_events).to be_empty + end end end @@ -230,35 +383,74 @@ def perform(*_args) context "with activejob_report_errors set to discard" do let(:options) { { :activejob_report_errors => "discard" } } - it "does not report error on first failure" do - with_test_adapter do - # Prevent the job from being instantly retried so we can test - # what happens before it's retried - allow_any_instance_of(ActiveJobErrorWithRetryTestJob).to receive(:retry_job) + describe "does not report error on first failure" do + def perform + with_test_adapter do + # Prevent the job from being instantly retried so we can test + # what happens before it's retried + allow_any_instance_of(ActiveJobErrorWithRetryTestJob).to receive(:retry_job) - queue_job(ActiveJobErrorWithRetryTestJob) + queue_job(ActiveJobErrorWithRetryTestJob) + end end - transaction = last_transaction - expect(transaction).to_not have_error - expect(transaction).to include_tags("executions" => 1) + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + + perform + + transaction = last_transaction + transaction._sample + expect(transaction).to_not have_error + expect(transaction).to include_tags("executions" => 1) + end + + it "in collector mode", :collector_mode do + start_collector_agent + + perform + last_transaction.complete + + expect(exception_events).to be_empty + expect(root_span.attributes["appsignal.tag.executions"]).to eq(1) + end end - it "reports error when discarding the job" do - allow(Appsignal).to receive(:increment_counter) - tags = { :queue => queue } - expect(Appsignal).to receive(:increment_counter) - .with("active_job_queue_job_count", 1, tags.merge(:status => :failed)) + describe "reports error when discarding the job" do + def perform + allow(Appsignal).to receive(:increment_counter) - with_test_adapter do - expect do + with_test_adapter do queue_job(ActiveJobErrorWithRetryTestJob) - end.to raise_error(RuntimeError, "uh oh") + end end - transaction = last_transaction - expect(transaction).to have_error("RuntimeError", "uh oh") - expect(transaction).to include_tags("executions" => 2) + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + + expect { perform }.to raise_error(RuntimeError, "uh oh") + + transaction = last_transaction + transaction._sample + expect(transaction).to have_error("RuntimeError", "uh oh") + expect(transaction).to include_tags("executions" => 2) + end + + it "in collector mode", :collector_mode do + start_collector_agent + + expect { perform }.to raise_error(RuntimeError, "uh oh") + last_transaction.complete + + event = exception_events.find do |e| + e.attributes["exception.type"] == "RuntimeError" + end + expect(event).not_to be_nil + expect(event.attributes["exception.message"]).to eq("uh oh") + expect(event.attributes["exception.stacktrace"]).to be_a(String) + expect(event.attributes["appsignal.alert_this_error"]).to eq(true) + expect(root_span.attributes["appsignal.tag.executions"]).to eq(2) + end end end end @@ -275,131 +467,309 @@ def perform(*_args) end) end - it "reports the priority as tag on the transaction" do - tags = { :queue => queue } - expect(Appsignal).to receive(:increment_counter) - .with("active_job_queue_job_count", 1, tags.merge(:status => :processed)) - expect(Appsignal).to receive(:increment_counter) - .with("active_job_queue_job_count", 1, tags.merge(:status => :failed)) - expect(Appsignal).to receive(:increment_counter) - .with("active_job_queue_priority_job_count", 1, tags.merge(:priority => 10, - :status => :processed)) - expect(Appsignal).to receive(:increment_counter) - .with("active_job_queue_priority_job_count", 1, tags.merge(:priority => 10, - :status => :failed)) - - expect do + describe "reports the priority as tag" do + def perform queue_job(ActiveJobErrorPriorityTestJob) - end.to raise_error(RuntimeError, "uh oh") + end - expect(last_transaction).to include_tags("queue" => queue, "priority" => 10) + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + + allow(Appsignal).to receive(:increment_counter) + + expect { perform }.to raise_error(RuntimeError, "uh oh") + expect(last_transaction).to include_tags("queue" => queue, "priority" => 10) + end + + it "in collector mode", :collector_mode do + start_collector_agent + + allow(Appsignal).to receive(:increment_counter) + + expect { perform }.to raise_error(RuntimeError, "uh oh") + last_transaction.complete + + event = root_span.events.find { |e| e.name == "exception" } + expect(event).not_to be_nil + expect(event.attributes["exception.type"]).to eq("RuntimeError") + expect(event.attributes["exception.message"]).to eq("uh oh") + expect(event.attributes["appsignal.alert_this_error"]).to eq(true) + expect(root_span.status.code).to eq(::OpenTelemetry::Trace::Status::ERROR) + expect(root_span.attributes["appsignal.tag.queue"]).to eq(queue) + expect(root_span.attributes["appsignal.tag.priority"]).to eq(10) + end end end end end context "with retries" do - it "reports the number of retries as executions" do - with_test_adapter do - expect do + describe "reports the number of retries as executions" do + def perform + with_test_adapter do queue_job(ActiveJobErrorWithRetryTestJob) - end.to raise_error(RuntimeError, "uh oh") + end end - expect(last_transaction).to include_tags("executions" => 2) + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + + expect { perform }.to raise_error(RuntimeError, "uh oh") + expect(last_transaction).to include_tags("executions" => 2) + end + + it "in collector mode", :collector_mode do + start_collector_agent + + expect { perform }.to raise_error(RuntimeError, "uh oh") + last_transaction.complete + + expect(root_span.attributes["appsignal.tag.executions"]).to eq(2) + end end end context "when wrapped in another transaction" do - it "does not create a new transaction or close the currently open one" do - current_transaction = background_job_transaction - set_current_transaction current_transaction + describe "does not create a new transaction or close the currently open one" do + def perform(current_transaction) + set_current_transaction current_transaction + queue_job(ActiveJobTestJob) + end - queue_job(ActiveJobTestJob) + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) - expect(created_transactions.count).to eql(1) + allow(Appsignal).to receive(:increment_counter) - transaction = current_transaction - expect(transaction).to_not be_completed - transaction._sample - # It does set data on the transaction - expect(transaction).to have_namespace(namespace) - expect(transaction).to have_id(current_transaction.transaction_id) - expect(transaction).to have_action("ActiveJobTestJob#perform") - expect(transaction).to_not have_error - expect(transaction).to_not include_metadata - expect(transaction).to include_params([]) - expect(transaction).to include_tags( - "active_job_id" => kind_of(String), - "request_id" => kind_of(String), - "queue" => queue, - "executions" => 1 - ) + current_transaction = background_job_transaction + perform(current_transaction) + + expect(created_transactions.count).to eql(1) + + transaction = current_transaction + expect(transaction).to_not be_completed + transaction._sample + # It does set data on the transaction + expect(transaction).to have_namespace(namespace) + expect(transaction).to have_id(current_transaction.transaction_id) + expect(transaction).to have_action("ActiveJobTestJob#perform") + expect(transaction).to_not have_error + expect(transaction).to_not include_metadata + expect(transaction).to include_params([]) + expect(transaction).to include_tags( + "active_job_id" => kind_of(String), + "request_id" => kind_of(String), + "queue" => queue, + "executions" => 1 + ) - events = transaction.to_h["events"] - .reject { |e| e["name"] == "enqueue.active_job" } - .sort_by { |e| e["start"] } - .map { |event| event["name"] } - expect(events).to eq(expected_perform_events) + events = transaction.to_h["events"] + .reject { |e| e["name"] == "enqueue.active_job" } + .sort_by { |e| e["start"] } + .map { |event| event["name"] } + expect(events).to eq(expected_perform_events) + end + + it "in collector mode", :collector_mode do + start_collector_agent + + allow(Appsignal).to receive(:increment_counter) + + current_transaction = background_job_transaction + perform(current_transaction) + + expect(created_transactions.count).to eql(1) + expect(current_transaction).to_not be_completed + + current_transaction.complete + + expect(root_span.attributes["appsignal.namespace"]).to eq("background") + expect(root_span.attributes["appsignal.action_name"]).to eq("ActiveJobTestJob#perform") + expect(exception_events).to be_empty + expect(root_span.attributes).to_not have_key("appsignal.metadata") + expect(JSON.parse(root_span.attributes["appsignal.function.parameters"])).to eq([]) + expect(root_span.attributes["appsignal.tag.active_job_id"]).to be_a(String) + expect(root_span.attributes["appsignal.tag.request_id"]).to be_a(String) + expect(root_span.attributes["appsignal.tag.queue"]).to eq(queue) + expect(root_span.attributes["appsignal.tag.executions"]).to eq(1) + end end end - context "when enqueuing a job" do - before { ActiveJob::Base.queue_adapter = :test } + context "with distributed trace context" do + let(:trace_id_hex) { "0af7651916cd43dd8448eb211c80319c" } + let(:span_id_hex) { "b7ad6b7169203331" } + let(:traceparent) { "00-#{trace_id_hex}-#{span_id_hex}-01" } + + describe "serializing context onto the job" do + it "round-trips __otel_headers through serialize/deserialize in collector mode", + :collector_mode do + start_collector_agent + + job = ActiveJobTestJob.new + job.__otel_headers = { "traceparent" => traceparent } + data = job.serialize + + # Wire-compatible with OpenTelemetry: headers ride as an array of + # [key, value] pairs (ActiveJob's argument-serializer output), not a + # hash. + expect(data["__otel_headers"]).to eq([["traceparent", traceparent]]) + expect(ActiveJobTestJob.deserialize(data).__otel_headers) + .to eq("traceparent" => traceparent) + end - context "with an active transaction" do - it "records a single enqueue.active_job event on the transaction" do + it "leaves the job untouched outside collector mode", :agent_mode do + start_agent(**start_agent_args) + + job = ActiveJobTestJob.new + job.__otel_headers = { "traceparent" => traceparent } + + expect(job.serialize).to_not have_key("__otel_headers") + end + end + + describe "injecting context on enqueue" do + before { ActiveJob::Base.queue_adapter = :test } + + # Returns the enqueuing transaction so the example can read its events. + def enqueue_within_transaction transaction = http_request_transaction set_current_transaction(transaction) - ActiveJobTestJob.perform_later + transaction + end + + it "writes the producer span's context onto the job in collector mode", + :collector_mode do + start_collector_agent + enqueue_within_transaction + Appsignal::Transaction.complete_current! + + # The enqueue is a producer event span under the enqueuing + # transaction. Its name leads with the category, followed by the + # title naming the job being enqueued. + producer = event_span_for("enqueue.active_job") + expect(producer.name).to eq("enqueue.active_job (enqueue ActiveJobTestJob job)") + expect(scope_of(producer)).to eq(["appsignal-ruby/active_job", Appsignal::VERSION]) + expect(producer.kind).to eq(:producer) + expect(producer.attributes["messaging.system"]).to eq("active_job") + expect(producer.attributes["messaging.operation.name"]).to eq("enqueue") + expect(producer.attributes["messaging.operation.type"]).to eq("send") + expect(producer.attributes["messaging.destination.name"]).to eq("default") + expect(producer.parent_span_id).to eq(root_span.span_id) + + # The serialized job carries that span's context, so the performed job + # links back to it. + enqueued = ActiveJob::Base.queue_adapter.enqueued_jobs.first + expect(enqueued["__otel_headers"]).to eq( + [["traceparent", "00-#{producer.hex_trace_id}-#{producer.hex_span_id}-01"]] + ) + end - # Exactly one enqueue event: ours. Rails' native `enqueue.active_job` + it "records an enqueue event without wire context in agent mode", :agent_mode do + start_agent(**start_agent_args) + transaction = enqueue_within_transaction + + # Exactly one enqueue event: ours. The native `enqueue.active_job` # notification is suppressed so it isn't recorded a second time. enqueue_events = transaction.to_h["events"].select { |event| event["name"] == "enqueue.active_job" } expect(enqueue_events.size).to eq(1) # The event is titled after the job being enqueued. expect(enqueue_events.first["title"]).to eq("enqueue ActiveJobTestJob job") - end - end - - context "without an active transaction" do - it "is a transparent pass-through that still enqueues the job" do - expect do - ActiveJobTestJob.perform_later - end.to_not(change { created_transactions.count }) - expect(ActiveJob::Base.queue_adapter.enqueued_jobs.count).to eq(1) + enqueued = ActiveJob::Base.queue_adapter.enqueued_jobs.first + expect(enqueued).to_not have_key("__otel_headers") end end - context "with an active transaction" do - it "suppresses nested adapter enqueue events while enqueuing" do - transaction = http_request_transaction - set_current_transaction(transaction) + describe "suppressing nested adapter enqueue events" do + before { ActiveJob::Base.queue_adapter = :test } - # The window in which a nested adapter integration (Sidekiq, Resque, - # ...) would record its own event, which Active Job suppresses so the - # enqueue is recorded once. - suppressed_during_enqueue = nil + # Records whether job enqueue events were suppressed at the moment the + # adapter enqueued the job -- the window in which a nested adapter + # integration (Sidekiq, Resque, ...) would record its own event, and + # which Active Job suppresses so the enqueue is recorded once. + def suppressed_during_enqueue + captured = nil adapter = ActiveJob::Base.queue_adapter allow(adapter).to receive(:enqueue).and_wrap_original do |method, *args| - suppressed_during_enqueue = - Appsignal::Transaction.current.job_enqueue_events_suppressed? + captured = Appsignal::Transaction.current.job_enqueue_events_suppressed? method.call(*args) end + transaction = http_request_transaction + set_current_transaction(transaction) ActiveJobTestJob.perform_later + captured + end + + it "suppresses them while the adapter enqueues in agent mode", :agent_mode do + start_agent(**start_agent_args) + expect(suppressed_during_enqueue).to be(true) + end + + it "suppresses them while the adapter enqueues in collector mode", + :collector_mode do + start_collector_agent expect(suppressed_during_enqueue).to be(true) end end + describe "linking a performed job back to the enqueuer" do + # A job arrives with OpenTelemetry's serialized array-of-pairs carrier. + def perform_with_incoming_context + job_data = ActiveJobTestJob.new.serialize + .merge("__otel_headers" => [["traceparent", traceparent]]) + perform_active_job { ActiveJob::Base.execute(job_data) } + end + + it "parents and links the job trace to the enqueuer in collector mode", + :collector_mode do + start_collector_agent + perform_with_incoming_context + + # The job continues the enqueuer's trace as a child and links back to it. + expect(root_span.kind).to eq(:consumer) + expect(root_span.hex_trace_id).to eq(trace_id_hex) + expect(root_span.parent_span_id.unpack1("H*")).to eq(span_id_hex) + expect(root_span.links.size).to eq(1) + link = root_span.links.first.span_context + expect(link.hex_trace_id).to eq(trace_id_hex) + expect(link.hex_span_id).to eq(span_id_hex) + end + + it "does not leak the trace context as metadata in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform_with_incoming_context + + expect(last_transaction.to_h["metadata"].keys).to_not include("__otel_headers") + end + end + context "when enqueue instrumentation is disabled" do let(:options) { { :enable_job_enqueue_instrumentation => false } } + before { ActiveJob::Base.queue_adapter = :test } + + # Captures the job as the adapter receives it, so the test can check what + # trace context the enqueue wrote onto it. + def enqueue_and_capture_job + captured = nil + adapter = ActiveJob::Base.queue_adapter + allow(adapter).to receive(:enqueue).and_wrap_original do |method, job| + captured = job + method.call(job) + end + + set_current_transaction(http_request_transaction) + ActiveJobTestJob.perform_later - it "does not record an enqueue event but still enqueues the job" do + captured + end + + it "does not record an enqueue event but still enqueues the job", :agent_mode do + start_agent(**start_agent_args) transaction = http_request_transaction set_current_transaction(transaction) @@ -410,28 +780,70 @@ def perform(*_args) expect(enqueue_events).to be_empty expect(ActiveJob::Base.queue_adapter.enqueued_jobs.count).to eq(1) end + + it "emits no enqueue span and writes no trace context", :collector_mode do + start_collector_agent + + job = enqueue_and_capture_job + Appsignal::Transaction.complete_current! + + # No enqueue event means no producer span, so there is nothing for the + # performing job to link back to and no trace context is written. The + # job starts its own trace instead of linking to the web request. + expect(event_spans_for("enqueue.active_job")).to be_empty + expect(job.serialize).to_not have_key("__otel_headers") + expect(ActiveJob::Base.queue_adapter.enqueued_jobs.count).to eq(1) + end end end context "with params" do let(:options) { { :filter_parameters => ["foo"] } } - it "filters the configured params" do - queue_job(ActiveJobTestJob, method_given_args) + describe "filters the configured params" do + def perform + queue_job(ActiveJobTestJob, method_given_args) + end - transaction = last_transaction - transaction_hash = transaction.to_h - expect(transaction_hash["sample_data"]["params"]).to include( - [ - "foo", - { - "_aj_symbol_keys" => ["foo"], - "foo" => "[FILTERED]", - "bar" => "Bar", - "baz" => { "_aj_symbol_keys" => [], "1" => "foo" } - } - ] - ) + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + + perform + + transaction = last_transaction + transaction_hash = transaction.to_h + expect(transaction_hash["sample_data"]["params"]).to include( + [ + "foo", + { + "_aj_symbol_keys" => ["foo"], + "foo" => "[FILTERED]", + "bar" => "Bar", + "baz" => { "_aj_symbol_keys" => [], "1" => "foo" } + } + ] + ) + end + + it "in collector mode", :collector_mode do + start_collector_agent + + perform + last_transaction.complete + + params = JSON.parse(root_span.attributes["appsignal.function.parameters"]) + expect(params).to include( + [ + "foo", + { + "_aj_symbol_keys" => ["foo"], + "foo" => "[FILTERED]", + "bar" => "Bar", + "baz" => { "_aj_symbol_keys" => [], "1" => "foo" } + } + ] + ) + end end end @@ -462,12 +874,29 @@ def perform(*_args) end) end - it "sets provider_job_id as tag" do - queue_job(ProviderWrappedActiveJobTestJob) + describe "sets provider_job_id as tag" do + def perform + queue_job(ProviderWrappedActiveJobTestJob) + end - expect(last_transaction).to include_tags( - "provider_job_id" => "my_provider_job_id" - ) + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + + perform + expect(last_transaction).to include_tags( + "provider_job_id" => "my_provider_job_id" + ) + end + + it "in collector mode", :collector_mode do + start_collector_agent + + perform + last_transaction.complete + + expect(root_span.attributes["appsignal.tag.provider_job_id"]) + .to eq("my_provider_job_id") + end end end @@ -499,22 +928,17 @@ def perform(*_args) end) end - it "sets queue time on transaction" do + # `have_queue_start` reads agent-only backend state, so this stays + # agent-only. In collector mode the queue start surfaces as a span event + # and `transaction_queue_duration` metric (covered in the transaction spec). + it "sets queue time on transaction", :agent_mode do + start_agent(**start_agent_args) + queue_job(ProviderWrappedActiveJobTestJob) queue_time = Time.parse("2001-01-01T09:00:00.000000000Z") expect(last_transaction).to have_queue_start((queue_time.to_f * 1_000).to_i) end - - it "reports the queue time" do - allow(Appsignal).to receive(:add_distribution_value) - - queue_job(ProviderWrappedActiveJobTestJob) - - # Asserts 1 hour queue time - expect(Appsignal).to have_received(:add_distribution_value) - .with("active_job_queue_time", 3_600_000.0, :queue => queue) - end end context "with ActionMailer job" do @@ -528,55 +952,67 @@ def welcome(_first_arg = nil, _second_arg = nil) end context "without params" do - it "sets the Action mailer data on the transaction" do - perform_mailer(ActionMailerTestJob, :welcome) + describe "sets the Action mailer data on the transaction" do + def perform + perform_mailer(ActionMailerTestJob, :welcome) + end - transaction = last_transaction - expect(transaction).to have_action("ActionMailerTestJob#welcome") - expect(transaction).to include_params( - ["ActionMailerTestJob", "welcome", "deliver_now"] + active_job_args_wrapper - ) - expect(transaction).to include_tags( - "active_job_id" => kind_of(String), - "request_id" => kind_of(String), - "queue" => "mailers", - "executions" => 1 - ) + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + + perform + + transaction = last_transaction + transaction._sample + expect(transaction).to have_action("ActionMailerTestJob#welcome") + expect(transaction).to include_params( + ["ActionMailerTestJob", "welcome", "deliver_now"] + active_job_args_wrapper + ) + expect(transaction).to include_tags( + "active_job_id" => kind_of(String), + "request_id" => kind_of(String), + "queue" => "mailers", + "executions" => 1 + ) + end + + it "in collector mode", :collector_mode do + start_collector_agent + + perform + last_transaction.complete + + expect(root_span.attributes["appsignal.action_name"]) + .to eq("ActionMailerTestJob#welcome") + expected_params = + ["ActionMailerTestJob", "welcome", "deliver_now"] + active_job_args_wrapper + expect(JSON.parse(root_span.attributes["appsignal.function.parameters"])) + .to eq(expected_params) + expect(root_span.attributes["appsignal.tag.active_job_id"]).to be_a(String) + expect(root_span.attributes["appsignal.tag.request_id"]).to be_a(String) + expect(root_span.attributes["appsignal.tag.queue"]).to eq("mailers") + expect(root_span.attributes["appsignal.tag.executions"]).to eq(1) + end end end context "with multiple arguments" do - it "sets the arguments on the transaction" do - perform_mailer(ActionMailerTestJob, :welcome, method_given_args) + describe "sets the arguments on the transaction" do + def perform + perform_mailer(ActionMailerTestJob, :welcome, method_given_args) + end - transaction = last_transaction - expect(transaction).to have_action("ActionMailerTestJob#welcome") - expect(transaction).to include_params( - ["ActionMailerTestJob", "welcome", - "deliver_now"] + active_job_args_wrapper(:args => method_expected_args) - ) - expect(transaction).to include_tags( - "active_job_id" => kind_of(String), - "request_id" => kind_of(String), - "queue" => "mailers", - "executions" => 1 - ) - end - end + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) - if DependencyHelper.rails_version >= Gem::Version.new("5.2.0") - context "with parameterized arguments" do - it "sets the arguments on the transaction" do - perform_mailer(ActionMailerTestJob, :welcome, parameterized_given_args) + perform transaction = last_transaction + transaction._sample expect(transaction).to have_action("ActionMailerTestJob#welcome") expect(transaction).to include_params( - [ - "ActionMailerTestJob", - "welcome", - "deliver_now" - ] + active_job_args_wrapper(:params => parameterized_expected_args) + ["ActionMailerTestJob", "welcome", + "deliver_now"] + active_job_args_wrapper(:args => method_expected_args) ) expect(transaction).to include_tags( "active_job_id" => kind_of(String), @@ -585,6 +1021,80 @@ def welcome(_first_arg = nil, _second_arg = nil) "executions" => 1 ) end + + it "in collector mode", :collector_mode do + start_collector_agent + + perform + last_transaction.complete + + expect(root_span.attributes["appsignal.action_name"]) + .to eq("ActionMailerTestJob#welcome") + expected_params = + ["ActionMailerTestJob", "welcome", + "deliver_now"] + active_job_args_wrapper(:args => method_expected_args) + expect(JSON.parse(root_span.attributes["appsignal.function.parameters"])) + .to eq(expected_params) + expect(root_span.attributes["appsignal.tag.active_job_id"]).to be_a(String) + expect(root_span.attributes["appsignal.tag.request_id"]).to be_a(String) + expect(root_span.attributes["appsignal.tag.queue"]).to eq("mailers") + expect(root_span.attributes["appsignal.tag.executions"]).to eq(1) + end + end + end + + if DependencyHelper.rails_version >= Gem::Version.new("5.2.0") + context "with parameterized arguments" do + describe "sets the arguments on the transaction" do + def perform + perform_mailer(ActionMailerTestJob, :welcome, parameterized_given_args) + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + + perform + + transaction = last_transaction + transaction._sample + expect(transaction).to have_action("ActionMailerTestJob#welcome") + expect(transaction).to include_params( + [ + "ActionMailerTestJob", + "welcome", + "deliver_now" + ] + active_job_args_wrapper(:params => parameterized_expected_args) + ) + expect(transaction).to include_tags( + "active_job_id" => kind_of(String), + "request_id" => kind_of(String), + "queue" => "mailers", + "executions" => 1 + ) + end + + it "in collector mode", :collector_mode do + start_collector_agent + + perform + last_transaction.complete + + expect(root_span.attributes["appsignal.action_name"]) + .to eq("ActionMailerTestJob#welcome") + expected_params = + [ + "ActionMailerTestJob", + "welcome", + "deliver_now" + ] + active_job_args_wrapper(:params => parameterized_expected_args) + expect(JSON.parse(root_span.attributes["appsignal.function.parameters"])) + .to eq(expected_params) + expect(root_span.attributes["appsignal.tag.active_job_id"]).to be_a(String) + expect(root_span.attributes["appsignal.tag.request_id"]).to be_a(String) + expect(root_span.attributes["appsignal.tag.queue"]).to eq("mailers") + expect(root_span.attributes["appsignal.tag.executions"]).to eq(1) + end + end end end end @@ -602,42 +1112,25 @@ def welcome(*_args) end) end - it "sets the Action mailer data on the transaction" do - perform_mailer(ActionMailerTestMailDeliveryJob, :welcome) + describe "sets the Action mailer data on the transaction" do + def perform + perform_mailer(ActionMailerTestMailDeliveryJob, :welcome) + end - transaction = last_transaction - expect(transaction).to have_action("ActionMailerTestMailDeliveryJob#welcome") - expect(transaction).to include_params( - [ - "ActionMailerTestMailDeliveryJob", - "welcome", - "deliver_now", - { active_job_internal_key => ["args"], "args" => [] } - ] - ) - expect(transaction).to include_tags( - "active_job_id" => kind_of(String), - "request_id" => kind_of(String), - "queue" => "mailers", - "executions" => 1 - ) - end + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) - context "with method arguments" do - it "sets the Action mailer data on the transaction" do - perform_mailer(ActionMailerTestMailDeliveryJob, :welcome, method_given_args) + perform transaction = last_transaction + transaction._sample expect(transaction).to have_action("ActionMailerTestMailDeliveryJob#welcome") expect(transaction).to include_params( [ "ActionMailerTestMailDeliveryJob", "welcome", "deliver_now", - { - active_job_internal_key => ["args"], - "args" => method_expected_args - } + { active_job_internal_key => ["args"], "args" => [] } ] ) expect(transaction).to include_tags( @@ -647,15 +1140,103 @@ def welcome(*_args) "executions" => 1 ) end + + it "in collector mode", :collector_mode do + start_collector_agent + + perform + last_transaction.complete + + expect(root_span.attributes["appsignal.action_name"]) + .to eq("ActionMailerTestMailDeliveryJob#welcome") + expect(JSON.parse(root_span.attributes["appsignal.function.parameters"])) + .to eq([ + "ActionMailerTestMailDeliveryJob", + "welcome", + "deliver_now", + { active_job_internal_key => ["args"], "args" => [] } + ]) + expect(root_span.attributes["appsignal.tag.active_job_id"]).to be_a(String) + expect(root_span.attributes["appsignal.tag.request_id"]).to be_a(String) + expect(root_span.attributes["appsignal.tag.queue"]).to eq("mailers") + expect(root_span.attributes["appsignal.tag.executions"]).to eq(1) + end + end + + context "with method arguments" do + describe "sets the Action mailer data on the transaction" do + def perform + perform_mailer(ActionMailerTestMailDeliveryJob, :welcome, method_given_args) + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + + perform + + transaction = last_transaction + transaction._sample + expect(transaction).to have_action("ActionMailerTestMailDeliveryJob#welcome") + expect(transaction).to include_params( + [ + "ActionMailerTestMailDeliveryJob", + "welcome", + "deliver_now", + { + active_job_internal_key => ["args"], + "args" => method_expected_args + } + ] + ) + expect(transaction).to include_tags( + "active_job_id" => kind_of(String), + "request_id" => kind_of(String), + "queue" => "mailers", + "executions" => 1 + ) + end + + it "in collector mode", :collector_mode do + start_collector_agent + + perform + last_transaction.complete + + expect(root_span.attributes["appsignal.action_name"]) + .to eq("ActionMailerTestMailDeliveryJob#welcome") + expect(JSON.parse(root_span.attributes["appsignal.function.parameters"])) + .to eq([ + "ActionMailerTestMailDeliveryJob", + "welcome", + "deliver_now", + { + active_job_internal_key => ["args"], + "args" => method_expected_args + } + ]) + expect(root_span.attributes["appsignal.tag.active_job_id"]).to be_a(String) + expect(root_span.attributes["appsignal.tag.request_id"]).to be_a(String) + expect(root_span.attributes["appsignal.tag.queue"]).to eq("mailers") + expect(root_span.attributes["appsignal.tag.executions"]).to eq(1) + end + end end context "with parameterized arguments" do - it "sets the Action mailer data on the transaction" do - perform_mailer(ActionMailerTestMailDeliveryJob, :welcome, parameterized_given_args) + describe "sets the Action mailer data on the transaction" do + def perform + perform_mailer(ActionMailerTestMailDeliveryJob, :welcome, parameterized_given_args) + end - transaction = last_transaction - expect(transaction).to have_action("ActionMailerTestMailDeliveryJob#welcome") - expect(transaction).to include_params( + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + + perform + + transaction = last_transaction + transaction._sample + expect(transaction).to have_action("ActionMailerTestMailDeliveryJob#welcome") + expect(transaction).to include_params( [ "ActionMailerTestMailDeliveryJob", "welcome", @@ -667,12 +1248,38 @@ def welcome(*_args) } ] ) - expect(transaction).to include_tags( - "active_job_id" => kind_of(String), - "request_id" => kind_of(String), - "queue" => "mailers", - "executions" => 1 - ) + expect(transaction).to include_tags( + "active_job_id" => kind_of(String), + "request_id" => kind_of(String), + "queue" => "mailers", + "executions" => 1 + ) + end + + it "in collector mode", :collector_mode do + start_collector_agent + + perform + last_transaction.complete + + expect(root_span.attributes["appsignal.action_name"]) + .to eq("ActionMailerTestMailDeliveryJob#welcome") + expect(JSON.parse(root_span.attributes["appsignal.function.parameters"])) + .to eq([ + "ActionMailerTestMailDeliveryJob", + "welcome", + "deliver_now", + { + active_job_internal_key => ["params", "args"], + "args" => [], + "params" => parameterized_expected_args + } + ]) + expect(root_span.attributes["appsignal.tag.active_job_id"]).to be_a(String) + expect(root_span.attributes["appsignal.tag.request_id"]).to be_a(String) + expect(root_span.attributes["appsignal.tag.queue"]).to eq("mailers") + expect(root_span.attributes["appsignal.tag.executions"]).to eq(1) + end end end end @@ -714,4 +1321,198 @@ def active_job_internal_key end end end + + # The agent has no in-memory metric readout, so agent mode keeps the + # `increment_counter` mock while collector mode asserts the same metric + # reaches the OpenTelemetry backend. Only the metric is asserted here — the + # transaction-shape coverage stays agent-only (in the instrumentation describe + # above), since action/namespace/tags aren't implemented in collector mode + # yet. Self-contained so it doesn't inherit the `ActiveJobClassInstrumentation` + # group's parameterized `start_agent`; `start_agent` comes from the mode + # contexts. + describe "emitting the queue job count metric" do + before do + ActiveJob::Base.queue_adapter = :inline + stub_const("ActiveJobTestJob", Class.new(ActiveJob::Base) do + def perform(*_args) + end + end) + end + + def perform + ActiveJobTestJob.perform_later + end + + it "in agent mode", :agent_mode do + start_agent + + expect(Appsignal).to receive(:increment_counter) + .with("active_job_queue_job_count", 1, { :queue => "default", :status => :processed }) + + perform + end + + it "in collector mode", :collector_mode do + start_collector_agent + + perform + + snapshot = metric_snapshot("active_job_queue_job_count") + expect(snapshot).not_to be_nil + expect(snapshot.data_points.first.value).to eq(1.0) + expect(snapshot.data_points.first.attributes).to include( + "queue" => "default", + "status" => "processed" + ) + end + end + + # A failing job emits the job count metric a second time, tagged + # `status: failed`. Self-contained, same rationale as the describe above. + describe "emitting the failed job count metric" do + before do + ActiveJob::Base.queue_adapter = :inline + stub_const("ActiveJobFailingJob", Class.new(ActiveJob::Base) do + def perform(*_args) + raise "uh oh" + end + end) + end + + def perform + ActiveJobFailingJob.perform_later + rescue RuntimeError + # The inline adapter re-raises the job's error; swallow it so the + # example can assert on the metric the hook emits in its `ensure`. + end + + it "in agent mode", :agent_mode do + start_agent + + allow(Appsignal).to receive(:increment_counter) # the `processed` call + expect(Appsignal).to receive(:increment_counter) + .with("active_job_queue_job_count", 1, { :queue => "default", :status => :failed }) + + perform + end + + it "in collector mode", :collector_mode do + start_collector_agent + + perform + + snapshot = metric_snapshot("active_job_queue_job_count") + expect(snapshot).not_to be_nil + failed = snapshot.data_points.find { |point| point.attributes["status"] == "failed" } + expect(failed).not_to be_nil + expect(failed.value).to eq(1.0) + expect(failed.attributes).to include("queue" => "default", "status" => "failed") + end + end + + # A job with a priority emits an additional `priority_job_count` metric. + if DependencyHelper.rails_version >= Gem::Version.new("5.0.0") + describe "emitting the priority job count metric" do + before do + ActiveJob::Base.queue_adapter = :inline + stub_const("ActiveJobPriorityJob", Class.new(ActiveJob::Base) do + queue_with_priority 10 + + def perform(*_args) + end + end) + end + + def perform + ActiveJobPriorityJob.perform_later + end + + it "in agent mode", :agent_mode do + start_agent + + allow(Appsignal).to receive(:increment_counter) # the queue_job_count call + expect(Appsignal).to receive(:increment_counter).with( + "active_job_queue_priority_job_count", + 1, + { :queue => "default", :priority => 10, :status => :processed } + ) + + perform + end + + it "in collector mode", :collector_mode do + start_collector_agent + + perform + + snapshot = metric_snapshot("active_job_queue_priority_job_count") + expect(snapshot).not_to be_nil + point = snapshot.data_points.first + expect(point.value).to eq(1.0) + expect(point.attributes).to include( + "queue" => "default", + "priority" => 10, + "status" => "processed" + ) + end + end + end + + # A job carrying an `enqueued_at` reports its queue time as a distribution. + context "with enqueued_at", + :skip => DependencyHelper.rails_version < Gem::Version.new("6.0.0") do + describe "emitting the queue time metric" do + before do + stub_const( + "ActiveJob::QueueAdapters::AppsignalTestAdapter", + Class.new(ActiveJob::QueueAdapters::InlineAdapter) do + # Inject an `enqueued_at` an hour before the frozen "now" below. + def enqueue(job) + ActiveJob::Base.execute( + job.serialize.merge("enqueued_at" => "2001-01-01T09:00:00.000000000Z") + ) + end + end + ) + stub_const("ActiveJobQueueTimeJob", Class.new(ActiveJob::Base) do + self.queue_adapter = :appsignal_test + + def perform(*_args) + end + end) + end + + def perform + Timecop.freeze(Time.parse("2001-01-01T10:00:00.000000000Z")) do + ActiveJobQueueTimeJob.perform_later + end + end + + it "in agent mode", :agent_mode do + start_agent + + allow(Appsignal).to receive(:add_distribution_value) + + perform + + # One hour of queue time, in milliseconds. + expect(Appsignal).to have_received(:add_distribution_value) + .with("active_job_queue_time", 3_600_000.0, :queue => "default") + end + + it "in collector mode", :collector_mode do + start_collector_agent + + perform + + snapshot = metric_snapshot("active_job_queue_time") + expect(snapshot).not_to be_nil + expect(snapshot.instrument_kind).to eq(:histogram) + point = snapshot.data_points.first + expect(point.count).to eq(1) + expect(point.sum).to eq(3_600_000.0) + expect(point.attributes).to include("queue" => "default") + end + end + end end diff --git a/spec/lib/appsignal/hooks/at_exit_spec.rb b/spec/lib/appsignal/hooks/at_exit_spec.rb index 9d4b11cd6..a03cb7f30 100644 --- a/spec/lib/appsignal/hooks/at_exit_spec.rb +++ b/spec/lib/appsignal/hooks/at_exit_spec.rb @@ -1,11 +1,12 @@ describe Appsignal::Hooks::AtExit do describe ".install" do - before { start_agent(:options => options) } + # The mode contexts run `start_agent`; thread the at_exit options through. + let(:start_agent_args) { { :options => options } } context "with :enable_at_exit_reporter == true" do let(:options) { { :enable_at_exit_reporter => true } } - it "installs the at_exit hook" do + it_in_both_modes "installs the at_exit hook" do expect(Appsignal::Hooks::AtExit::AtExitCallback).to receive(:call) expect(Kernel).to receive(:at_exit).with(no_args) do |*_args, &block| @@ -19,7 +20,7 @@ context "with :enable_at_exit_reporter == false" do let(:options) { { :enable_at_exit_reporter => false } } - it "doesn't install the at_exit hook" do + it_in_both_modes "doesn't install the at_exit hook" do expect(Kernel).to_not receive(:at_exit) end end @@ -27,8 +28,7 @@ end describe Appsignal::Hooks::AtExit::AtExitCallback do - around { |example| keep_transactions { example.run } } - before { start_agent(:options => options) } + let(:start_agent_args) { { :options => options } } def with_error(error_class, error_message) raise error_class, error_message @@ -48,7 +48,7 @@ def call_callback } end - it "reports no transaction if the process didn't exit with an error" do + it_in_both_modes "reports no transaction if the process didn't exit with an error" do expect(Appsignal).to_not receive(:stop) logs = capture_logs do @@ -60,20 +60,41 @@ def call_callback expect(logs).to_not contains_log(:error, "Appsignal.report_error: Cannot add error.") end - it "reports an error if there's an unhandled error" do - expect(Appsignal).to receive(:stop).with("at_exit") - expect do + describe "reports an error if there's an unhandled error" do + def perform with_error(ExampleException, "error message") do call_callback end - end.to change { created_transactions.count }.by(1) + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + expect(Appsignal).to receive(:stop).with("at_exit") + expect { perform }.to change { created_transactions.count }.by(1) - transaction = last_transaction - expect(transaction).to have_namespace("unhandled") - expect(transaction).to have_error("ExampleException", "error message") + transaction = last_transaction + expect(transaction).to have_namespace("unhandled") + expect(transaction).to have_error("ExampleException", "error message") + end + + it "in collector mode", :collector_mode do + start_collector_agent + expect(Appsignal).to receive(:stop).with("at_exit") + expect { perform }.to change { created_transactions.count }.by(1) + + expect(root_span.attributes["appsignal.namespace"]).to eq("unhandled") + expect(scope_of(root_span)).to eq(["appsignal-ruby/at_exit", Appsignal::VERSION]) + event = root_span.events.find { |e| e.name == "exception" } + expect(event).not_to be_nil + expect(event.attributes["exception.type"]).to eq("ExampleException") + expect(event.attributes["exception.message"]).to eq("error message") + expect(event.attributes["exception.stacktrace"]).to be_a(String) + expect(event.attributes["appsignal.alert_this_error"]).to eq(true) + expect(root_span.status.code).to eq(::OpenTelemetry::Trace::Status::ERROR) + end end - it "doesn't report the error if it is also the last error reported" do + it_in_both_modes "doesn't report the error if it is also the last error reported" do expect(Appsignal).to_not receive(:stop) with_error(ExampleException, "error message") do |error| Appsignal.report_error(error) @@ -85,7 +106,7 @@ def call_callback end end - it "doesn't report the error if it is a SystemExit exception" do + it_in_both_modes "doesn't report the error if it is a SystemExit exception" do expect(Appsignal).to_not receive(:stop) with_error(SystemExit, "error message") do |error| Appsignal.report_error(error) @@ -97,7 +118,7 @@ def call_callback end end - it "doesn't report the error if it is a SignalException exception" do + it_in_both_modes "doesn't report the error if it is a SignalException exception" do expect(Appsignal).to_not receive(:stop) with_error(SignalException, "TERM") do |error| Appsignal.report_error(error) @@ -118,7 +139,7 @@ def call_callback } end - it "reports no error if the process didn't exit with an error" do + it_in_both_modes "reports no error if the process didn't exit with an error" do expect(Appsignal).to_not receive(:stop) logs = capture_logs do @@ -130,7 +151,7 @@ def call_callback expect(logs).to_not contains_log(:error, "Appsignal.report_error: Cannot add error.") end - it "reports no error if there's an unhandled error" do + it_in_both_modes "reports no error if there's an unhandled error" do expect(Appsignal).to_not receive(:stop) logs = capture_logs do @@ -148,7 +169,7 @@ def call_callback context "when enable_at_exit_hook is true" do let(:options) { { :enable_at_exit_hook => "always" } } - it "calls Appsignal.stop" do + it_in_both_modes "calls Appsignal.stop" do expect(Appsignal).to receive(:stop).with("at_exit") call_callback end @@ -157,7 +178,7 @@ def call_callback context "when enable_at_exit_hook is false" do let(:options) { { :enable_at_exit_hook => false } } - it "does not call Appsignal.stop" do + it_in_both_modes "does not call Appsignal.stop" do expect(Appsignal).to_not receive(:stop).with("at_exit") call_callback end diff --git a/spec/lib/appsignal/hooks/dry_monitor_spec.rb b/spec/lib/appsignal/hooks/dry_monitor_spec.rb index ca72877ec..436721562 100644 --- a/spec/lib/appsignal/hooks/dry_monitor_spec.rb +++ b/spec/lib/appsignal/hooks/dry_monitor_spec.rb @@ -33,13 +33,8 @@ describe "Dry Monitor Integration" do let(:notifications) { Dry::Monitor::Notifications.new(:test) } - let(:transaction) { http_request_transaction } - before do - start_agent - set_current_transaction(transaction) - end - context "when is a dry-sql event" do + describe "a SQL event" do let(:event_id) { :sql } let(:payload) do { @@ -48,8 +43,15 @@ } end - it "creates an sql event named after ROM" do + def perform notifications.instrument(event_id, payload) + end + + it "in agent mode", :agent_mode do + start_agent + transaction = http_request_transaction + set_current_transaction(transaction) + perform expect(transaction).to include_event( "body" => "SELECT * FROM users", "body_format" => Appsignal::EventFormatter::SQL_BODY_FORMAT, @@ -58,18 +60,135 @@ "title" => "" ) end + + it "in collector mode", :collector_mode do + start_collector_agent + transaction = http_request_transaction + set_current_transaction(transaction) + perform + Appsignal::Transaction.complete_current! + + expect(event_spans.size).to eq(1) + span = event_spans.first + expect(span.name).to eq("query.rom") + expect(span.parent_span_id).to eq(root_span.span_id) + # ROM emits its queries as dry-monitor `sql` events; a query is an + # outgoing call, so it carries CLIENT kind. + expect(span.kind).to eq(:client) + attrs = span.attributes + expect(attrs["db.query.text"]).to eq("SELECT * FROM users") + # The payload's `name` is Sequel's database_type symbol for the + # database ROM is talking to, which the formatter maps to the real + # engine name rather than leaving it at the SQL sentinel. + expect(attrs["db.system.name"]).to eq("postgresql") + expect(event_category(span)).to eq("query.rom") + # ROM emits this event, so it is attributed to ROM rather than to the + # bus it arrived over. + expect(scope_of(span)).to eq(["appsignal-ruby/rom", Appsignal::VERSION]) + expect(attrs).not_to have_key("appsignal.body") + end end - context "when is an unregistered formatter event" do - let(:event_id) { :foo } - let(:payload) do - { - :name => "foo" - } + describe "an event whose formatter declares attributes" do + let(:event_id) { :attributed } + let(:payload) { { :name => "attributed" } } + + # A minimal stand-in for a formatter that both formats an event's body + # and names OpenTelemetry attributes for it, such as ROM's SQL + # formatter does once it names a real `db.system.name`. This proves + # `instrument` calls through to `opentelemetry_attributes`, not only + # to `format`. + let(:formatter_class) do + Class.new(Appsignal::EventFormatter) do + def format(_payload) + ["attributed.dry", "body", Appsignal::EventFormatter::DEFAULT] + end + + def opentelemetry_attributes(_payload) + { "db.system.name" => "postgresql" } + end + end + end + + before { Appsignal::EventFormatter.register("attributed.dry", formatter_class) } + + after { Appsignal::EventFormatter.unregister("attributed.dry", formatter_class) } + + def perform + notifications.instrument(event_id, payload) + end + + it "puts the formatter's attributes on the event's own span", :collector_mode do + start_collector_agent + transaction = http_request_transaction + set_current_transaction(transaction) + perform + Appsignal::Transaction.complete_current! + + expect(event_spans.size).to eq(1) + span = event_spans.first + expect(span.attributes["db.system.name"]).to eq("postgresql") + end + end + + describe "an event that another integration records" do + let(:event_id) { :claimed } + let(:payload) { { :name => "claimed" } } + + before do + Appsignal::EventFormatter.register( + "claimed.dry", + Appsignal::EventFormatter::RecordedElsewhere + ) + end + + after do + Appsignal::EventFormatter.unregister( + "claimed.dry", + Appsignal::EventFormatter::RecordedElsewhere + ) + end + + def perform + notifications.instrument(event_id, payload) { "block value" } + end + + it "in agent mode", :agent_mode do + start_agent + transaction = http_request_transaction + set_current_transaction(transaction) + + expect(perform).to eq("block value") + + expect(transaction.to_h["events"]).to be_empty + end + + it "in collector mode", :collector_mode do + start_collector_agent + transaction = http_request_transaction + set_current_transaction(transaction) + + expect(perform).to eq("block value") + + Appsignal::Transaction.complete_current! + + expect(event_spans).to be_empty end + end + + describe "an unregistered formatter event" do + let(:event_id) { :foo } + let(:payload) { { :name => "foo" } } - it "creates a generic event in the dry-monitor group" do + def perform notifications.instrument(event_id, payload) + end + + it "in agent mode", :agent_mode do + start_agent + transaction = http_request_transaction + set_current_transaction(transaction) + perform expect(transaction).to include_event( "body" => "", "body_format" => Appsignal::EventFormatter::DEFAULT, @@ -78,6 +197,29 @@ "title" => "" ) end + + it "in collector mode", :collector_mode do + start_collector_agent + transaction = http_request_transaction + set_current_transaction(transaction) + perform + Appsignal::Transaction.complete_current! + + expect(event_spans.size).to eq(1) + span = event_spans.first + expect(span.name).to eq("foo.dry") + expect(span.parent_span_id).to eq(root_span.span_id) + # A non-SQL dry event is not an outgoing call, so it keeps the default kind. + expect(span.kind).to eq(:internal) + # No formatter names a library for this event, so it is attributed to + # the bus it arrived over. + expect(scope_of(span)).to eq(["appsignal-ruby/dry_monitor", Appsignal::VERSION]) + attrs = span.attributes + expect(event_category(span)).to eq("foo.dry") + expect(attrs).not_to have_key("appsignal.body") + expect(attrs).not_to have_key("db.query.text") + expect(attrs).not_to have_key("db.system.name") + end end end end diff --git a/spec/lib/appsignal/hooks/excon_spec.rb b/spec/lib/appsignal/hooks/excon_spec.rb index 6a68ec00e..4e2a1138c 100644 --- a/spec/lib/appsignal/hooks/excon_spec.rb +++ b/spec/lib/appsignal/hooks/excon_spec.rb @@ -6,6 +6,17 @@ before do stub_const("Excon", Module.new) stub_const("Excon::Connection", Class.new) + stub_const("Excon::Middleware", Module.new) + stub_const("Excon::Middleware::Base", Class.new do + def initialize(stack = nil) + @stack = stack + end + end) + stub_const("Excon::Middleware::Mock", Class.new(Excon::Middleware::Base)) + # Mock is the innermost default middleware; the hook inserts ours before it. + Excon.singleton_class.define_method(:defaults) do + @defaults ||= { :middlewares => [Excon::Middleware::Mock] } + end Appsignal::Hooks::ExconHook.new.install end @@ -26,6 +37,21 @@ expect(Excon::Connection.ancestors) .to include(Appsignal::Integrations::ExconIntegration) end + + it "adds the AppSignal middleware to Excon, before the Mock middleware" do + middlewares = Excon.defaults[:middlewares] + expect(middlewares).to include(Appsignal::Integrations::ExconMiddleware) + expect(middlewares.index(Appsignal::Integrations::ExconMiddleware)) + .to be < middlewares.index(Excon::Middleware::Mock) + end + + it "does not add the middleware twice when installed again" do + Appsignal::Hooks::ExconHook.new.install + + expect( + Excon.defaults[:middlewares].count(Appsignal::Integrations::ExconMiddleware) + ).to eq(1) + end end end diff --git a/spec/lib/appsignal/hooks/http_spec.rb b/spec/lib/appsignal/hooks/http_spec.rb index 3df5f9175..2f29940a8 100644 --- a/spec/lib/appsignal/hooks/http_spec.rb +++ b/spec/lib/appsignal/hooks/http_spec.rb @@ -28,6 +28,11 @@ .to include(Appsignal::Integrations::HttpIntegration::HashOptions) end end + + it "injects trace context on outgoing requests" do + expect(HTTP::Client.included_modules) + .to include(Appsignal::Integrations::HttpIntegration::ContextInjection) + end end context "with instrument_http_rb set to false" do diff --git a/spec/lib/appsignal/hooks/rake_spec.rb b/spec/lib/appsignal/hooks/rake_spec.rb index 2c1360396..e56e6baa7 100644 --- a/spec/lib/appsignal/hooks/rake_spec.rb +++ b/spec/lib/appsignal/hooks/rake_spec.rb @@ -5,11 +5,9 @@ let(:task) { Rake::Task.new("task:name", Rake::Application.new) } let(:arguments) { Rake::TaskArguments.new(["foo"], ["bar"]) } let(:options) { {} } - before do - start_agent(:options => options) - allow(Kernel).to receive(:at_exit) - end - around { |example| keep_transactions { example.run } } + # The mode contexts run `start_agent`; thread the Rake options through them. + let(:start_agent_args) { { :options => options } } + before { allow(Kernel).to receive(:at_exit) } after do if helper.instance_variable_defined?(:@register_at_exit_hook) helper.remove_instance_variable(:@register_at_exit_hook) @@ -33,15 +31,15 @@ def perform context "with :enable_rake_performance_instrumentation == false" do let(:options) { { :enable_rake_performance_instrumentation => false } } - it "creates no transaction" do + it_in_both_modes "creates no transaction" do expect { perform }.to_not(change { created_transactions.count }) end - it "calls the original task" do + it_in_both_modes "calls the original task" do expect(perform).to eq([]) end - it "does not register an at_exit hook" do + it_in_both_modes "does not register an at_exit hook" do perform expect_to_not_have_registered_at_exit_hook end @@ -50,24 +48,44 @@ def perform context "with :enable_rake_performance_instrumentation == true" do let(:options) { { :enable_rake_performance_instrumentation => true } } - it "creates a transaction" do - expect { perform }.to(change { created_transactions.count }.by(1)) - - transaction = last_transaction - expect(transaction).to have_id - expect(transaction).to have_namespace("rake") - expect(transaction).to have_action("task:name") - expect(transaction).to_not have_error - expect(transaction).to include_params("foo" => "bar") - expect(transaction).to include_event("name" => "task.rake") - expect(transaction).to be_completed + describe "creates a transaction" do + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + expect { perform }.to(change { created_transactions.count }.by(1)) + + transaction = last_transaction + expect(transaction).to have_id + expect(transaction).to have_namespace("rake") + expect(transaction).to have_action("task:name") + expect(transaction).to_not have_error + expect(transaction).to include_params("foo" => "bar") + expect(transaction).to include_event("name" => "task.rake") + expect(transaction).to be_completed + end + + it "in collector mode", :collector_mode do + start_collector_agent + expect { perform }.to(change { created_transactions.count }.by(1)) + + # NOTE: params (include_params) is a collector-mode gap -- + # set_sample_data is not yet implemented in the OpenTelemetry backend. + expect(root_span.attributes["appsignal.namespace"]).to eq("rake") + expect(root_span.name).to eq("task:name") + expect(root_span.attributes["appsignal.action_name"]).to eq("task:name") + expect(exception_events).to be_empty + expect(event_spans.map(&:name)).to include("task.rake") + expect(scope_of(root_span)).to eq(["appsignal-ruby/rake", Appsignal::VERSION]) + task_span = event_spans.find { |s| s.name == "task.rake" } + expect(scope_of(task_span)).to eq(["appsignal-ruby/rake", Appsignal::VERSION]) + expect(last_transaction).to be_completed + end end - it "calls the original task" do + it_in_both_modes "calls the original task" do expect(perform).to eq([]) end - it "registers an at_exit hook" do + it_in_both_modes "registers an at_exit hook" do perform expect_to_have_registered_at_exit_hook end @@ -86,19 +104,42 @@ def perform context "with normal error" do let(:error) { ExampleException.new("error message") } - it "creates a background job transaction" do - perform + describe "creates a background job transaction" do + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + + transaction = last_transaction + expect(transaction).to have_id + expect(transaction).to have_namespace("rake") + expect(transaction).to have_action("task:name") + expect(transaction).to have_error("ExampleException", "error message") + expect(transaction).to include_params("foo" => "bar") + expect(transaction).to be_completed + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform - transaction = last_transaction - expect(transaction).to have_id - expect(transaction).to have_namespace("rake") - expect(transaction).to have_action("task:name") - expect(transaction).to have_error("ExampleException", "error message") - expect(transaction).to include_params("foo" => "bar") - expect(transaction).to be_completed + # NOTE: params (include_params) is a collector-mode gap -- + # set_sample_data is not yet implemented in the OpenTelemetry backend. + expect(root_span.attributes["appsignal.namespace"]).to eq("rake") + expect(root_span.name).to eq("task:name") + expect(root_span.attributes["appsignal.action_name"]).to eq("task:name") + + event = root_span.events.find { |e| e.name == "exception" } + expect(event).not_to be_nil + expect(event.attributes["exception.type"]).to eq("ExampleException") + expect(event.attributes["exception.message"]).to eq("error message") + expect(event.attributes["exception.stacktrace"]).to be_a(String) + expect(event.attributes["appsignal.alert_this_error"]).to eq(true) + expect(root_span.status.code).to eq(::OpenTelemetry::Trace::Status::ERROR) + expect(last_transaction).to be_completed + end end - it "registers an at_exit hook" do + it_in_both_modes "registers an at_exit hook" do perform expect_to_have_registered_at_exit_hook end @@ -106,7 +147,10 @@ def perform context "when first argument is not a `Rake::TaskArguments`" do let(:arguments) { nil } - it "does not add the params to the transaction" do + # Agent-only: asserting on params is a collector-mode gap + # (set_sample_data is not yet implemented in the OpenTelemetry backend). + it "does not add the params to the transaction", :agent_mode do + start_agent(**start_agent_args) perform expect(last_transaction).to_not include_params @@ -117,22 +161,40 @@ def perform context "when error is a SystemExit" do let(:error) { SystemExit.new(1) } - it "does not report the error" do - perform + describe "does not report the error" do + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform - transaction = last_transaction - expect(transaction).to_not have_error + expect(last_transaction).to_not have_error + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + + expect(exception_events).to be_empty + end end end context "when error is a SignalException" do let(:error) { SignalException.new(1) } - it "does not report the error" do - perform + describe "does not report the error" do + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform - transaction = last_transaction - expect(transaction).to_not have_error + expect(last_transaction).to_not have_error + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + + expect(exception_events).to be_empty + end end end end @@ -142,12 +204,16 @@ def perform describe "Appsignal::Integrations::RakeIntegrationHelper" do let(:helper) { Appsignal::Integrations::RakeIntegrationHelper } describe ".register_at_exit_hook" do - before do - start_agent - allow(Appsignal).to receive(:stop) + before { allow(Appsignal).to receive(:stop) } + # Reset the memoized registration flag so each example (including the + # agent/collector pair) starts fresh. + after do + if helper.instance_variable_defined?(:@register_at_exit_hook) + helper.remove_instance_variable(:@register_at_exit_hook) + end end - it "registers the at_exit hook only once" do + it_in_both_modes "registers the at_exit hook only once" do allow(Kernel).to receive(:at_exit) helper.register_at_exit_hook helper.register_at_exit_hook @@ -157,12 +223,9 @@ def perform describe ".at_exit_hook" do let(:helper) { Appsignal::Integrations::RakeIntegrationHelper } - before do - start_agent - allow(Appsignal).to receive(:stop) - end + before { allow(Appsignal).to receive(:stop) } - it "calls Appsignal.stop" do + it_in_both_modes "calls Appsignal.stop" do helper.at_exit_hook expect(Appsignal).to have_received(:stop).with("rake") end diff --git a/spec/lib/appsignal/hooks/redis_client_spec.rb b/spec/lib/appsignal/hooks/redis_client_spec.rb index 888af0737..d8ed1b8d5 100644 --- a/spec/lib/appsignal/hooks/redis_client_spec.rb +++ b/spec/lib/appsignal/hooks/redis_client_spec.rb @@ -1,13 +1,11 @@ describe Appsignal::Hooks::RedisClientHook do let(:options) { {} } - before do - start_agent(:options => options) - end if DependencyHelper.redis_client_present? context "with redis-client" do context "with instrumentation enabled" do describe "#dependencies_present?" do + before { start_agent(:options => options) } subject { described_class.new.dependencies_present? } context "with gem version new than 0.14.0" do @@ -35,6 +33,7 @@ context "install" do before do + start_agent(:options => options) Appsignal::Hooks.load_hooks end @@ -48,6 +47,8 @@ end context "requirements" do + before { start_agent(:options => options) } + it "driver should have the write method" do # Since we stub the driver class below, to make sure that we don't # create a real connection, the test won't fail if the method definition @@ -58,8 +59,8 @@ end context "instrumentation" do + let(:client_config) { RedisClient::Config.new(:id => "stub_id") } before do - start_agent # Stub RedisClient::RubyConnection class so that it doesn't perform an actual # Redis query. This class will be included (prepended) with the # AppSignal Redis integration. @@ -77,35 +78,93 @@ def write(_commands) # track if it was installed already or not. Appsignal::Hooks::RedisClientHook.new.install end - let(:transaction) { http_request_transaction } - let!(:client_config) { RedisClient::Config.new(:id => "stub_id") } - before { set_current_transaction(transaction) } - around { |example| keep_transactions { example.run } } - - it "instrument a redis call" do - connection = RedisClient::RubyConnection.new client_config - expect(connection.write([:get, "key"])).to eql("stub_write") - - expect(transaction).to include_event( - "name" => "query.redis", - "body" => "get ?", - "title" => "stub_id" - ) + + describe "a redis call" do + def perform + RedisClient::RubyConnection.new(client_config).write([:get, "key"]) + end + + it "in agent mode", :agent_mode do + start_agent + transaction = http_request_transaction + set_current_transaction(transaction) + expect(perform).to eql("stub_write") + + expect(transaction).to include_event( + "name" => "query.redis", + "body" => "get ?", + "title" => "stub_id" + ) + end + + it "in collector mode", :collector_mode do + start_collector_agent + transaction = http_request_transaction + set_current_transaction(transaction) + expect(perform).to eql("stub_write") + Appsignal::Transaction.complete_current! + + expect(event_spans.size).to eq(1) + span = event_spans.first + expect(span.name).to eq("query.redis (stub_id)") + expect(span.kind).to eq(:client) + expect(span.parent_span_id).to eq(root_span.span_id) + expect(span.attributes["appsignal.body"]).to eq("get ?") + expect(event_category(span)).to eq("query.redis") + expect(scope_of(span)).to eq(["appsignal-ruby/redis_client", Appsignal::VERSION]) + expect(span.attributes["db.system.name"]).to eq("redis") + # The command name, in the case the application wrote it. + expect(span.attributes["db.operation.name"]).to eq("get") + # The index of the database the connection is on, as a String. + expect(span.attributes["db.namespace"]).to eq("0") + expect(span.attributes).not_to have_key("db.query.text") + end end - it "instrument a redis script call" do - connection = ::RedisClient::RubyConnection.new client_config - script = "return redis.call('set',KEYS[1],ARGV[1])" - keys = ["foo"] - argv = ["bar"] - expect(connection.write([:eval, script, keys.size, keys, argv])) - .to eql("stub_write") - - expect(transaction).to include_event( - "name" => "query.redis", - "body" => "#{script} ? ?", - "title" => "stub_id" - ) + describe "a redis script call" do + let(:script) { "return redis.call('set',KEYS[1],ARGV[1])" } + + def perform + keys = ["foo"] + argv = ["bar"] + RedisClient::RubyConnection.new(client_config) + .write([:eval, script, keys.size, keys, argv]) + end + + it "in agent mode", :agent_mode do + start_agent + transaction = http_request_transaction + set_current_transaction(transaction) + expect(perform).to eql("stub_write") + + expect(transaction).to include_event( + "name" => "query.redis", + "body" => "#{script} ? ?", + "title" => "stub_id" + ) + end + + it "in collector mode", :collector_mode do + start_collector_agent + transaction = http_request_transaction + set_current_transaction(transaction) + expect(perform).to eql("stub_write") + Appsignal::Transaction.complete_current! + + expect(event_spans.size).to eq(1) + span = event_spans.first + expect(span.name).to eq("query.redis (stub_id)") + expect(span.kind).to eq(:client) + expect(span.parent_span_id).to eq(root_span.span_id) + expect(span.attributes["appsignal.body"]).to eq("#{script} ? ?") + expect(event_category(span)).to eq("query.redis") + expect(span.attributes["db.system.name"]).to eq("redis") + # A script is run with the EVAL command, so that is the operation. + # The script itself stays in the event body. + expect(span.attributes["db.operation.name"]).to eq("eval") + expect(span.attributes["db.namespace"]).to eq("0") + expect(span.attributes).not_to have_key("db.query.text") + end end end end @@ -118,6 +177,7 @@ def write(_commands) context "install" do before do + start_agent(:options => options) Appsignal::Hooks.load_hooks end @@ -131,6 +191,8 @@ def write(_commands) end context "requirements" do + before { start_agent(:options => options) } + it "driver should have the write method" do # Since we stub the driver class below, to make sure that we don't # create a real connection, the test won't fail if the method definition @@ -141,8 +203,8 @@ def write(_commands) end context "instrumentation" do + let(:client_config) { RedisClient::Config.new(:id => "stub_id") } before do - start_agent # Stub RedisClient::HiredisConnection class so that it doesn't perform an actual # Redis query. This class will be included (prepended) with the # AppSignal Redis integration. @@ -160,35 +222,90 @@ def write(_commands) # track if it was installed already or not. Appsignal::Hooks::RedisClientHook.new.install end - let(:transaction) { http_request_transaction } - let!(:client_config) { RedisClient::Config.new(:id => "stub_id") } - before { set_current_transaction(transaction) } - around { |example| keep_transactions { example.run } } - it "instrument a redis call" do - connection = RedisClient::HiredisConnection.new client_config - expect(connection.write([:get, "key"])).to eql("stub_write") + describe "a redis call" do + def perform + RedisClient::HiredisConnection.new(client_config).write([:get, "key"]) + end - expect(transaction).to include_event( - "name" => "query.redis", - "body" => "get ?", - "title" => "stub_id" - ) + it "in agent mode", :agent_mode do + start_agent + transaction = http_request_transaction + set_current_transaction(transaction) + expect(perform).to eql("stub_write") + + expect(transaction).to include_event( + "name" => "query.redis", + "body" => "get ?", + "title" => "stub_id" + ) + end + + it "in collector mode", :collector_mode do + start_collector_agent + transaction = http_request_transaction + set_current_transaction(transaction) + expect(perform).to eql("stub_write") + Appsignal::Transaction.complete_current! + + expect(event_spans.size).to eq(1) + span = event_spans.first + expect(span.name).to eq("query.redis (stub_id)") + expect(span.parent_span_id).to eq(root_span.span_id) + expect(span.attributes["appsignal.body"]).to eq("get ?") + expect(event_category(span)).to eq("query.redis") + expect(span.attributes["db.system.name"]).to eq("redis") + # The command name, in the case the application wrote it. + expect(span.attributes["db.operation.name"]).to eq("get") + # The index of the database the connection is on, as a String. + expect(span.attributes["db.namespace"]).to eq("0") + expect(span.attributes).not_to have_key("db.query.text") + end end - it "instrument a redis script call" do - connection = ::RedisClient::HiredisConnection.new client_config - script = "return redis.call('set',KEYS[1],ARGV[1])" - keys = ["foo"] - argv = ["bar"] - expect(connection.write([:eval, script, keys.size, keys, - argv])).to eql("stub_write") + describe "a redis script call" do + let(:script) { "return redis.call('set',KEYS[1],ARGV[1])" } - expect(transaction).to include_event( - "name" => "query.redis", - "body" => "#{script} ? ?", - "title" => "stub_id" - ) + def perform + keys = ["foo"] + argv = ["bar"] + RedisClient::HiredisConnection.new(client_config) + .write([:eval, script, keys.size, keys, argv]) + end + + it "in agent mode", :agent_mode do + start_agent + transaction = http_request_transaction + set_current_transaction(transaction) + expect(perform).to eql("stub_write") + + expect(transaction).to include_event( + "name" => "query.redis", + "body" => "#{script} ? ?", + "title" => "stub_id" + ) + end + + it "in collector mode", :collector_mode do + start_collector_agent + transaction = http_request_transaction + set_current_transaction(transaction) + expect(perform).to eql("stub_write") + Appsignal::Transaction.complete_current! + + expect(event_spans.size).to eq(1) + span = event_spans.first + expect(span.name).to eq("query.redis (stub_id)") + expect(span.parent_span_id).to eq(root_span.span_id) + expect(span.attributes["appsignal.body"]).to eq("#{script} ? ?") + expect(event_category(span)).to eq("query.redis") + expect(span.attributes["db.system.name"]).to eq("redis") + # A script is run with the EVAL command, so that is the operation. + # The script itself stays in the event body. + expect(span.attributes["db.operation.name"]).to eq("eval") + expect(span.attributes["db.namespace"]).to eq("0") + expect(span.attributes).not_to have_key("db.query.text") + end end end end @@ -200,6 +317,7 @@ def write(_commands) let(:options) { { :instrument_redis => false } } describe "#dependencies_present?" do + before { start_agent(:options => options) } subject { described_class.new.dependencies_present? } it { is_expected.to be_falsy } @@ -209,6 +327,7 @@ def write(_commands) else context "without redis-client" do describe "#dependencies_present?" do + before { start_agent(:options => options) } subject { described_class.new.dependencies_present? } it { is_expected.to be_falsy } diff --git a/spec/lib/appsignal/hooks/redis_spec.rb b/spec/lib/appsignal/hooks/redis_spec.rb index a4edab721..795e5ae2f 100644 --- a/spec/lib/appsignal/hooks/redis_spec.rb +++ b/spec/lib/appsignal/hooks/redis_spec.rb @@ -1,6 +1,5 @@ describe Appsignal::Hooks::RedisHook do let(:options) { {} } - before { start_agent(:options => options) } if DependencyHelper.redis_present? context "with redis" do @@ -8,6 +7,7 @@ context "with redis-client" do context "with instrumentation enabled" do describe "#dependencies_present?" do + before { start_agent(:options => options) } subject { described_class.new.dependencies_present? } it { is_expected.to be_falsey } @@ -17,6 +17,7 @@ else context "with instrumentation enabled" do describe "#dependencies_present?" do + before { start_agent(:options => options) } subject { described_class.new.dependencies_present? } it { is_expected.to be_truthy } @@ -27,6 +28,7 @@ context "install" do before do + start_agent(:options => options) Appsignal::Hooks.load_hooks end @@ -40,6 +42,8 @@ end context "requirements" do + before { start_agent(:options => options) } + it "driver should have the write method" do # Since we stub the client class below, to make sure that we don't # create a real connection, the test won't fail if the method definition @@ -51,7 +55,6 @@ context "instrumentation" do before do - start_agent # Stub Redis::Client class so that it doesn't perform an actual # Redis query. This class will be included (prepended) with the # AppSignal Redis integration. @@ -60,6 +63,12 @@ def id "stub_id" end + # The index of the database the connection is on, which the + # real client exposes the same way. + def db + 3 + end + def write(_commands) "stub_write" end @@ -69,33 +78,92 @@ def write(_commands) # track if it was installed already or not. Appsignal::Hooks::RedisHook.new.install end - let(:transaction) { http_request_transaction } - before { set_current_transaction(transaction) } - around { |example| keep_transactions { example.run } } - - it "instrument a redis call" do - client = Redis::Client.new - expect(client.write([:get, "key"])).to eql("stub_write") - - expect(transaction).to include_event( - "name" => "query.redis", - "body" => "get ?", - "title" => "stub_id" - ) + + describe "a redis call" do + def perform + Redis::Client.new.write([:get, "key"]) + end + + it "in agent mode", :agent_mode do + start_agent + transaction = http_request_transaction + set_current_transaction(transaction) + expect(perform).to eql("stub_write") + + expect(transaction).to include_event( + "name" => "query.redis", + "body" => "get ?", + "title" => "stub_id" + ) + end + + it "in collector mode", :collector_mode do + start_collector_agent + transaction = http_request_transaction + set_current_transaction(transaction) + expect(perform).to eql("stub_write") + Appsignal::Transaction.complete_current! + + expect(event_spans.size).to eq(1) + span = event_spans.first + expect(span.name).to eq("query.redis (stub_id)") + expect(span.kind).to eq(:client) + expect(span.parent_span_id).to eq(root_span.span_id) + expect(span.attributes["appsignal.body"]).to eq("get ?") + expect(event_category(span)).to eq("query.redis") + expect(scope_of(span)).to eq(["appsignal-ruby/redis", Appsignal::VERSION]) + expect(span.attributes["db.system.name"]).to eq("redis") + # The command name, in the case the application wrote it. + expect(span.attributes["db.operation.name"]).to eq("get") + # The index of the database the connection is on, as a String. + expect(span.attributes["db.namespace"]).to eq("3") + expect(span.attributes).not_to have_key("db.query.text") + end end - it "instrument a redis script call" do - client = Redis::Client.new - script = "return redis.call('set',KEYS[1],ARGV[1])" - keys = ["foo"] - argv = ["bar"] - expect(client.write([:eval, script, keys.size, keys, argv])).to eql("stub_write") - - expect(transaction).to include_event( - "name" => "query.redis", - "body" => "#{script} ? ?", - "title" => "stub_id" - ) + describe "a redis script call" do + let(:script) { "return redis.call('set',KEYS[1],ARGV[1])" } + + def perform + keys = ["foo"] + argv = ["bar"] + Redis::Client.new.write([:eval, script, keys.size, keys, argv]) + end + + it "in agent mode", :agent_mode do + start_agent + transaction = http_request_transaction + set_current_transaction(transaction) + expect(perform).to eql("stub_write") + + expect(transaction).to include_event( + "name" => "query.redis", + "body" => "#{script} ? ?", + "title" => "stub_id" + ) + end + + it "in collector mode", :collector_mode do + start_collector_agent + transaction = http_request_transaction + set_current_transaction(transaction) + expect(perform).to eql("stub_write") + Appsignal::Transaction.complete_current! + + expect(event_spans.size).to eq(1) + span = event_spans.first + expect(span.name).to eq("query.redis (stub_id)") + expect(span.kind).to eq(:client) + expect(span.parent_span_id).to eq(root_span.span_id) + expect(span.attributes["appsignal.body"]).to eq("#{script} ? ?") + expect(event_category(span)).to eq("query.redis") + expect(span.attributes["db.system.name"]).to eq("redis") + # A script is run with the EVAL command, so that is the + # operation. The script itself stays in the event body. + expect(span.attributes["db.operation.name"]).to eq("eval") + expect(span.attributes["db.namespace"]).to eq("3") + expect(span.attributes).not_to have_key("db.query.text") + end end end end @@ -105,6 +173,7 @@ def write(_commands) let(:options) { { :instrument_redis => false } } describe "#dependencies_present?" do + before { start_agent(:options => options) } subject { described_class.new.dependencies_present? } it { is_expected.to be_falsy } @@ -115,6 +184,7 @@ def write(_commands) else context "without redis" do describe "#dependencies_present?" do + before { start_agent(:options => options) } subject { described_class.new.dependencies_present? } it { is_expected.to be_falsy } diff --git a/spec/lib/appsignal/hooks/sequel_spec.rb b/spec/lib/appsignal/hooks/sequel_spec.rb index 9e758fbca..7e29eaf74 100644 --- a/spec/lib/appsignal/hooks/sequel_spec.rb +++ b/spec/lib/appsignal/hooks/sequel_spec.rb @@ -1,4 +1,45 @@ describe Appsignal::Hooks::SequelHook do + describe ".sequel_db_attributes" do + subject { described_class.sequel_db_attributes(database) } + + context "with a recognised engine and a named database" do + let(:database) do + double(:database_type => :postgres, :opts => { :database => "app_production" }) + end + + it "names both the engine and the database" do + expect(subject).to eq( + "db.system.name" => "postgresql", + "db.namespace" => "app_production" + ) + end + end + + context "with an in-memory database, which Sequel names with no :database option" do + let(:database) { double(:database_type => :sqlite, :opts => {}) } + + it "names the engine without a namespace" do + expect(subject).to eq("db.system.name" => "sqlite") + end + end + + context "with an engine the mapping does not recognise" do + let(:database) { double(:database_type => :db2, :opts => {}) } + + it "names neither, leaving the SQL sentinel to apply" do + expect(subject).to eq({}) + end + end + + context "with an engine that only the older semantic conventions map" do + let(:database) { double(:database_type => :oracle, :opts => {}) } + + it "names the engine with the older semantic conventions value" do + expect(subject).to eq("db.system.name" => "oracle") + end + end + end + if DependencyHelper.sequel_present? let(:db) do if DependencyHelper.running_jruby? @@ -8,30 +49,54 @@ end end - before { start_agent } - describe "#dependencies_present?" do + before { start_agent } subject { described_class.new.dependencies_present? } it { is_expected.to be_truthy } end context "with a transaction" do - let(:transaction) { http_request_transaction } - before do - set_current_transaction(transaction) - db.logger = Logger.new($stdout) # To test #log_duration call + def perform + db["SELECT 1"].all.to_a end - it "should instrument queries" do - expect(transaction).to receive(:start_event).at_least(:once) - expect(transaction).to receive(:finish_event) - .at_least(:once) - .with("sql.sequel", nil, kind_of(String), 1) + it "in agent mode", :agent_mode do + start_agent + transaction = http_request_transaction + set_current_transaction(transaction) + perform + + expect(transaction).to include_event( + "name" => "sql.sequel", + "title" => "", + "body" => "SELECT 1", + "body_format" => Appsignal::EventFormatter::SQL_BODY_FORMAT + ) + end - expect(db).to receive(:log_duration).at_least(:once) + it "in collector mode", :collector_mode do + start_collector_agent + transaction = http_request_transaction + set_current_transaction(transaction) + perform + Appsignal::Transaction.complete_current! - db["SELECT 1"].all.to_a + span = event_spans.find do |s| + s.name == "sql.sequel" && s.attributes["db.query.text"] == "SELECT 1" + end + expect(span).not_to be_nil + expect(span.kind).to eq(:client) + expect(span.parent_span_id).to eq(root_span.span_id) + # The Sequel::Database's own database_type names the engine, rather + # than the SQL sentinel every unrecognized engine falls back to. + expect(span.attributes["db.system.name"]).to eq("sqlite") + # This in-memory database has no :database connection option, so + # there is no name to put in db.namespace. + expect(span.attributes).not_to have_key("db.namespace") + expect(span.attributes).not_to have_key("appsignal.body") + expect(event_category(span)).to eq("sql.sequel") + expect(scope_of(span)).to eq(["appsignal-ruby/sequel", Appsignal::VERSION]) end end else diff --git a/spec/lib/appsignal/integrations/active_support_event_reporter_spec.rb b/spec/lib/appsignal/integrations/active_support_event_reporter_spec.rb index dfa32b8f3..03f51c590 100644 --- a/spec/lib/appsignal/integrations/active_support_event_reporter_spec.rb +++ b/spec/lib/appsignal/integrations/active_support_event_reporter_spec.rb @@ -2,24 +2,48 @@ describe Appsignal::Integrations::ActiveSupportEventReporter::Subscriber do let(:subscriber) { described_class.new } - let(:logger) { instance_double(Appsignal::Logger) } - - before do - start_agent - allow(Appsignal::Logger).to receive(:new).with("rails_events").and_return(logger) + let(:event) do + { + :name => "user.created", + :payload => { :id => 123, :email => "user@example.com" } + } end describe "#emit" do - it "logs the event name and payload" do - event = { - :name => "user.created", - :payload => { :id => 123, :email => "user@example.com" } - } + def perform + subscriber.emit(event) + end - expect(logger).to receive(:info).with("user.created", - { :id => 123, :email => "user@example.com" }) + it "in agent mode", :agent_mode do + start_agent - subscriber.emit(event) + logger = instance_double(Appsignal::Logger) + allow(Appsignal::Logger).to receive(:new).with("rails_events").and_return(logger) + + expect(logger).to receive(:info).with( + "user.created", + { :id => 123, :email => "user@example.com" } + ) + + perform + end + + it "in collector mode", :collector_mode do + start_collector_agent + + perform + + expect(log_records.size).to eq(1) + record = log_records.first + expect(record).not_to be_nil + expect(record.body).to eq("user.created") + expect(record.severity_number).to eq(9) + expect(record.severity_text).to eq("INFO") + expect(record.attributes).to include( + "id" => 123, + "email" => "user@example.com", + "appsignal.group" => "rails_events" + ) end end end diff --git a/spec/lib/appsignal/integrations/code_ownership_spec.rb b/spec/lib/appsignal/integrations/code_ownership_spec.rb index ec834c9c6..c55747760 100644 --- a/spec/lib/appsignal/integrations/code_ownership_spec.rb +++ b/spec/lib/appsignal/integrations/code_ownership_spec.rb @@ -3,8 +3,6 @@ describe Appsignal::Integrations::CodeOwnershipIntegration do before do - start_agent - Appsignal::Hooks::CodeOwnershipHook.new.install end @@ -19,7 +17,10 @@ FileUtils.rm_rf(File.join(tmp_dir, "config")) end + # These examples exercise the error-handling path and assert on + # internal_logger output, which is not OTel-routed. No collector coverage. it "handles missing config file" do + start_agent create_app_files transaction = create_transaction @@ -39,6 +40,7 @@ end it "handles missing team config files" do + start_agent create_app_files create_config_file transaction = create_transaction @@ -75,10 +77,10 @@ FileUtils.rm_rf(File.join(tmp_dir, "config")) end - it "sets an owner tag of the transaction based on file-annotation" do - transaction = create_transaction + describe "sets an owner tag of the transaction based on file-annotation" do + let(:transaction) { create_transaction } - begin + def perform load File.join(tmp_dir, "app", "file_annotation_based.rb") rescue => error transaction.add_error(error) @@ -86,13 +88,31 @@ transaction.complete end - expect(transaction).to include_tags("owner" => "FileTeam") + it "in agent mode", :agent_mode do + start_agent + perform + transaction._sample + + expect(transaction).to include_tags("owner" => "FileTeam") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + + # The owner lookup is driven by the recorded error; assert the + # exception event that produced it is present. + event = root_span.events.find { |e| e.name == "exception" } + expect(event).not_to be_nil + expect(event.attributes["exception.type"]).to eq("RuntimeError") + expect(root_span.attributes["appsignal.tag.owner"]).to eq("FileTeam") + end end - it "sets an owner tag of the transaction based on directory ownership" do - transaction = create_transaction + describe "sets an owner tag of the transaction based on directory ownership" do + let(:transaction) { create_transaction } - begin + def perform load File.join(tmp_dir, "app", "dir", "directory_based.rb") rescue => error transaction.add_error(error) @@ -100,13 +120,31 @@ transaction.complete end - expect(transaction).to include_tags("owner" => "DirectoryTeam") + it "in agent mode", :agent_mode do + start_agent + perform + transaction._sample + + expect(transaction).to include_tags("owner" => "DirectoryTeam") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + + # The owner lookup is driven by the recorded error; assert the + # exception event that produced it is present. + event = root_span.events.find { |e| e.name == "exception" } + expect(event).not_to be_nil + expect(event.attributes["exception.type"]).to eq("RuntimeError") + expect(root_span.attributes["appsignal.tag.owner"]).to eq("DirectoryTeam") + end end - it "sets owner tag of the transaction based on `owned_globs` in team.yml file" do - transaction = create_transaction + describe "sets owner tag of the transaction based on `owned_globs` in team.yml file" do + let(:transaction) { create_transaction } - begin + def perform load File.join(tmp_dir, "app", "glob", "glob_based.rb") rescue => error transaction.add_error(error) @@ -114,10 +152,31 @@ transaction.complete end - expect(transaction).to include_tags("owner" => "GlobTeam") + it "in agent mode", :agent_mode do + start_agent + perform + transaction._sample + + expect(transaction).to include_tags("owner" => "GlobTeam") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + + # The owner lookup is driven by the recorded error; assert the + # exception event that produced it is present. + event = root_span.events.find { |e| e.name == "exception" } + expect(event).not_to be_nil + expect(event.attributes["exception.type"]).to eq("RuntimeError") + expect(root_span.attributes["appsignal.tag.owner"]).to eq("GlobTeam") + end end + # These examples assert on both tag absence and internal_logger output + # (no log emitted). No collector coverage for logging behavior. it "handles files without owners" do + start_agent transaction = create_transaction logs = capture_logs do @@ -133,6 +192,7 @@ end it "handles transactions without errors" do + start_agent transaction = create_transaction logs = capture_logs do diff --git a/spec/lib/appsignal/integrations/data_mapper_spec.rb b/spec/lib/appsignal/integrations/data_mapper_spec.rb index 0b0fd58e6..e585ed7f4 100644 --- a/spec/lib/appsignal/integrations/data_mapper_spec.rb +++ b/spec/lib/appsignal/integrations/data_mapper_spec.rb @@ -2,7 +2,6 @@ describe Appsignal::Hooks::DataMapperLogListener do describe "#log" do - let(:transaction) { http_request_transaction } let(:message) do double( :query => "SELECT * from users", @@ -15,16 +14,13 @@ def log(message) end end) stub_const("DataObjects", Module.new) - start_agent - set_current_transaction(transaction) end - around { |example| keep_transactions { example.run } } def log_message connection_class.new.log(message) end - context "when the scheme is SQL-like" do + describe "a SQL-like scheme" do let(:connection_class) { DataObjects::Sqlite3::Connection } before do stub_const("DataObjects::Sqlite3::Connection", Class.new do @@ -33,9 +29,16 @@ def log_message end) end - it "records the log entry in an event" do + def perform + transaction = http_request_transaction + set_current_transaction(transaction) log_message + transaction + end + it "in agent mode", :agent_mode do + start_agent + transaction = perform expect(transaction).to include_event( "name" => "query.data_mapper", "title" => "DataMapper Query", @@ -44,9 +47,31 @@ def log_message "duration" => 100.0 ) end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + Appsignal::Transaction.complete_current! + + expect(event_spans.size).to eq(1) + span = event_spans.first + expect(span.name).to eq("query.data_mapper (DataMapper Query)") + expect(span.kind).to eq(:client) + expect(span.parent_span_id).to eq(root_span.span_id) + attrs = span.attributes + expect(attrs["db.query.text"]).to eq("SELECT * from users") + # The connection class names the engine, rather than the SQL sentinel + # every unrecognized SQL_CLASSES entry would fall back to. + expect(attrs["db.system.name"]).to eq("sqlite") + expect(event_category(span)).to eq("query.data_mapper") + expect(scope_of(span)).to eq(["appsignal-ruby/data_mapper", Appsignal::VERSION]) + expect(attrs).not_to have_key("appsignal.body") + observed = span.end_timestamp - span.start_timestamp + expect(observed).to be_within(50_000_000).of(100_000_000) + end end - context "when the scheme is not SQL-like" do + describe "a non-SQL scheme" do let(:connection_class) { DataObjects::MongoDB::Connection } before do stub_const("DataObjects::MongoDB::Connection", Class.new do @@ -55,9 +80,16 @@ def log_message end) end - it "records the log entry in an event without body" do + def perform + transaction = http_request_transaction + set_current_transaction(transaction) log_message + transaction + end + it "in agent mode", :agent_mode do + start_agent + transaction = perform expect(transaction).to include_event( "name" => "query.data_mapper", "title" => "DataMapper Query", @@ -66,6 +98,26 @@ def log_message "duration" => 100.0 ) end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + Appsignal::Transaction.complete_current! + + expect(event_spans.size).to eq(1) + span = event_spans.first + expect(span.name).to eq("query.data_mapper (DataMapper Query)") + expect(span.kind).to eq(:client) + expect(span.parent_span_id).to eq(root_span.span_id) + attrs = span.attributes + expect(event_category(span)).to eq("query.data_mapper") + expect(scope_of(span)).to eq(["appsignal-ruby/data_mapper", Appsignal::VERSION]) + expect(attrs).not_to have_key("appsignal.body") + expect(attrs).not_to have_key("db.query.text") + expect(attrs).not_to have_key("db.system.name") + observed = span.end_timestamp - span.start_timestamp + expect(observed).to be_within(50_000_000).of(100_000_000) + end end end end diff --git a/spec/lib/appsignal/integrations/delayed_job_plugin_spec.rb b/spec/lib/appsignal/integrations/delayed_job_plugin_spec.rb index 57bc2bc28..b7e482a5c 100644 --- a/spec/lib/appsignal/integrations/delayed_job_plugin_spec.rb +++ b/spec/lib/appsignal/integrations/delayed_job_plugin_spec.rb @@ -35,50 +35,89 @@ def perform_job(job) describe "enqueueing a job" do context "with an active transaction" do - it "records an enqueue event titled after the job" do + it "records an enqueue event titled after the job", :agent_mode do start_agent transaction = http_request_transaction set_current_transaction(transaction) - Delayed::Job.enqueue(DelayedTestJob.new) + Delayed::Job.enqueue(DelayedTestJob.new, :queue => "dj-queue") event = transaction.to_h["events"].find { |e| e["name"] == "enqueue.delayed_job" } expect(event).to_not be_nil expect(event["title"]).to eq("enqueue DelayedTestJob job") end + + it "records the enqueue as a producer span", :collector_mode do + start_collector_agent + transaction = http_request_transaction + set_current_transaction(transaction) + + Delayed::Job.enqueue(DelayedTestJob.new, :queue => "dj-queue") + Appsignal::Transaction.complete_current! + + # Delayed Job has no envelope to carry trace context, so -- like + # OpenTelemetry's own instrumentation -- nothing is injected; the + # producer span is not linked to the later perform. + producer = event_span_for("enqueue.delayed_job") + expect(producer.name).to eq("enqueue.delayed_job (enqueue DelayedTestJob job)") + expect(scope_of(producer)).to eq(["appsignal-ruby/delayed_job", Appsignal::VERSION]) + expect(producer.kind).to eq(:producer) + expect(producer.attributes["messaging.system"]).to eq("delayed_job") + expect(producer.attributes["messaging.operation.name"]).to eq("enqueue") + expect(producer.attributes["messaging.operation.type"]).to eq("send") + expect(producer.attributes["messaging.destination.name"]).to eq("dj-queue") + expect(producer.parent_span_id).to eq(root_span.span_id) + end end - context "with a custom appsignal_name" do - before do - stub_const("DelayedNamedJob", Class.new do - def perform - end + context "when enqueue instrumentation is disabled" do + let(:start_agent_args) do + { :options => { :enable_job_enqueue_instrumentation => false } } + end - def appsignal_name - "CustomName#perform" - end - end) + it "records no enqueue event but still enqueues the job", :agent_mode do + start_agent(**start_agent_args) + transaction = http_request_transaction + set_current_transaction(transaction) + + expect { Delayed::Job.enqueue(DelayedTestJob.new) } + .to change { Delayed::Backend::Test::Job.count }.by(1) + + event_names = transaction.to_h["events"].map { |event| event["name"] } + expect(event_names).to_not include("enqueue.delayed_job") end - it "titles the enqueue event with the custom name" do - start_agent + it "emits no enqueue span but still enqueues the job", :collector_mode do + start_collector_agent transaction = http_request_transaction set_current_transaction(transaction) - Delayed::Job.enqueue(DelayedNamedJob.new) + expect { Delayed::Job.enqueue(DelayedTestJob.new) } + .to change { Delayed::Backend::Test::Job.count }.by(1) + Appsignal::Transaction.complete_current! - event = transaction.to_h["events"].find { |e| e["name"] == "enqueue.delayed_job" } - expect(event["title"]).to eq("enqueue CustomName#perform job") + expect(event_spans_for("enqueue.delayed_job")).to be_empty end end context "without an active transaction" do - it "is a transparent pass-through" do + it "is a transparent pass-through", :agent_mode do start_agent expect { Delayed::Job.enqueue(DelayedTestJob.new) } .to change { Delayed::Backend::Test::Job.count }.by(1) end + + it "emits no enqueue span", :collector_mode do + start_collector_agent + + Delayed::Job.enqueue(DelayedTestJob.new) + + # Event span names lead with the event category, so derive the + # categories from the event spans and check none is the enqueue event. + categories = event_spans.map { |s| event_category(s) } + expect(categories).to_not include("enqueue.delayed_job") + end end if DependencyHelper.active_job_present? @@ -96,7 +135,7 @@ def perform(*) end) end - it "does not record a second enqueue event" do + it "does not record a second enqueue event", :agent_mode do start_agent transaction = http_request_transaction set_current_transaction(transaction) @@ -109,13 +148,37 @@ def perform(*) end end end + + context "with a custom appsignal_name" do + before do + stub_const("DelayedNamedJob", Class.new do + def perform + end + + def appsignal_name + "CustomName#perform" + end + end) + end + + it "titles the enqueue event with the custom name", :agent_mode do + start_agent + transaction = http_request_transaction + set_current_transaction(transaction) + + Delayed::Job.enqueue(DelayedNamedJob.new) + + event = transaction.to_h["events"].find { |e| e["name"] == "enqueue.delayed_job" } + expect(event["title"]).to eq("enqueue CustomName#perform job") + end + end end describe "performing a job" do context "with a normal job" do - it "wraps it in a background_job transaction" do + it "wraps it in a background_job transaction", :agent_mode do start_agent - job = Delayed::Job.enqueue(DelayedTestJob.new) + job = Delayed::Job.enqueue(DelayedTestJob.new, :queue => "dj-queue") keep_transactions { perform_job(job) } @@ -126,6 +189,30 @@ def perform(*) expect(transaction).to include_event(:name => "perform_job.delayed_job") expect(transaction).to include_tags("attempts" => 0, "priority" => 0) end + + it "wraps it in a consumer span", :collector_mode do + start_collector_agent + job = Delayed::Job.enqueue(DelayedTestJob.new, :queue => "dj-queue") + + perform_job(job) + Appsignal::Transaction.complete_current! + + expect(root_span.kind).to eq(:consumer) + expect(root_span.attributes["messaging.system"]).to eq("delayed_job") + expect(root_span.attributes["messaging.operation.name"]).to eq("perform") + expect(root_span.attributes["messaging.operation.type"]).to eq("process") + expect(root_span.attributes["messaging.destination.name"]).to eq("dj-queue") + expect(root_span.attributes["appsignal.action_name"]).to eq("DelayedTestJob#perform") + expect(root_span.attributes["appsignal.namespace"]).to eq("background") + expect(event_spans.map(&:name)).to include("perform_job.delayed_job") + perform_span = event_spans.find { |s| s.name == "perform_job.delayed_job" } + expect(perform_span.attributes["messaging.system"]).to eq("delayed_job") + expect(perform_span.attributes["messaging.operation.name"]).to eq("perform") + expect(perform_span.attributes["messaging.operation.type"]).to eq("process") + expect(perform_span.attributes["messaging.destination.name"]).to eq("dj-queue") + expect(scope_of(root_span)).to eq(["appsignal-ruby/delayed_job", Appsignal::VERSION]) + expect(scope_of(perform_span)).to eq(["appsignal-ruby/delayed_job", Appsignal::VERSION]) + end end context "with a job that raises" do @@ -137,7 +224,7 @@ def perform end) end - it "records the error on the transaction" do + it "records the error on the transaction", :agent_mode do start_agent job = Delayed::Job.enqueue(DelayedErrorJob.new) @@ -150,6 +237,19 @@ def perform expect(transaction).to have_action("DelayedErrorJob#perform") expect(transaction).to have_error("ExampleException", "uh oh") end + + it "records the error on the consumer span", :collector_mode do + start_collector_agent + job = Delayed::Job.enqueue(DelayedErrorJob.new) + + expect { perform_job(job) }.to raise_error(ExampleException, "uh oh") + Appsignal::Transaction.complete_current! + + expect(root_span.kind).to eq(:consumer) + event = root_span.events.find { |e| e.name == "exception" } + expect(event.attributes["exception.type"]).to eq("ExampleException") + expect(event.attributes["exception.message"]).to eq("uh oh") + end end context "with a custom appsignal_name" do @@ -164,7 +264,7 @@ def appsignal_name end) end - it "uses the custom name as the action" do + it "uses the custom name as the action", :agent_mode do start_agent job = Delayed::Job.enqueue(DelayedNamedJob.new) @@ -187,7 +287,7 @@ def perform(*) end) end - it "uses the Active Job class as the action" do + it "uses the Active Job class as the action", :agent_mode do start_agent keep_transactions do diff --git a/spec/lib/appsignal/integrations/excon_spec.rb b/spec/lib/appsignal/integrations/excon_spec.rb index 23e4acf5f..aa2803824 100644 --- a/spec/lib/appsignal/integrations/excon_spec.rb +++ b/spec/lib/appsignal/integrations/excon_spec.rb @@ -9,10 +9,6 @@ before { Appsignal::Hooks::ExconHook.new.install } let(:transaction) { http_request_transaction } - before do - start_agent - set_current_transaction(transaction) - end def event_names transaction.to_h["events"].map { |event| event["name"] } @@ -22,13 +18,44 @@ def event_duration(name) transaction.to_h["events"].find { |event| event["name"] == name }["duration"] end + # The single span an Excon request records. + def excon_span + event_span_for("request.excon") + end + + # How long a span lasted, in milliseconds. Spans carry nanoseconds. + def span_duration(span) + (span.end_timestamp - span.start_timestamp) / 1_000_000.0 + end + + # Reads the `traceparent` header off the last recorded outgoing request to + # `url`. Returns nil when nothing wrote one. + def injected_traceparent(url) + traceparent = nil + # The block is a predicate, so it has to match whatever it reads. It is + # only here to get at the headers of the requests that were made. + matcher = a_request(:get, url).with do |request| + traceparent = request.headers["Traceparent"] + true + end + expect(matcher).to have_been_made.at_least_once + traceparent + end + + # The W3C traceparent that names `span` as the parent, sampled. + def traceparent_for(span) + "00-#{span.hex_trace_id}-#{span.hex_span_id}-01" + end + describe "a request that succeeds" do def perform stub_request(:get, "http://www.example.com/") Excon.get("http://www.example.com/") end - it "records the request as one event" do + it "in agent mode", :agent_mode do + start_agent + set_current_transaction(transaction) perform expect(event_names).to eq(["request.excon"]) @@ -37,13 +64,48 @@ def perform "title" => "GET http://www.example.com", "body" => "" ) + # Trace context is only written in collector mode. + expect(injected_traceparent("http://www.example.com/")).to be_nil end - it "returns the response to the caller" do - expect(perform.status).to eq(200) + it "in collector mode", :collector_mode do + start_collector_agent + set_current_transaction(transaction) + perform + Appsignal::Transaction.complete_current! + + expect(event_spans.size).to eq(1) + span = excon_span + expect(span.name).to eq("request.excon (GET http://www.example.com)") + expect(span.kind).to eq(:client) + expect(span.parent_span_id).to eq(root_span.span_id) + expect(scope_of(span)).to eq(["appsignal-ruby/excon", Appsignal::VERSION]) + + # The request and the response are described on the same span. Excon's + # own instrumentor could only report the status on a span of its own, + # which carried nothing else. + expect(span.attributes["http.request.method"]).to eq("GET") + # Excon hands us the method as a lowercase Symbol, so the canonical form + # is recorded and the original kept alongside it. + expect(span.attributes["http.request.method_original"]).to eq("get") + expect(span.attributes["server.address"]).to eq("www.example.com") + expect(span.attributes["server.port"]).to eq(80) + expect(span.attributes["url.full"]).to eq("http://www.example.com/") + expect(span.attributes["http.response.status_code"]).to eq(200) + + # The called service joins this trace, as a child of the client span. + expect(injected_traceparent("http://www.example.com/")) + .to eq(traceparent_for(span)) end end + it_in_both_modes "returns the response to the caller" do + set_current_transaction(transaction) + stub_request(:get, "http://www.example.com/") + + expect(Excon.get("http://www.example.com/").status).to eq(200) + end + # Excon runs its middleware stack twice for a request: once on the way out to # send it, and again on the way back to read the response. Instrumenting the # connection puts both passes inside the event, so the wait for the remote @@ -68,11 +130,22 @@ def perform ) end - it "is recorded on the event" do + it "in agent mode", :agent_mode do + start_agent + set_current_transaction(transaction) perform expect(event_duration("request.excon")).to be >= 100 end + + it "in collector mode", :collector_mode do + start_collector_agent + set_current_transaction(transaction) + perform + Appsignal::Transaction.complete_current! + + expect(span_duration(excon_span)).to be >= 100 + end end describe "a request that fails" do @@ -82,11 +155,31 @@ def perform .to raise_error(Excon::Error::Timeout) end - it "records the request as one event, and lets the error through" do + it "in agent mode", :agent_mode do + start_agent + set_current_transaction(transaction) perform expect(event_names).to eq(["request.excon"]) end + + it "in collector mode", :collector_mode do + start_collector_agent + set_current_transaction(transaction) + perform + Appsignal::Transaction.complete_current! + + expect(event_spans.size).to eq(1) + span = excon_span + expect(span.kind).to eq(:client) + # Says what kind of failure ended the request, which the conventions ask + # for on a span whose operation failed. Recorded because the whole + # request now runs inside one instrumented block, which sets this when + # the block raises. + expect(span.attributes["error.type"]).to eq("Excon::Error::Timeout") + # No response was received, so there is no status to describe. + expect(span.attributes).to_not have_key("http.response.status_code") + end end # An outer HTTP client integration, such as Faraday, records a request @@ -99,11 +192,23 @@ def perform end end - it "records no event, and still returns the response" do - expect(perform.status).to eq(200) + it "in agent mode", :agent_mode do + start_agent + set_current_transaction(transaction) + expect(perform.status).to eq(200) expect(event_names).to be_empty end + + it "in collector mode", :collector_mode do + start_collector_agent + set_current_transaction(transaction) + + expect(perform.status).to eq(200) + Appsignal::Transaction.complete_current! + + expect(event_spans).to be_empty + end end # Excon retries a request when it is marked idempotent and fails with a @@ -122,11 +227,26 @@ def perform end.to raise_error(Excon::Error::Timeout) end - it "records every attempt as one event" do + it "in agent mode", :agent_mode do + start_agent + set_current_transaction(transaction) perform expect(event_names).to eq(["request.excon"]) end + + it "in collector mode", :collector_mode do + start_collector_agent + set_current_transaction(transaction) + perform + Appsignal::Transaction.complete_current! + + expect(event_spans.size).to eq(1) + # Every attempt shares the one span, so every attempt carries the same + # trace context. + expect(injected_traceparent("http://www.example.com/")) + .to eq(traceparent_for(excon_span)) + end end # Excon follows a redirect the same way, by making the request again from @@ -146,17 +266,67 @@ def perform ) end - it "records every hop as one event" do + # The event is named after the request that was made, not the location it + # ended up at. + it "in agent mode", :agent_mode do + start_agent + set_current_transaction(transaction) perform expect(event_names).to eq(["request.excon"]) - # The event is titled after the request that was made, not the location - # it ended up at. expect(transaction).to include_event( "name" => "request.excon", "title" => "GET http://www.example.com" ) end + + it "in collector mode", :collector_mode do + start_collector_agent + set_current_transaction(transaction) + perform + Appsignal::Transaction.complete_current! + + expect(event_spans.size).to eq(1) + span = excon_span + expect(span.name).to eq("request.excon (GET http://www.example.com)") + # The span describes the request that was made and the response that + # came back, so the URL is the one asked for and the status is the one + # the last hop answered with. + expect(span.attributes["url.full"]).to eq("http://www.example.com/") + expect(span.attributes["http.response.status_code"]).to eq(200) + end + end + + # A pipelined request is the one case where the event does not cover a whole + # request. Excon returns from the call once the request has been sent, and + # the response is read later, so there is no response to describe. It hands + # back the request data rather than a response. + describe "a pipelined request" do + def perform + stub_request(:get, "http://www.example.com/") + Excon.new("http://www.example.com/") + .request(:method => :get, :pipeline => true) + end + + it "in agent mode", :agent_mode do + start_agent + set_current_transaction(transaction) + + expect(perform).to be_kind_of(Hash) + expect(event_names).to eq(["request.excon"]) + end + + it "in collector mode", :collector_mode do + start_collector_agent + set_current_transaction(transaction) + perform + Appsignal::Transaction.complete_current! + + expect(event_spans.size).to eq(1) + span = excon_span + expect(span.attributes["url.full"]).to eq("http://www.example.com/") + expect(span.attributes).to_not have_key("http.response.status_code") + end end end end diff --git a/spec/lib/appsignal/integrations/faraday_spec.rb b/spec/lib/appsignal/integrations/faraday_spec.rb index f0b922fcf..83318695e 100644 --- a/spec/lib/appsignal/integrations/faraday_spec.rb +++ b/spec/lib/appsignal/integrations/faraday_spec.rb @@ -1,25 +1,70 @@ if DependencyHelper.faraday_present? require "faraday" require "appsignal/integrations/faraday" - require "faraday/excon" if DependencyHelper.excon_present? # Integration test against the real Faraday gem. The hook auto-installs # AppSignal's middleware onto every connection, so the `request.faraday` event - # is recorded without the user adding anything themselves -- and without a - # dependency on ActiveSupport (these gemfiles no longer load it). + # is recorded and outgoing requests carry trace context, without the user + # adding anything -- and without a dependency on ActiveSupport (these gemfiles + # no longer load it). describe "Faraday integration" do before { Appsignal::Hooks::FaradayHook.new.install } + describe "claiming the request.faraday event" do + # The event formatter registry is shared by the whole test suite, so put + # back whatever this example changes. Reloading the hook file below also + # re-registers it in the hooks registry, so put that back too. + around do |example| + formatters = Appsignal::EventFormatter.formatters.dup + formatter_classes = Appsignal::EventFormatter.formatter_classes.dup + hooks = Appsignal::Hooks.hooks.dup + example.run + ensure + Appsignal::EventFormatter.formatters.replace(formatters) + Appsignal::EventFormatter.formatter_classes.replace(formatter_classes) + Appsignal::Hooks.hooks.replace(hooks) + end + + # This integration records the request itself, so Faraday's own + # notification must not record it a second time. The hook file claims + # the event as soon as it is required, rather than when `install` + # runs, so the claim holds even when Faraday instrumentation ends up + # disabled and `install` never runs. A customer who turns the + # instrumentation off should not see the native notification reported + # instead. + # + # The outer `before` above already installed the hook once, and the + # hook file has already been required, so `require` will not run its + # body again. Force the event back to unclaimed, then load the file + # with `load` instead of `require` so its body runs again. That + # proves the claim comes from loading the file, not from `install`, + # which this example does not call again. + it "stays claimed when Faraday instrumentation is disabled" do + configure(:options => { :instrument_faraday => false }) + expect(Appsignal::Hooks::FaradayHook.new.dependencies_present?).to be(false) + + Appsignal::EventFormatter.unregister( + "request.faraday", + Appsignal::EventFormatter::RecordedElsewhere + ) + expect(Appsignal::EventFormatter.record?("request.faraday")).to be(true) + + load "appsignal/hooks/faraday.rb" + + expect(Appsignal::EventFormatter.record?("request.faraday")).to be(false) + end + end + # The common case: the default adapter is Net::HTTP, which AppSignal also # instruments. Faraday suppresses it, so the request is recorded once -- as - # the `request.faraday` event. + # the `request.faraday` event, which also writes the `traceparent`. describe "a request over the default Net::HTTP adapter" do def perform stub_request(:get, "http://www.example.com/") Faraday.new("http://www.example.com").get("/") end - it "records the request once, as the Faraday event" do + it "in agent mode", :agent_mode do start_agent transaction = http_request_transaction set_current_transaction(transaction) @@ -34,11 +79,41 @@ def perform # Net::HTTP is suppressed under Faraday, so it isn't recorded again. expect(transaction).to_not include_event("name" => "request.net_http") end + + it "in collector mode", :collector_mode do + start_collector_agent + transaction = http_request_transaction + set_current_transaction(transaction) + perform + Appsignal::Transaction.complete_current! + + faraday_span = event_span("request.faraday") + expect(faraday_span).not_to be_nil + expect(faraday_span.kind).to eq(:client) + expect(faraday_span.attributes["http.request.method"]).to eq("GET") + # The client hands us the method as a lowercase Symbol, so the + # canonical form is recorded and the original kept alongside it. + expect(faraday_span.attributes["http.request.method_original"]).to eq("get") + expect(faraday_span.attributes["server.address"]).to eq("www.example.com") + expect(faraday_span.attributes["server.port"]).to eq(80) + expect(faraday_span.attributes["url.full"]).to eq("http://www.example.com/") + expect(faraday_span.attributes["http.response.status_code"]).to eq(200) + expect(faraday_span.parent_span_id).to eq(root_span.span_id) + expect(scope_of(faraday_span)).to eq(["appsignal-ruby/faraday", Appsignal::VERSION]) + + # Net::HTTP is suppressed, so there's no nested net_http span. + expect(event_span("request.net_http")).to be_nil + + # Faraday writes the wire traceparent (Net::HTTP doesn't run its inject). + expect(injected_traceparent("http://www.example.com/")) + .to eq("00-#{faraday_span.hex_trace_id}-#{faraday_span.hex_span_id}-01") + end end - # Excon is also a Faraday adapter, and AppSignal instruments it through - # Excon's instrumentor. Faraday suppresses it too, so the request is recorded - # once -- as the `request.faraday` event. + # Excon is the other adapter AppSignal instruments itself, so it is the other + # adapter that has to be suppressed. Net::HTTP above suppresses through its + # own integration; Excon suppresses through a different one, so both are + # worth a test. describe "a request over the Excon adapter", :if => DependencyHelper.excon_present? do before { Appsignal::Hooks::ExconHook.new.install } @@ -50,7 +125,7 @@ def perform connection.get("/") end - it "records the request once, as the Faraday event" do + it "in agent mode", :agent_mode do start_agent transaction = http_request_transaction set_current_transaction(transaction) @@ -62,11 +137,71 @@ def perform "body" => "" ) # Excon is suppressed under Faraday, so it isn't recorded again. - # Asserted on the whole list of event names, so that an Excon event with - # any title at all fails this. - expect(transaction.to_h["events"].map { |event| event["name"] }) - .to eq(["request.faraday"]) + expect(transaction).to_not include_event("name" => "request.excon") end + + it "in collector mode", :collector_mode do + start_collector_agent + transaction = http_request_transaction + set_current_transaction(transaction) + perform + Appsignal::Transaction.complete_current! + + # Excon is suppressed, so there's no nested excon span. + expect(event_span("request.excon")).to be_nil + + # Faraday writes the wire traceparent. Excon's own inject middleware + # still runs, but with no Excon event span of its own it can only write + # the same client span Faraday already wrote. + faraday_span = event_span("request.faraday") + expect(faraday_span).not_to be_nil + expect(injected_traceparent("http://www.example.com/")) + .to eq("00-#{faraday_span.hex_trace_id}-#{faraday_span.hex_span_id}-01") + end + end + + # With an adapter AppSignal does not instrument at all (here Faraday's test + # adapter), our inject middleware is the only thing writing context, so the + # request carries the `request.faraday` client span's traceparent -- proving + # the middleware runs and injects inside that event's span. This is the path + # that gives Faraday propagation for adapters AppSignal doesn't instrument + # directly. + it "injects the Faraday client context on a non-Net::HTTP adapter", :collector_mode do + start_collector_agent + transaction = http_request_transaction + set_current_transaction(transaction) + + captured_env = nil + connection = Faraday.new("http://www.example.com") do |faraday| + faraday.adapter :test do |stub| + stub.get("/") do |env| + captured_env = env + [200, {}, ""] + end + end + end + connection.get("/") + Appsignal::Transaction.complete_current! + + faraday_span = event_span("request.faraday") + expect(faraday_span).not_to be_nil + expect(captured_env.request_headers["traceparent"]) + .to eq("00-#{faraday_span.hex_trace_id}-#{faraday_span.hex_span_id}-01") + end + + # Finds the recorded event span for a category (AS::N name), which now leads + # the event span's name. + def event_span(category) + event_span_for(category) + end + + # Reads the `traceparent` header off the recorded outgoing request to `url`. + def injected_traceparent(url) + traceparent = nil + expect( + a_request(:get, url).with { |request| traceparent = request.headers["Traceparent"] } + ).to have_been_made + traceparent end end end diff --git a/spec/lib/appsignal/integrations/http_spec.rb b/spec/lib/appsignal/integrations/http_spec.rb index 1b01e4797..4b34a2126 100644 --- a/spec/lib/appsignal/integrations/http_spec.rb +++ b/spec/lib/appsignal/integrations/http_spec.rb @@ -6,78 +6,201 @@ describe Appsignal::Integrations::HttpIntegration do let(:transaction) { http_request_transaction } - around do |example| - keep_transactions { example.run } - end - before do - start_agent - set_current_transaction(transaction) - end - - it "instruments a HTTP request" do - stub_request(:get, "http://www.google.com") - - HTTP.get("http://www.google.com") + describe "instrumenting a HTTP request" do + def perform + stub_request(:get, "http://www.google.com") - expect(transaction).to have_namespace(Appsignal::Transaction::HTTP_REQUEST) - expect(transaction).to include_event( - "body" => "", - "body_format" => Appsignal::EventFormatter::DEFAULT, - "name" => "request.http_rb", - "title" => "GET http://www.google.com" - ) - end + HTTP.get("http://www.google.com") + end - it "instruments a HTTPS request" do - stub_request(:get, "https://www.google.com") + it "in agent mode", :agent_mode do + start_agent + set_current_transaction(transaction) + perform - HTTP.get("https://www.google.com") + expect(transaction).to have_namespace(Appsignal::Transaction::HTTP_REQUEST) + expect(transaction).to include_event( + "body" => "", + "body_format" => Appsignal::EventFormatter::DEFAULT, + "name" => "request.http_rb", + "title" => "GET http://www.google.com" + ) + end - expect(transaction).to have_namespace(Appsignal::Transaction::HTTP_REQUEST) - expect(transaction).to include_event( - "body" => "", - "body_format" => Appsignal::EventFormatter::DEFAULT, - "name" => "request.http_rb", - "title" => "GET https://www.google.com" - ) + it "in collector mode", :collector_mode do + start_collector_agent + set_current_transaction(transaction) + perform + Appsignal::Transaction.complete_current! + + expect(root_span.attributes["appsignal.namespace"]) + .to eq("web") + expect(event_spans.size).to eq(1) + span = event_spans.first + expect(span.name).to eq("request.http_rb (GET http://www.google.com)") + expect(span.kind).to eq(:client) + expect(span.attributes["http.request.method"]).to eq("GET") + # The client hands us the method as a lowercase Symbol, so the + # canonical form is recorded and the original kept alongside it. + expect(span.attributes["http.request.method_original"]).to eq("get") + expect(span.attributes["server.address"]).to eq("www.google.com") + expect(span.attributes["server.port"]).to eq(80) + expect(span.attributes["url.full"]).to eq("http://www.google.com/") + expect(span.attributes["http.response.status_code"]).to eq(200) + expect(span.parent_span_id).to eq(root_span.span_id) + expect(event_category(span)).to eq("request.http_rb") + expect(scope_of(span)).to eq(["appsignal-ruby/http_rb", Appsignal::VERSION]) + expect(span.attributes).not_to have_key("appsignal.body") + + # The outgoing request carries a W3C traceparent for the client span, so + # the called service joins this trace. + expect(injected_traceparent("http://www.google.com/")) + .to eq("00-#{span.hex_trace_id}-#{span.hex_span_id}-01") + end end - context "with request parameters" do - it "does not include the query parameters in the title" do - stub_request(:get, "https://www.google.com?q=Appsignal") + describe "instrumenting a HTTPS request" do + def perform + stub_request(:get, "https://www.google.com") + + HTTP.get("https://www.google.com") + end - HTTP.get("https://www.google.com", :params => { :q => "Appsignal" }) + it "in agent mode", :agent_mode do + start_agent + set_current_transaction(transaction) + perform + expect(transaction).to have_namespace(Appsignal::Transaction::HTTP_REQUEST) expect(transaction).to include_event( "body" => "", + "body_format" => Appsignal::EventFormatter::DEFAULT, + "name" => "request.http_rb", "title" => "GET https://www.google.com" ) end - it "does not include the request body in the title" do - stub_request(:post, "https://www.google.com") - .with(:body => { :q => "Appsignal" }.to_json) + it "in collector mode", :collector_mode do + start_collector_agent + set_current_transaction(transaction) + perform + Appsignal::Transaction.complete_current! + + expect(root_span.attributes["appsignal.namespace"]) + .to eq("web") + expect(event_spans.size).to eq(1) + span = event_spans.first + expect(span.name).to eq("request.http_rb (GET https://www.google.com)") + expect(span.kind).to eq(:client) + expect(span.attributes["http.request.method"]).to eq("GET") + # The client hands us the method as a lowercase Symbol, so the + # canonical form is recorded and the original kept alongside it. + expect(span.attributes["http.request.method_original"]).to eq("get") + expect(span.attributes["server.address"]).to eq("www.google.com") + expect(span.attributes["server.port"]).to eq(443) + expect(span.attributes["url.full"]).to eq("https://www.google.com/") + expect(span.attributes["http.response.status_code"]).to eq(200) + expect(span.parent_span_id).to eq(root_span.span_id) + expect(event_category(span)).to eq("request.http_rb") + expect(scope_of(span)).to eq(["appsignal-ruby/http_rb", Appsignal::VERSION]) + expect(span.attributes).not_to have_key("appsignal.body") + + expect(injected_traceparent("https://www.google.com/")) + .to eq("00-#{span.hex_trace_id}-#{span.hex_span_id}-01") + end + end - HTTP.post("https://www.google.com", :json => { :q => "Appsignal" }) + context "with request parameters" do + describe "not including the query parameters in the title" do + def perform + stub_request(:get, "https://www.google.com?q=Appsignal") - expect(transaction).to include_event( - "body" => "", - "title" => "POST https://www.google.com" - ) + HTTP.get("https://www.google.com", :params => { :q => "Appsignal" }) + end + + it "in agent mode", :agent_mode do + start_agent + set_current_transaction(transaction) + perform + + expect(transaction).to include_event( + "body" => "", + "title" => "GET https://www.google.com" + ) + end + + it "in collector mode", :collector_mode do + start_collector_agent + set_current_transaction(transaction) + perform + Appsignal::Transaction.complete_current! + + expect(event_spans.size).to eq(1) + span = event_spans.first + expect(span.name).to eq("request.http_rb (GET https://www.google.com)") + expect(event_category(span)).to eq("request.http_rb") + expect(scope_of(span)).to eq(["appsignal-ruby/http_rb", Appsignal::VERSION]) + expect(span.attributes).not_to have_key("appsignal.body") + # The query parameters are the client's to add, and they stay out of + # the URL too. + expect(span.attributes["url.full"]).to eq("https://www.google.com/") + expect(span.attributes["http.response.status_code"]).to eq(200) + end + end + + describe "not including the request body in the title" do + def perform + stub_request(:post, "https://www.google.com") + .with(:body => { :q => "Appsignal" }.to_json) + + HTTP.post("https://www.google.com", :json => { :q => "Appsignal" }) + end + + it "in agent mode", :agent_mode do + start_agent + set_current_transaction(transaction) + perform + + expect(transaction).to include_event( + "body" => "", + "title" => "POST https://www.google.com" + ) + end + + it "in collector mode", :collector_mode do + start_collector_agent + set_current_transaction(transaction) + perform + Appsignal::Transaction.complete_current! + + expect(event_spans.size).to eq(1) + span = event_spans.first + expect(span.name).to eq("request.http_rb (POST https://www.google.com)") + expect(event_category(span)).to eq("request.http_rb") + expect(scope_of(span)).to eq(["appsignal-ruby/http_rb", Appsignal::VERSION]) + expect(span.attributes).not_to have_key("appsignal.body") + end end end describe "following redirects" do # `HTTP.follow` chains through `HTTP::Session#request` in http6, which is # instrumented separately from `HTTP::Client#request`. The event is - # recorded at the request boundary, so a redirected request is a single - # `request.http_rb` event spanning every hop. - it "records a single event spanning every hop" do + # recorded at the request boundary, so a redirected request stays a single + # `request.http_rb` event (span) spanning every hop; trace context still + # rides on each hop, injected at `perform`. + def perform stub_request(:get, "http://www.google.com") .to_return(:status => 301, :headers => { "Location" => "http://www.example.com" }) stub_request(:get, "http://www.example.com").to_return(:status => 200) HTTP.follow.get("http://www.google.com") + end + + it "in agent mode", :agent_mode do + start_agent + set_current_transaction(transaction) + perform events = transaction.to_h["events"] .select { |event| event["name"] == "request.http_rb" } @@ -85,69 +208,201 @@ ["GET http://www.google.com"] ) end + + it "in collector mode", :collector_mode do + start_collector_agent + set_current_transaction(transaction) + perform + Appsignal::Transaction.complete_current! + + expect(event_spans.size).to eq(1) + span = event_spans.first + expect(span.name).to eq("request.http_rb (GET http://www.google.com)") + expect(span.kind).to eq(:client) + expect(span.attributes["http.request.method"]).to eq("GET") + # The client hands us the method as a lowercase Symbol, so the + # canonical form is recorded and the original kept alongside it. + expect(span.attributes["http.request.method_original"]).to eq("get") + expect(event_category(span)).to eq("request.http_rb") + expect(scope_of(span)).to eq(["appsignal-ruby/http_rb", Appsignal::VERSION]) + end end context "with various URI objects" do - it "parses an object responding to #to_s" do - request_uri = Struct.new(:uri) do - def to_s - uri.to_s + describe "parsing an object responding to #to_s" do + def perform + request_uri = Struct.new(:uri) do + def to_s + uri.to_s + end end + + stub_request(:get, "http://www.google.com") + + HTTP.get(request_uri.new("http://www.google.com")) end - stub_request(:get, "http://www.google.com") + it "in agent mode", :agent_mode do + start_agent + set_current_transaction(transaction) + perform - HTTP.get(request_uri.new("http://www.google.com")) + expect(transaction).to include_event( + "name" => "request.http_rb", + "title" => "GET http://www.google.com" + ) + end - expect(transaction).to include_event( - "name" => "request.http_rb", - "title" => "GET http://www.google.com" - ) + it "in collector mode", :collector_mode do + start_collector_agent + set_current_transaction(transaction) + perform + Appsignal::Transaction.complete_current! + + expect(event_spans.size).to eq(1) + span = event_spans.first + expect(span.name).to eq("request.http_rb (GET http://www.google.com)") + expect(event_category(span)).to eq("request.http_rb") + expect(scope_of(span)).to eq(["appsignal-ruby/http_rb", Appsignal::VERSION]) + end end - it "parses an URI object" do - stub_request(:get, "http://www.google.com") + describe "parsing an URI object" do + def perform + stub_request(:get, "http://www.google.com") - HTTP.get(URI("http://www.google.com")) + HTTP.get(URI("http://www.google.com")) + end - expect(transaction).to include_event( - "name" => "request.http_rb", - "title" => "GET http://www.google.com" - ) + it "in agent mode", :agent_mode do + start_agent + set_current_transaction(transaction) + perform + + expect(transaction).to include_event( + "name" => "request.http_rb", + "title" => "GET http://www.google.com" + ) + end + + it "in collector mode", :collector_mode do + start_collector_agent + set_current_transaction(transaction) + perform + Appsignal::Transaction.complete_current! + + expect(event_spans.size).to eq(1) + span = event_spans.first + expect(span.name).to eq("request.http_rb (GET http://www.google.com)") + expect(event_category(span)).to eq("request.http_rb") + expect(scope_of(span)).to eq(["appsignal-ruby/http_rb", Appsignal::VERSION]) + end end - it "parses an HTTP::URI object" do - stub_request(:get, "http://www.google.com") + describe "parsing an HTTP::URI object" do + def perform + stub_request(:get, "http://www.google.com") + + HTTP.get(HTTP::URI.parse("http://www.google.com")) + end - HTTP.get(HTTP::URI.parse("http://www.google.com")) + it "in agent mode", :agent_mode do + start_agent + set_current_transaction(transaction) + perform - expect(transaction).to include_event( - "name" => "request.http_rb", - "title" => "GET http://www.google.com" - ) + expect(transaction).to include_event( + "name" => "request.http_rb", + "title" => "GET http://www.google.com" + ) + end + + it "in collector mode", :collector_mode do + start_collector_agent + set_current_transaction(transaction) + perform + Appsignal::Transaction.complete_current! + + expect(event_spans.size).to eq(1) + span = event_spans.first + expect(span.name).to eq("request.http_rb (GET http://www.google.com)") + expect(event_category(span)).to eq("request.http_rb") + expect(scope_of(span)).to eq(["appsignal-ruby/http_rb", Appsignal::VERSION]) + end end - it "parses a string" do - stub_request(:get, "http://www.google.com") + describe "parsing a string" do + def perform + stub_request(:get, "http://www.google.com") - HTTP.get("http://www.google.com") + HTTP.get("http://www.google.com") + end - expect(transaction).to include_event( - "name" => "request.http_rb", - "title" => "GET http://www.google.com" - ) + it "in agent mode", :agent_mode do + start_agent + set_current_transaction(transaction) + perform + + expect(transaction).to include_event( + "name" => "request.http_rb", + "title" => "GET http://www.google.com" + ) + end + + it "in collector mode", :collector_mode do + start_collector_agent + set_current_transaction(transaction) + perform + Appsignal::Transaction.complete_current! + + expect(event_spans.size).to eq(1) + span = event_spans.first + expect(span.name).to eq("request.http_rb (GET http://www.google.com)") + expect(event_category(span)).to eq("request.http_rb") + expect(scope_of(span)).to eq(["appsignal-ruby/http_rb", Appsignal::VERSION]) + end end - it "parses a string with non-ascii characters" do - stub_request(:get, "http://www.example.com/áéíóúãÔù") + describe "parsing a string with non-ascii characters" do + def perform + stub_request(:get, "http://www.example.com/áéíóúãÔù") - HTTP.get("http://www.example.com/áéíóúãÔù") + HTTP.get("http://www.example.com/áéíóúãÔù") + end - expect(transaction).to include_event( - "name" => "request.http_rb", - "title" => "GET http://www.example.com" - ) + it "in agent mode", :agent_mode do + start_agent + set_current_transaction(transaction) + perform + + expect(transaction).to include_event( + "name" => "request.http_rb", + "title" => "GET http://www.example.com" + ) + end + + it "in collector mode", :collector_mode do + start_collector_agent + set_current_transaction(transaction) + perform + Appsignal::Transaction.complete_current! + + expect(event_spans.size).to eq(1) + span = event_spans.first + expect(span.name).to eq("request.http_rb (GET http://www.example.com)") + expect(event_category(span)).to eq("request.http_rb") + expect(scope_of(span)).to eq(["appsignal-ruby/http_rb", Appsignal::VERSION]) + end end end + + # Reads the `traceparent` header off the recorded outgoing request to `url`. + def injected_traceparent(url) + traceparent = nil + expect( + a_request(:get, url).with { |request| traceparent = request.headers["Traceparent"] } + ).to have_been_made + traceparent + end end end diff --git a/spec/lib/appsignal/integrations/mongo_ruby_driver_spec.rb b/spec/lib/appsignal/integrations/mongo_ruby_driver_spec.rb index 0e5934ab6..f4e3c23ab 100644 --- a/spec/lib/appsignal/integrations/mongo_ruby_driver_spec.rb +++ b/spec/lib/appsignal/integrations/mongo_ruby_driver_spec.rb @@ -1,5 +1,4 @@ require "appsignal/integrations/mongo_ruby_driver" - describe Appsignal::Hooks::MongoMonitorSubscriber do if DependencyHelper.mongo_present? let(:subscriber) { Appsignal::Hooks::MongoMonitorSubscriber.new } @@ -27,20 +26,24 @@ def command_succeeded_event( ) end - def command_failed_event( + # `failure` is the error document MongoDB replied with, which names the error + # in its `codeName` field. + def command_failed_event( # rubocop:disable Metrics/ParameterLists started_event, request_id: 1, command_name: "find", - database_name: "test", duration: 0.9919 + database_name: "test", duration: 0.9919, + failure: { "code" => 26, "codeName" => "NamespaceNotFound" } ) Mongo::Monitoring::Event::CommandFailed.new( - command_name, database_name, address, request_id, 1, "message", {}, duration, + command_name, database_name, address, request_id, 1, "message", failure, duration, :started_event => started_event ) end # `started` sanitizes the command and stores it on the transaction, keyed by - # request id, for the matching `succeeded`/`failed` to pick up. - it "stores the sanitized command on the transaction" do - start_agent + # request id, for the matching `succeeded`/`failed` to pick up. The store + # lives on the base transaction (not the backend), so this is identical in + # both modes. + it_in_both_modes "stores the sanitized command on the transaction" do transaction = http_request_transaction set_current_transaction(transaction) @@ -58,7 +61,7 @@ def perform subscriber.succeeded(succeeded_event) end - it "records the query as an event and emits a duration metric" do + it "in agent mode", :agent_mode do start_agent transaction = http_request_transaction set_current_transaction(transaction) @@ -77,6 +80,80 @@ def perform "body" => "{\"foo\":\"?\"}" ) end + + it "in collector mode", :collector_mode do + start_collector_agent + transaction = http_request_transaction + set_current_transaction(transaction) + perform + Appsignal::Transaction.complete_current! + + span = event_span_for("query.mongodb") + expect(span).not_to be_nil + expect(span.name).to eq("query.mongodb (find | test | SUCCEEDED)") + expect(span.kind).to eq(:client) + expect(span.parent_span_id).to eq(root_span.span_id) + expect(event_category(span)).to eq("query.mongodb") + expect(scope_of(span)).to eq(["appsignal-ruby/mongo", Appsignal::VERSION]) + expect(span.attributes["db.system.name"]).to eq("mongodb") + expect(span.attributes["db.operation.name"]).to eq("find") + expect(span.attributes["db.namespace"]).to eq("test") + expect(span.attributes["server.address"]).to eq("127.0.0.1") + expect(span.attributes["server.port"]).to eq(27_017) + # This command names no collection, so none is reported. + expect(span.attributes).not_to have_key("db.collection.name") + expect(span.attributes["appsignal.body"]).to eq("{\"foo\":\"?\"}") + expect(span.attributes).not_to have_key("error.type") + + snapshot = metric_snapshot("mongodb_query_duration") + expect(snapshot).not_to be_nil + expect(snapshot.data_points.first.sum).to be_within(0.0001).of(0.9919) + expect(snapshot.data_points.first.attributes).to eq("database" => "test") + end + end + + describe "instrumenting a query on a collection" do + let(:started_event) do + command_started_event( + :request_id => 2, + :command => { "find" => "users", "filter" => { "foo" => "bar" } } + ) + end + let(:succeeded_event) { command_succeeded_event(started_event, :request_id => 2) } + + def perform + subscriber.started(started_event) + subscriber.succeeded(succeeded_event) + end + + it "in agent mode", :agent_mode do + start_agent + transaction = http_request_transaction + set_current_transaction(transaction) + + perform + + expect(transaction).to include_event( + "name" => "query.mongodb", + "title" => "find | test | SUCCEEDED", + "body" => "{\"find\":\"users\",\"filter\":{\"foo\":\"?\"}}" + ) + end + + it "in collector mode", :collector_mode do + start_collector_agent + transaction = http_request_transaction + set_current_transaction(transaction) + perform + Appsignal::Transaction.complete_current! + + span = event_span_for("query.mongodb") + expect(span).not_to be_nil + # MongoDB names the collection in the field named after the command, so + # a query on one reports it. + expect(span.attributes["db.collection.name"]).to eq("users") + expect(span.attributes["db.operation.name"]).to eq("find") + end end describe "instrumenting a failed query" do @@ -88,7 +165,54 @@ def perform subscriber.failed(failed_event) end - it "records the query as an event" do + it "in agent mode", :agent_mode do + start_agent + transaction = http_request_transaction + set_current_transaction(transaction) + + perform + + expect(transaction).to include_event( + "name" => "query.mongodb", + "title" => "find | test | FAILED", + "body" => "{\"foo\":\"?\"}" + ) + end + + it "in collector mode", :collector_mode do + start_collector_agent + transaction = http_request_transaction + set_current_transaction(transaction) + perform + Appsignal::Transaction.complete_current! + + span = event_span_for("query.mongodb") + expect(span).not_to be_nil + expect(span.name).to eq("query.mongodb (find | test | FAILED)") + expect(span.kind).to eq(:client) + expect(event_category(span)).to eq("query.mongodb") + expect(scope_of(span)).to eq(["appsignal-ruby/mongo", Appsignal::VERSION]) + expect(span.attributes["db.system.name"]).to eq("mongodb") + expect(span.attributes["appsignal.body"]).to eq("{\"foo\":\"?\"}") + # The query failed, and MongoDB named the error in the document it + # replied with, along with its own code for it. + expect(span.attributes["error.type"]).to eq("NamespaceNotFound") + expect(span.attributes["db.response.status_code"]).to eq("26") + end + end + + describe "instrumenting a query that failed without a named error" do + let(:started_event) { command_started_event(:request_id => 2) } + let(:failed_event) do + command_failed_event(started_event, :request_id => 2, :failure => {}) + end + + def perform + subscriber.started(started_event) + subscriber.failed(failed_event) + end + + it "in agent mode", :agent_mode do start_agent transaction = http_request_transaction set_current_transaction(transaction) @@ -101,10 +225,28 @@ def perform "body" => "{\"foo\":\"?\"}" ) end + + it "in collector mode", :collector_mode do + start_collector_agent + transaction = http_request_transaction + set_current_transaction(transaction) + perform + Appsignal::Transaction.complete_current! + + span = event_span_for("query.mongodb") + expect(span).not_to be_nil + # A dropped connection is a failure MongoDB never replied to, so there + # is no error name or code to report. The span says what it can with + # the fallback the semantic conventions define. + expect(span.attributes["error.type"]).to eq("_OTHER") + expect(span.attributes).to_not have_key("db.response.status_code") + end end - # The subscriber guards on a current, unpaused transaction before touching - # the extension, so nothing is recorded otherwise. + # The subscriber guards (`return unless current?` / `return if paused?`) run + # before any backend is touched, so "no instrumentation is recorded" is an + # invariant in both modes. Agent mode asserts no extension calls; collector + # mode asserts nothing is exported. describe "without an active transaction" do def perform started = command_started_event @@ -112,7 +254,7 @@ def perform subscriber.succeeded(command_succeeded_event(started)) end - it "does not record anything" do + it "in agent mode", :agent_mode do start_agent expect(Appsignal::Extension).to_not receive(:start_event) expect(Appsignal::Extension).to_not receive(:finish_event) @@ -120,6 +262,15 @@ def perform perform end + + it "in collector mode", :collector_mode do + start_collector_agent + + perform + + expect(span_exporter.finished_spans).to be_empty + expect(metric_snapshot("mongodb_query_duration")).to be_nil + end end describe "when the transaction is paused" do @@ -129,7 +280,7 @@ def perform subscriber.succeeded(command_succeeded_event(started)) end - it "does not record anything" do + it "in agent mode", :agent_mode do start_agent transaction = http_request_transaction set_current_transaction(transaction) @@ -141,6 +292,19 @@ def perform perform end + + it "in collector mode", :collector_mode do + start_collector_agent + transaction = http_request_transaction + set_current_transaction(transaction) + transaction.pause! + + perform + Appsignal::Transaction.complete_current! + + expect(event_spans).to be_empty + expect(metric_snapshot("mongodb_query_duration")).to be_nil + end end end end diff --git a/spec/lib/appsignal/integrations/net_http_spec.rb b/spec/lib/appsignal/integrations/net_http_spec.rb index 24138e2aa..68a8e042c 100644 --- a/spec/lib/appsignal/integrations/net_http_spec.rb +++ b/spec/lib/appsignal/integrations/net_http_spec.rb @@ -1,33 +1,175 @@ require "appsignal/integrations/net_http" describe Appsignal::Integrations::NetHttpIntegration do - let(:transaction) { http_request_transaction } - before { start_agent } - before { set_current_transaction transaction } - around { |example| keep_transactions { example.run } } + describe "a http request" do + def perform + stub_request(:any, "http://www.google.com/") - it "instruments a http request" do - stub_request(:any, "http://www.google.com/") + Net::HTTP.get_response(URI.parse("http://www.google.com")) + end - Net::HTTP.get_response(URI.parse("http://www.google.com")) + it "in agent mode", :agent_mode do + start_agent + transaction = http_request_transaction + set_current_transaction(transaction) + perform - expect(transaction).to include_event( - "name" => "request.net_http", - "title" => "GET http://www.google.com" - ) + expect(transaction).to include_event( + "name" => "request.net_http", + "title" => "GET http://www.google.com", + "body" => "" + ) + end + + it "in collector mode", :collector_mode do + start_collector_agent + transaction = http_request_transaction + set_current_transaction(transaction) + perform + Appsignal::Transaction.complete_current! + + expect(event_spans.size).to eq(1) + span = event_spans.first + expect(span.name).to eq("request.net_http (GET http://www.google.com)") + expect(span.kind).to eq(:client) + expect(span.attributes["http.request.method"]).to eq("GET") + expect(span.attributes["server.address"]).to eq("www.google.com") + expect(span.attributes["server.port"]).to eq(80) + expect(span.attributes["url.full"]).to eq("http://www.google.com/") + expect(span.attributes["http.response.status_code"]).to eq(200) + expect(span.parent_span_id).to eq(root_span.span_id) + expect(event_category(span)).to eq("request.net_http") + expect(scope_of(span)).to eq(["appsignal-ruby/net_http", Appsignal::VERSION]) + expect(span.attributes).not_to have_key("appsignal.body") + + # The outgoing request carries a W3C traceparent for the client span, so + # the called service joins this trace. + expect(injected_traceparent("http://www.google.com/")) + .to eq("00-#{span.hex_trace_id}-#{span.hex_span_id}-01") + end + end + + describe "a https request" do + def perform + stub_request(:any, "https://www.google.com/") + + uri = URI.parse("https://www.google.com") + http = Net::HTTP.new(uri.host, uri.port) + http.use_ssl = true + http.get(uri.request_uri) + end + + it "in agent mode", :agent_mode do + start_agent + transaction = http_request_transaction + set_current_transaction(transaction) + perform + + expect(transaction).to include_event( + "name" => "request.net_http", + "title" => "GET https://www.google.com", + "body" => "" + ) + end + + it "in collector mode", :collector_mode do + start_collector_agent + transaction = http_request_transaction + set_current_transaction(transaction) + perform + Appsignal::Transaction.complete_current! + + expect(event_spans.size).to eq(1) + span = event_spans.first + expect(span.name).to eq("request.net_http (GET https://www.google.com)") + expect(span.kind).to eq(:client) + expect(span.attributes["http.request.method"]).to eq("GET") + expect(span.attributes["server.address"]).to eq("www.google.com") + expect(span.attributes["server.port"]).to eq(443) + expect(span.attributes["url.full"]).to eq("https://www.google.com/") + expect(span.attributes["http.response.status_code"]).to eq(200) + expect(span.parent_span_id).to eq(root_span.span_id) + expect(event_category(span)).to eq("request.net_http") + expect(scope_of(span)).to eq(["appsignal-ruby/net_http", Appsignal::VERSION]) + expect(span.attributes).not_to have_key("appsignal.body") + + expect(injected_traceparent("https://www.google.com/")) + .to eq("00-#{span.hex_trace_id}-#{span.hex_span_id}-01") + end end - it "instruments a https request" do - stub_request(:any, "https://www.google.com/") + describe "a request with a path and a query string" do + def perform + stub_request(:any, "http://www.google.com/search?q=secret") + + Net::HTTP.get_response(URI.parse("http://www.google.com/search?q=secret")) + end - uri = URI.parse("https://www.google.com") - http = Net::HTTP.new(uri.host, uri.port) - http.use_ssl = true - http.get(uri.request_uri) + it "in agent mode", :agent_mode do + start_agent + transaction = http_request_transaction + set_current_transaction(transaction) + perform + + expect(transaction).to include_event( + "name" => "request.net_http", + "title" => "GET http://www.google.com", + "body" => "" + ) + end + + it "in collector mode", :collector_mode do + start_collector_agent + transaction = http_request_transaction + set_current_transaction(transaction) + perform + Appsignal::Transaction.complete_current! + + span = event_spans.first + # The client gives us a request target, which carries the query string. + # The URL keeps the path and drops the query string. + expect(span.attributes["url.full"]).to eq("http://www.google.com/search") + end + end + + describe "a request the server answered with an error" do + def perform + stub_request(:any, "http://www.google.com/").to_return(:status => 503) + + Net::HTTP.get_response(URI.parse("http://www.google.com")) + end + + it "in agent mode", :agent_mode do + start_agent + transaction = http_request_transaction + set_current_transaction(transaction) + perform + + expect(transaction).to include_event( + "name" => "request.net_http", + "title" => "GET http://www.google.com" + ) + end + + it "in collector mode", :collector_mode do + start_collector_agent + transaction = http_request_transaction + set_current_transaction(transaction) + perform + Appsignal::Transaction.complete_current! + + # A response the server answered with an error is still a response, so it + # is reported the same way a successful one is. + expect(event_spans.first.attributes["http.response.status_code"]).to eq(503) + end + end - expect(transaction).to include_event( - "name" => "request.net_http", - "title" => "GET https://www.google.com" - ) + # Reads the `traceparent` header off the recorded outgoing request to `url`. + def injected_traceparent(url) + traceparent = nil + expect( + a_request(:get, url).with { |request| traceparent = request.headers["Traceparent"] } + ).to have_been_made + traceparent end end diff --git a/spec/lib/appsignal/integrations/object_spec.rb b/spec/lib/appsignal/integrations/object_spec.rb index a8a27d122..d3c710daa 100644 --- a/spec/lib/appsignal/integrations/object_spec.rb +++ b/spec/lib/appsignal/integrations/object_spec.rb @@ -1,8 +1,6 @@ require "appsignal/integrations/object" describe Object do - around { |example| keep_transactions { example.run } } - describe "#instrument_method" do context "with instance method" do let(:klass) do @@ -14,6 +12,7 @@ def foo(param1, options = {}, keyword_param: 1) end end let(:instance) { klass.new } + let(:transaction) { http_request_transaction } def call_with_arguments instance.foo( @@ -24,12 +23,6 @@ def call_with_arguments end context "when active" do - let(:transaction) { http_request_transaction } - before do - start_agent - set_current_transaction(transaction) - end - context "with different kind of arguments" do let(:klass) do Class.new do @@ -62,7 +55,10 @@ def splat(*args, **kwargs) end end - it "instruments the method and calls it" do + # Asserts only on return values, which are identical in both modes. + it_in_both_modes "instruments the method and calls it" do + set_current_transaction(transaction) + expect(instance.positional_arguments("abc", "def")).to eq(["abc", "def"]) expect(instance.positional_arguments_splat("abc", "def")).to eq(["abc", "def"]) expect(instance.keyword_arguments(:a => "a", :b => "b")).to eq(["a", "b"]) @@ -77,15 +73,32 @@ def splat(*args, **kwargs) end end - context "with anonymous class" do - it "instruments the method and calls it" do + describe "with anonymous class" do + def perform expect(call_with_arguments).to eq(["abc", { :foo => "bar" }, 2]) + end + + it "in agent mode", :agent_mode do + start_agent + set_current_transaction(transaction) + perform expect(transaction).to include_event("name" => "foo.AnonymousClass.other") end + + it "in collector mode", :collector_mode do + start_collector_agent + set_current_transaction(transaction) + perform + Appsignal::Transaction.complete_current! + + expect(event_spans.size).to eq(1) + expect(event_spans.first.parent_span_id).to eq(root_span.span_id) + expect(event_spans.first.name).to eq("foo.AnonymousClass.other") + end end - context "with named class" do + describe "with named class" do before do stub_const("NamedClass", Class.new do def foo @@ -96,14 +109,31 @@ def foo end let(:klass) { NamedClass } - it "instruments the method and calls it" do + def perform expect(instance.foo).to eq(1) + end + + it "in agent mode", :agent_mode do + start_agent + set_current_transaction(transaction) + perform expect(transaction).to include_event("name" => "foo.NamedClass.other") end + + it "in collector mode", :collector_mode do + start_collector_agent + set_current_transaction(transaction) + perform + Appsignal::Transaction.complete_current! + + expect(event_spans.size).to eq(1) + expect(event_spans.first.parent_span_id).to eq(root_span.span_id) + expect(event_spans.first.name).to eq("foo.NamedClass.other") + end end - context "with nested named class" do + describe "with nested named class" do before do stub_const("MyModule::NestedModule::NamedClass", Class.new do def bar @@ -114,16 +144,33 @@ def bar end let(:klass) { MyModule::NestedModule::NamedClass } - it "instruments the method and calls it" do + def perform expect(instance.bar).to eq(2) + end + + it "in agent mode", :agent_mode do + start_agent + set_current_transaction(transaction) + perform expect(transaction).to include_event( "name" => "bar.NamedClass.NestedModule.MyModule.other" ) end + + it "in collector mode", :collector_mode do + start_collector_agent + set_current_transaction(transaction) + perform + Appsignal::Transaction.complete_current! + + expect(event_spans.size).to eq(1) + expect(event_spans.first.parent_span_id).to eq(root_span.span_id) + expect(event_spans.first.name).to eq("bar.NamedClass.NestedModule.MyModule.other") + end end - context "with custom name" do + describe "with custom name" do let(:klass) do Class.new do def foo @@ -133,12 +180,27 @@ def foo end end - it "instruments with custom name" do + def perform expect(instance.foo).to eq(1) + end - expect(transaction).to include_event( - "name" => "my_method.group" - ) + it "in agent mode", :agent_mode do + start_agent + set_current_transaction(transaction) + perform + + expect(transaction).to include_event("name" => "my_method.group") + end + + it "in collector mode", :collector_mode do + start_collector_agent + set_current_transaction(transaction) + perform + Appsignal::Transaction.complete_current! + + expect(event_spans.size).to eq(1) + expect(event_spans.first.parent_span_id).to eq(root_span.span_id) + expect(event_spans.first.name).to eq("my_method.group") end end @@ -152,7 +214,10 @@ def foo end end - it "yields the block" do + # Asserts only on the yielded return value, identical in both modes. + it_in_both_modes "yields the block" do + set_current_transaction(transaction) + expect(instance.foo { 42 }).to eq(42) end end @@ -177,6 +242,8 @@ def self.bar(param1, options = {}, keyword_param: 1) appsignal_instrument_class_method :bar end end + let(:transaction) { http_request_transaction } + def call_with_arguments klass.bar( "abc", @@ -186,12 +253,6 @@ def call_with_arguments end context "when active" do - let(:transaction) { http_request_transaction } - before do - start_agent - set_current_transaction(transaction) - end - context "with different kind of arguments" do let(:klass) do Class.new do @@ -224,7 +285,10 @@ def self.splat(*args, **kwargs) end end - it "instruments the method and calls it" do + # Asserts only on return values, which are identical in both modes. + it_in_both_modes "instruments the method and calls it" do + set_current_transaction(transaction) + expect(klass.positional_arguments("abc", "def")).to eq(["abc", "def"]) expect(klass.positional_arguments_splat("abc", "def")).to eq(["abc", "def"]) expect(klass.keyword_arguments(:a => "a", :b => "b")).to eq(["a", "b"]) @@ -239,17 +303,34 @@ def self.splat(*args, **kwargs) end end - context "with anonymous class" do - it "instruments the method and calls it" do + describe "with anonymous class" do + def perform expect(Appsignal.active?).to be_truthy expect(call_with_arguments).to eq(["abc", { :foo => "bar" }, 2]) + end + + it "in agent mode", :agent_mode do + start_agent + set_current_transaction(transaction) + perform transaction._sample expect(transaction).to include_event("name" => "bar.class_method.AnonymousClass.other") end + + it "in collector mode", :collector_mode do + start_collector_agent + set_current_transaction(transaction) + perform + Appsignal::Transaction.complete_current! + + expect(event_spans.size).to eq(1) + expect(event_spans.first.parent_span_id).to eq(root_span.span_id) + expect(event_spans.first.name).to eq("bar.class_method.AnonymousClass.other") + end end - context "with named class" do + describe "with named class" do before do stub_const("NamedClass", Class.new do def self.bar @@ -260,13 +341,30 @@ def self.bar end let(:klass) { NamedClass } - it "instruments the method and calls it" do + def perform expect(Appsignal.active?).to be_truthy expect(klass.bar).to eq(2) + end + + it "in agent mode", :agent_mode do + start_agent + set_current_transaction(transaction) + perform expect(transaction).to include_event("name" => "bar.class_method.NamedClass.other") end + it "in collector mode", :collector_mode do + start_collector_agent + set_current_transaction(transaction) + perform + Appsignal::Transaction.complete_current! + + expect(event_spans.size).to eq(1) + expect(event_spans.first.parent_span_id).to eq(root_span.span_id) + expect(event_spans.first.name).to eq("bar.class_method.NamedClass.other") + end + context "with nested named class" do before do stub_const("MyModule::NestedModule::NamedClass", Class.new do @@ -278,17 +376,31 @@ def self.bar end let(:klass) { MyModule::NestedModule::NamedClass } - it "instruments the method and calls it" do - expect(Appsignal.active?).to be_truthy - expect(klass.bar).to eq(2) + it "in agent mode", :agent_mode do + start_agent + set_current_transaction(transaction) + perform + expect(transaction).to include_event( "name" => "bar.class_method.NamedClass.NestedModule.MyModule.other" ) end + + it "in collector mode", :collector_mode do + start_collector_agent + set_current_transaction(transaction) + perform + Appsignal::Transaction.complete_current! + + expect(event_spans.size).to eq(1) + expect(event_spans.first.parent_span_id).to eq(root_span.span_id) + expect(event_spans.first.name) + .to eq("bar.class_method.NamedClass.NestedModule.MyModule.other") + end end end - context "with custom name" do + describe "with custom name" do let(:klass) do Class.new do def self.bar @@ -298,12 +410,29 @@ def self.bar end end - it "instruments with custom name" do + def perform expect(Appsignal.active?).to be_truthy expect(klass.bar).to eq(2) + end + + it "in agent mode", :agent_mode do + start_agent + set_current_transaction(transaction) + perform expect(transaction).to include_event("name" => "my_method.group") end + + it "in collector mode", :collector_mode do + start_collector_agent + set_current_transaction(transaction) + perform + Appsignal::Transaction.complete_current! + + expect(event_spans.size).to eq(1) + expect(event_spans.first.parent_span_id).to eq(root_span.span_id) + expect(event_spans.first.name).to eq("my_method.group") + end end context "with a method given a block" do @@ -316,7 +445,10 @@ def self.bar end end - it "yields the block" do + # Asserts only on the yielded return value, identical in both modes. + it_in_both_modes "yields the block" do + set_current_transaction(transaction) + expect(klass.bar { 42 }).to eq(42) end end diff --git a/spec/lib/appsignal/integrations/ownership_spec.rb b/spec/lib/appsignal/integrations/ownership_spec.rb index 2a8bd8af6..de36a08ea 100644 --- a/spec/lib/appsignal/integrations/ownership_spec.rb +++ b/spec/lib/appsignal/integrations/ownership_spec.rb @@ -2,30 +2,40 @@ require "appsignal/integrations/ownership" describe Appsignal::Integrations::OwnershipIntegration do + let(:start_agent_args) { { :options => config } } let(:config) { { :ownership_set_namespace => false } } before do Ownership.around_change = nil - - start_agent(:options => config) - Appsignal::Hooks::OwnershipHook.new.install end context "when the transaction is created within an owner block" do - it "adds the owner to the transaction tags" do - transaction = nil - owner("owner") do - transaction = Appsignal::Transaction.create("namespace") + describe "adds the owner to the transaction tags" do + def perform + owner("owner") do + @transaction = Appsignal::Transaction.create("namespace") + end end - keep_transactions { transaction.complete } + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + keep_transactions { @transaction.complete } - expect(transaction).to include_tags("owner" => "owner") + expect(@transaction).to include_tags("owner" => "owner") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + @transaction.complete + + expect(root_span.attributes["appsignal.tag.owner"]).to eq("owner") + end end - it "does not set the namespace of the transaction to the owner" do - transaction = nil + it_in_both_modes "does not set the namespace of the transaction to the owner" do owner("owner") do transaction = Appsignal::Transaction.create("namespace") expect(transaction.namespace).to eq("namespace") @@ -35,7 +45,7 @@ context "when `ownership_set_namespace` config option is enabled" do let(:config) { { :ownership_set_namespace => true } } - it "sets the namespace of the transaction to the owner" do + it_in_both_modes "sets the namespace of the transaction to the owner" do owner("owner") do transaction = Appsignal::Transaction.create("namespace") expect(transaction.namespace).to eq("owner") @@ -45,33 +55,55 @@ end context "when the owner is changed after a transaction has been created" do - it "adds the new owner to the transaction tags" do - transaction = Appsignal::Transaction.create("namespace") + describe "adds the new owner to the transaction tags" do + def perform + @transaction = Appsignal::Transaction.create("namespace") + owner("owner") { nil } + end - owner("owner") do - keep_transactions { transaction.complete } - expect(transaction).to include_tags("owner" => "owner") + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + keep_transactions { @transaction.complete } + + expect(@transaction).to include_tags("owner" => "owner") end - end - it "keeps the owner tag set by the last ownership change" do - transaction = Appsignal::Transaction.create("namespace") + it "in collector mode", :collector_mode do + start_collector_agent + perform + @transaction.complete + + expect(root_span.attributes["appsignal.tag.owner"]).to eq("owner") + end + end - owner("first") do - nil + describe "keeps the owner tag set by the last ownership change" do + def perform + @transaction = Appsignal::Transaction.create("namespace") + owner("first") { nil } + owner("second") { nil } end - owner("second") do - nil + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + keep_transactions { @transaction.complete } + + expect(@transaction).to include_tags("owner" => "second") end - keep_transactions { transaction.complete } - expect(transaction).to include_tags("owner" => "second") + it "in collector mode", :collector_mode do + start_collector_agent + perform + @transaction.complete + + expect(root_span.attributes["appsignal.tag.owner"]).to eq("second") + end end - it "does not set the namespace of the current transaction to the owner" do + it_in_both_modes "does not set the namespace of the current transaction to the owner" do transaction = Appsignal::Transaction.create("namespace") - owner("owner") do expect(transaction.namespace).to eq("namespace") end @@ -80,7 +112,7 @@ context "when `ownership_set_namespace` config option is enabled" do let(:config) { { :ownership_set_namespace => true } } - it "sets the namespace of the current transaction to the owner" do + it_in_both_modes "sets the namespace of the current transaction to the owner" do transaction = Appsignal::Transaction.create("namespace") expect(transaction.namespace).to eq("namespace") @@ -89,69 +121,98 @@ end end - it "keeps the namespace given by the last ownership change" do + it_in_both_modes "keeps the namespace given by the last ownership change" do owner("owner") do transaction = Appsignal::Transaction.create("namespace") - owner("first") do - nil - end - - owner("second") do - nil - end + owner("first") { nil } + owner("second") { nil } expect(transaction.namespace).to eq("second") end end end - it "allows the `around_change` hook to be set" do - override = proc do |_owner, block| - # The `around_change` hook must call `block.call` to actually run - # the code within the `owner` block, as documented in `ownership`'s - # README: - # https://github.com/ankane/ownership/blob/b277ef821654d0e73d2e6c8df4f636932b7a90fa/README.md#custom-integrations - block.call - end + describe "allows the `around_change` hook to be set" do + def perform + override = proc do |_owner, block| + # The `around_change` hook must call `block.call` to actually run + # the code within the `owner` block, as documented in `ownership`'s + # README: + # https://github.com/ankane/ownership/blob/b277ef821654d0e73d2e6c8df4f636932b7a90fa/README.md#custom-integrations + block.call + end - expect(override).to receive(:call).with("owner", kind_of(Proc)).and_call_original + expect(override).to receive(:call).with("owner", kind_of(Proc)).and_call_original - Ownership.around_change = override + Ownership.around_change = override - transaction = Appsignal::Transaction.create("namespace") + @transaction = Appsignal::Transaction.create("namespace") + + block = proc {} + expect(block).to receive(:call) + + owner("owner", &block) + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + keep_transactions { @transaction.complete } - block = proc {} - expect(block).to receive(:call) + expect(@transaction).to include_tags("owner" => "owner") + end - owner("owner", &block) + it "in collector mode", :collector_mode do + start_collector_agent + perform + @transaction.complete - keep_transactions { transaction.complete } - expect(transaction).to include_tags("owner" => "owner") + expect(root_span.attributes["appsignal.tag.owner"]).to eq("owner") + end end end context "when an error is reported in a transaction" do - it "sets the owner tag of the transaction to the owner where the error was raised" do - transaction = Appsignal::Transaction.create("namespace") + describe "sets the owner tag of the transaction to the owner where the error was raised" do + def perform + @transaction = Appsignal::Transaction.create("namespace") - begin - owner("error") do - raise "error" - end - rescue StandardError => error - # This owner should be overriden on the tag by the error owner. - owner("rescue") do - nil + begin + owner("error") do + raise "error" + end + rescue StandardError => error + # This owner should be overriden on the tag by the error owner. + owner("rescue") { nil } + + @transaction.add_error(error) end + end - transaction.add_error(error) - keep_transactions { transaction.complete } - expect(transaction).to include_tags("owner" => "error") + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + keep_transactions { @transaction.complete } + + expect(@transaction).to include_tags("owner" => "error") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + @transaction.complete + + # The owner tag is driven by the recorded error; assert the exception + # event that produced it is present. + event = root_span.events.find { |e| e.name == "exception" } + expect(event).not_to be_nil + expect(event.attributes["exception.message"]).to eq("error") + expect(root_span.attributes["appsignal.tag.owner"]).to eq("error") end end - it "does not set the namespace of the transaction to the owner where the error was raised" do + it_in_both_modes "does not set the namespace to the owner where the error was raised" do transaction = Appsignal::Transaction.create("namespace") begin @@ -159,9 +220,7 @@ raise "error" end rescue StandardError => error - owner("rescue") do - nil - end + owner("rescue") { nil } transaction.add_error(error) transaction.complete @@ -172,7 +231,7 @@ context "when `ownership_set_namespace` config option is enabled" do let(:config) { { :ownership_set_namespace => true } } - it "sets the namespace of the transaction to the owner where the error was raised" do + it_in_both_modes "sets the namespace to the owner where the error was raised" do transaction = Appsignal::Transaction.create("namespace") begin @@ -181,9 +240,7 @@ end rescue StandardError => error # This owner should be overriden on the namespace by the error owner. - owner("rescue") do - nil - end + owner("rescue") { nil } expect(transaction.namespace).to eq("rescue") transaction.add_error(error) @@ -195,83 +252,111 @@ end context "when several errors are reported in a transaction" do - it "sets the owner tag of the transaction to the owner where its error was raised" do - transaction = Appsignal::Transaction.create("namespace") + describe "sets the owner tag of the transaction to the owner where its error was raised" do + def perform + @transaction = Appsignal::Transaction.create("namespace") - begin - owner("first") do - raise "first error" - end - rescue StandardError => first_error - # This owner should be overriden on the tag by the error owner. - owner("first_rescue") do - nil + begin + owner("first") do + raise "first error" + end + rescue StandardError => first_error + # This owner should be overriden on the tag by the error owner. + owner("first_rescue") { nil } + @transaction.add_error(first_error) end - transaction.add_error(first_error) + begin + owner("second") do + raise "second error" + end + rescue StandardError => second_error + # This owner should be overriden on the tag by the error owner. + owner("second_rescue") { nil } + @transaction.add_error(second_error) + end end - begin - owner("second") do - raise "second error" - end - rescue StandardError => second_error - # This owner should be overriden on the tag by the error owner. - owner("second_rescue") do - nil - end + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + keep_transactions { @transaction.complete } - transaction.add_error(second_error) + expect(created_transactions.length).to eq(2) + expect(created_transactions.find { |t| t == @transaction }) + .to include_tags("owner" => "first") + expect(created_transactions.find { |t| t != @transaction }) + .to include_tags("owner" => "second") end - keep_transactions { transaction.complete } + it "in collector mode", :collector_mode do + start_collector_agent + perform + @transaction.complete - expect(created_transactions.length).to eq(2) - expect(created_transactions.find do |t| - t == transaction - end).to include_tags("owner" => "first") - expect(created_transactions.find do |t| - t != transaction - end).to include_tags("owner" => "second") + # In collector mode, multiple errors are recorded as exception events + # on the single root span — no duplicate transactions. The owner tag + # is set by the `before_complete` hook with the first error's owner. + root_spans = span_exporter.finished_spans.select do |s| + [:server, :consumer].include?(s.kind) + end + expect(root_spans.size).to eq(1) + events = root_spans.first.events.select { |e| e.name == "exception" } + expect(events.map { |e| e.attributes["exception.message"] }) + .to contain_exactly("first error", "second error") + expect(root_span.attributes["appsignal.tag.owner"]).to eq("first") + end end context "when `ownership_set_namespace` config option is enabled" do let(:config) { { :ownership_set_namespace => true } } - it "sets the namespace of each transaction to the owner where its error was raised" do - transaction = Appsignal::Transaction.create("namespace") - - begin - owner("first") do - raise "first error" - end - rescue StandardError => first_error - # This owner should be overriden on the namespace by the error owner. - owner("first_rescue") do - nil + describe "sets the namespace of each transaction to the owner where its error was raised" do + def perform + @transaction = Appsignal::Transaction.create("namespace") + + begin + owner("first") do + raise "first error" + end + rescue StandardError => first_error + # This owner should be overriden on the namespace by the error owner. + owner("first_rescue") { nil } + @transaction.add_error(first_error) end - transaction.add_error(first_error) + begin + owner("second") do + raise "second error" + end + rescue StandardError => second_error + # This owner should be overriden on the namespace by the error owner. + owner("second_rescue") { nil } + @transaction.add_error(second_error) + end end - begin - owner("second") do - raise "second error" - end - rescue StandardError => second_error - # This owner should be overriden on the namespace by the error owner. - owner("second_rescue") do - nil - end + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + keep_transactions { @transaction.complete } - transaction.add_error(second_error) + expect(created_transactions.length).to eq(2) + expect(created_transactions.find { |t| t == @transaction }.namespace) + .to eq("first") + expect(created_transactions.find { |t| t != @transaction }.namespace) + .to eq("second") end - transaction.complete + it "in collector mode", :collector_mode do + start_collector_agent + perform + @transaction.complete - expect(created_transactions.length).to eq(2) - expect(created_transactions.find { |t| t == transaction }.namespace).to eq("first") - expect(created_transactions.find { |t| t != transaction }.namespace).to eq("second") + # In collector mode there is one trace: the namespace is set by the + # `before_complete` hook with the first error's owner. + expect(@transaction.namespace).to eq("first") + end end end end diff --git a/spec/lib/appsignal/integrations/puma_spec.rb b/spec/lib/appsignal/integrations/puma_spec.rb index 39dea34a0..952fea4e1 100644 --- a/spec/lib/appsignal/integrations/puma_spec.rb +++ b/spec/lib/appsignal/integrations/puma_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "appsignal/integrations/puma" describe Appsignal::Integrations::PumaServer do @@ -5,8 +7,10 @@ before do stub_const("Puma", PumaMock) stub_const("Puma::Server", puma_server) - start_agent + Appsignal::Hooks::PumaHook.new.install end + + let(:puma_server) { default_puma_server_mock } let(:queue_start_time) { fixed_time * 1_000 } let(:env) do Rack::MockRequest.env_for( @@ -20,7 +24,6 @@ let(:server) { Puma::Server.new } let(:error) { ExampleException.new("error message") } around { |example| keep_transactions { example.run } } - before { Appsignal::Hooks::PumaHook.new.install } def lowlevel_error(error, env, status = nil) result = @@ -34,92 +37,209 @@ def lowlevel_error(error, env, status = nil) result end - describe "error reporting" do - let(:puma_server) { default_puma_server_mock } + describe "reporting an error on the active transaction" do + def perform + lowlevel_error(error, env) + end - context "with active transaction" do - before { create_transaction } + it "in agent mode", :agent_mode do + start_agent + create_transaction + expect do + perform + end.to_not(change { created_transactions.count }) - it "reports the error to the transaction" do - expect do - lowlevel_error(error, env) - end.to_not(change { created_transactions.count }) + expect(last_transaction).to have_error("ExampleException", "error message") + expect(last_transaction).to include_tags("reported_by" => "puma_lowlevel_error") + end - expect(last_transaction).to have_error("ExampleException", "error message") - expect(last_transaction).to include_tags("reported_by" => "puma_lowlevel_error") - end + it "in collector mode", :collector_mode do + start_collector_agent + create_transaction + expect do + perform + end.to_not(change { created_transactions.count }) + + event = root_span.events.find { |e| e.name == "exception" } + expect(event).not_to be_nil + expect(event.attributes["exception.type"]).to eq("ExampleException") + expect(event.attributes["exception.message"]).to eq("error message") + expect(event.attributes["exception.stacktrace"]).to be_a(String) + expect(event.attributes["appsignal.alert_this_error"]).to eq(true) + expect(root_span.status.code).to eq(::OpenTelemetry::Trace::Status::ERROR) + expect(root_span.kind).to eq(:server) + expect(root_span.attributes["appsignal.tag.reported_by"]).to eq("puma_lowlevel_error") + # The transaction already existed, so report_error reuses it and keeps + # its default scope rather than applying the Puma scope. + expect(scope_of(root_span)).to eq(["appsignal-ruby", Appsignal::VERSION]) end + end - # This shouldn't happen if the EventHandler is set up correctly, but if - # it's not it will create a new transaction. - context "without active transaction" do - it "creates a new transaction with the error" do - expect do - lowlevel_error(error, env) - end.to change { created_transactions.count }.by(1) + # This shouldn't happen if the EventHandler is set up correctly, but if + # it's not it will create a new transaction. + describe "creating a new transaction with the error when no active transaction" do + def perform + lowlevel_error(error, env) + end - expect(last_transaction).to have_error("ExampleException", "error message") - expect(last_transaction).to include_tags("reported_by" => "puma_lowlevel_error") - end + it "in agent mode", :agent_mode do + start_agent + expect do + perform + end.to change { created_transactions.count }.by(1) + + expect(last_transaction).to have_error("ExampleException", "error message") + expect(last_transaction).to include_tags("reported_by" => "puma_lowlevel_error") end - it "doesn't report internal Puma errors" do + it "in collector mode", :collector_mode do + start_collector_agent expect do - lowlevel_error(Puma::MiniSSL::SSLError.new("error message"), env) - lowlevel_error(Puma::HttpParserError.new("error message"), env) - lowlevel_error(Puma::HttpParserError501.new("error message"), env) - end.to_not(change { created_transactions.count }) + perform + end.to change { created_transactions.count }.by(1) + + event = root_span.events.find { |e| e.name == "exception" } + expect(event).not_to be_nil + expect(event.attributes["exception.type"]).to eq("ExampleException") + expect(event.attributes["exception.message"]).to eq("error message") + expect(event.attributes["exception.stacktrace"]).to be_a(String) + expect(event.attributes["appsignal.alert_this_error"]).to eq(true) + expect(root_span.status.code).to eq(::OpenTelemetry::Trace::Status::ERROR) + expect(root_span.kind).to eq(:server) + expect(root_span.attributes["appsignal.tag.reported_by"]).to eq("puma_lowlevel_error") + expect(scope_of(root_span)).to eq(["appsignal-ruby/puma", Appsignal::VERSION]) end + end - describe "request metadata" do - it "sets request metadata" do - lowlevel_error(error, env) + it_in_both_modes "doesn't report internal Puma errors" do + expect do + lowlevel_error(Puma::MiniSSL::SSLError.new("error message"), env) + lowlevel_error(Puma::HttpParserError.new("error message"), env) + lowlevel_error(Puma::HttpParserError501.new("error message"), env) + end.to_not(change { created_transactions.count }) + end - expect(last_transaction).to include_metadata( - "request_method" => "GET", - "method" => "GET", - "request_path" => "/some/path", - "path" => "/some/path" - ) - expect(last_transaction).to include_environment( - "REQUEST_METHOD" => "GET", - "PATH_INFO" => "/some/path" - # and more, but we don't need to test Rack mock defaults - ) - end + describe "request metadata" do + def perform + lowlevel_error(error, env) + end - it "sets request parameters" do - lowlevel_error(error, env) + it "in agent mode", :agent_mode do + start_agent + perform - expect(last_transaction).to include_params( - "page" => "2", - "query" => "lorem" - ) - end + expect(last_transaction).to include_metadata( + "request_method" => "GET", + "method" => "GET", + "request_path" => "/some/path", + "path" => "/some/path" + ) + expect(last_transaction).to include_environment( + "REQUEST_METHOD" => "GET", + "PATH_INFO" => "/some/path" + # and more, but we don't need to test Rack mock defaults + ) + end - it "sets session data" do - lowlevel_error(error, env) + it "in collector mode", :collector_mode do + start_collector_agent + perform - expect(last_transaction).to include_session_data("session" => "data", "user_id" => 123) - end + # Metadata is emitted as appsignal.tag.* attributes in collector mode + expect(root_span.attributes["appsignal.tag.request_method"]).to eq("GET") + expect(root_span.attributes["appsignal.tag.method"]).to eq("GET") + expect(root_span.attributes["appsignal.tag.request_path"]).to eq("/some/path") + expect(root_span.attributes["appsignal.tag.path"]).to eq("/some/path") + end + end - it "sets the queue start" do - lowlevel_error(error, env) + describe "request parameters" do + def perform + lowlevel_error(error, env) + end - expect(last_transaction).to have_queue_start(queue_start_time) - end + it "in agent mode", :agent_mode do + start_agent + perform + + expect(last_transaction).to include_params( + "page" => "2", + "query" => "lorem" + ) + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + + # The transaction uses the HTTP_REQUEST (web) namespace, so params are + # stored under appsignal.request.payload. + expect(JSON.parse(root_span.attributes["appsignal.request.payload"])) + .to eq("page" => "2", "query" => "lorem") + end + end + + describe "session data" do + def perform + lowlevel_error(error, env) + end + + it "in agent mode", :agent_mode do + start_agent + perform + + expect(last_transaction).to include_session_data("session" => "data", "user_id" => 123) + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + + expect(JSON.parse(root_span.attributes["appsignal.request.session_data"])) + .to eq("session" => "data", "user_id" => 123) + end + end + + describe "queue start" do + def perform + lowlevel_error(error, env) + end + + # Queue start has no OpenTelemetry consumer; it is agent-only. + it "sets the queue start", :agent_mode do + start_agent + perform + + expect(last_transaction).to have_queue_start(queue_start_time) + end + + it "completes without error in collector mode", :collector_mode do + start_collector_agent + expect { perform }.to_not raise_error + expect(root_span).not_to be_nil end end - context "with Puma::Server#lowlevel_error accepting 3 arguments" do - let(:puma_server) { default_puma_server_mock } + describe "with Puma::Server#lowlevel_error accepting 3 arguments" do + def perform(status = nil) + lowlevel_error(error, env, status) + end - it "calls the super class with 3 arguments" do - result = lowlevel_error(error, env, 501) + it "in agent mode", :agent_mode do + start_agent + result = perform(501) expect(result).to eq([501, {}, ""]) expect(last_transaction).to include_tags("response_status" => 501) end + + it "in collector mode", :collector_mode do + start_collector_agent + result = perform(501) + expect(result).to eq([501, {}, ""]) + + expect(root_span.attributes["appsignal.tag.response_status"]).to eq(501) + end end context "with Puma::Server#lowlevel_error accepting 2 arguments" do @@ -131,11 +251,26 @@ def lowlevel_error(_error, _env) end end - it "calls the super class with 3 arguments" do - result = lowlevel_error(error, env) - expect(result).to eq([500, {}, ""]) + describe "calls the super class with 2 arguments and sets the response status" do + def perform + lowlevel_error(error, env) + end + + it "in agent mode", :agent_mode do + start_agent + result = perform + expect(result).to eq([500, {}, ""]) - expect(last_transaction).to include_tags("response_status" => 500) + expect(last_transaction).to include_tags("response_status" => 500) + end + + it "in collector mode", :collector_mode do + start_collector_agent + result = perform + expect(result).to eq([500, {}, ""]) + + expect(root_span.attributes["appsignal.tag.response_status"]).to eq(500) + end end end end diff --git a/spec/lib/appsignal/integrations/que_spec.rb b/spec/lib/appsignal/integrations/que_spec.rb index 886fe2b78..5df212b92 100644 --- a/spec/lib/appsignal/integrations/que_spec.rb +++ b/spec/lib/appsignal/integrations/que_spec.rb @@ -25,115 +25,221 @@ def run(post_id, user_id) let(:instance) { job.new(job_attrs) } before do allow(Que).to receive(:execute) - - start_agent end - around { |example| keep_transactions { example.run } } def perform_que_job(job) job._run end context "without exception" do - it "creates a transaction for a job" do - expect do - perform_que_job(instance) - end.to change { created_transactions.length }.by(1) - - transaction = last_transaction - expect(transaction).to have_id - expect(transaction).to have_namespace(Appsignal::Transaction::BACKGROUND_JOB) - expect(transaction).to have_action("MyQueJob#run") - expect(transaction).to_not have_error - expect(transaction).to include_event( - "body" => "", - "body_format" => Appsignal::EventFormatter::DEFAULT, - "count" => 1, - "name" => "perform_job.que", - "title" => "" - ) - expect(transaction).to include_params( - "arguments" => %w[post_id_123 user_id_123] - ) - if DependencyHelper.que2_present? + def perform + perform_que_job(instance) + end + + describe "creates a transaction for a job" do + it "in agent mode", :agent_mode do + start_agent + expect { perform }.to change { created_transactions.length }.by(1) + + transaction = last_transaction + expect(transaction).to have_id + expect(transaction).to have_namespace(Appsignal::Transaction::BACKGROUND_JOB) + expect(transaction).to have_action("MyQueJob#run") + expect(transaction).to_not have_error + expect(transaction).to include_event( + "body" => "", + "body_format" => Appsignal::EventFormatter::DEFAULT, + "count" => 1, + "name" => "perform_job.que", + "title" => "" + ) expect(transaction).to include_params( - "keyword_arguments" => {} + "arguments" => %w[post_id_123 user_id_123] ) - else - expect(transaction).to_not include_params( - "keyword_arguments" => anything + if DependencyHelper.que2_present? + expect(transaction).to include_params( + "keyword_arguments" => {} + ) + else + expect(transaction).to_not include_params( + "keyword_arguments" => anything + ) + end + expect(transaction).to include_tags( + "attempts" => 0, + "id" => 123, + "priority" => 100, + "queue" => "dfl", + "run_at" => fixed_time.to_s ) + expect(transaction).to be_completed + end + + it "in collector mode", :collector_mode do + start_collector_agent + expect { perform }.to change { created_transactions.length }.by(1) + + expect(root_span.kind).to eq(:consumer) + expect(root_span.attributes["messaging.system"]).to eq("que") + expect(root_span.attributes["messaging.operation.name"]).to eq("perform") + expect(root_span.attributes["messaging.operation.type"]).to eq("process") + expect(root_span.attributes["messaging.destination.name"]).to eq("dfl") + expect(root_span.attributes["appsignal.namespace"]) + .to eq("background") + expect(root_span.attributes["appsignal.action_name"]).to eq("MyQueJob#run") + expect(exception_events).to be_empty + span = event_spans.find { |s| s.name == "perform_job.que" } + expect(span.attributes["messaging.system"]).to eq("que") + expect(span.attributes["messaging.operation.name"]).to eq("perform") + expect(span.attributes["messaging.operation.type"]).to eq("process") + expect(span.attributes["messaging.destination.name"]).to eq("dfl") + expect(span).not_to be_nil + expect(span.parent_span_id).to eq(root_span.span_id) + expect(span.attributes).not_to have_key("appsignal.body") + expect(event_category(span)).to eq("perform_job.que") + expect(scope_of(root_span)).to eq(["appsignal-ruby/que", Appsignal::VERSION]) + expect(scope_of(span)).to eq(["appsignal-ruby/que", Appsignal::VERSION]) + expected_params = { "arguments" => %w[post_id_123 user_id_123] } + expected_params["keyword_arguments"] = {} if DependencyHelper.que2_present? + expect(JSON.parse(root_span.attributes["appsignal.function.parameters"])) + .to eq(expected_params) + expect(root_span.attributes["appsignal.tag.attempts"]).to eq(0) + expect(root_span.attributes["appsignal.tag.id"]).to eq(123) + expect(root_span.attributes["appsignal.tag.priority"]).to eq(100) + expect(root_span.attributes["appsignal.tag.queue"]).to eq("dfl") + expect(root_span.attributes["appsignal.tag.run_at"]).to eq(fixed_time.to_s) + expect(last_transaction).to be_completed end - expect(transaction).to include_tags( - "attempts" => 0, - "id" => 123, - "priority" => 100, - "queue" => "dfl", - "run_at" => fixed_time.to_s - ) - expect(transaction).to be_completed end end context "with exception" do let(:error) { ExampleException.new("oh no!") } - it "reports exceptions and re-raise them" do + before do allow(instance).to receive(:run).and_raise(error) + end + def perform expect do - expect do - perform_que_job(instance) - end.to raise_error(ExampleException) - end.to change { created_transactions.length }.by(1) - - transaction = last_transaction - expect(transaction).to have_id - expect(transaction).to have_action("MyQueJob#run") - expect(transaction).to have_namespace(Appsignal::Transaction::BACKGROUND_JOB) - expect(transaction).to have_error(error.class.name, error.message) - expect(transaction).to include_params( - "arguments" => %w[post_id_123 user_id_123] - ) - expect(transaction).to include_tags( - "attempts" => 0, - "id" => 123, - "priority" => 100, - "queue" => "dfl", - "run_at" => fixed_time.to_s - ) - expect(transaction).to be_completed + perform_que_job(instance) + end.to raise_error(ExampleException) + end + + describe "reports exceptions and re-raises them" do + it "in agent mode", :agent_mode do + start_agent + expect { perform }.to change { created_transactions.length }.by(1) + + transaction = last_transaction + expect(transaction).to have_id + expect(transaction).to have_action("MyQueJob#run") + expect(transaction).to have_namespace(Appsignal::Transaction::BACKGROUND_JOB) + expect(transaction).to have_error(error.class.name, error.message) + expect(transaction).to include_params( + "arguments" => %w[post_id_123 user_id_123] + ) + expect(transaction).to include_tags( + "attempts" => 0, + "id" => 123, + "priority" => 100, + "queue" => "dfl", + "run_at" => fixed_time.to_s + ) + expect(transaction).to be_completed + end + + it "in collector mode", :collector_mode do + start_collector_agent + expect { perform }.to change { created_transactions.length }.by(1) + + expect(root_span.kind).to eq(:consumer) + expect(root_span.attributes["appsignal.action_name"]).to eq("MyQueJob#run") + expect(root_span.attributes["appsignal.namespace"]) + .to eq("background") + event = exception_events.find { |e| e.attributes["exception.type"] == error.class.name } + expect(event).not_to be_nil + expect(event.attributes["exception.message"]).to eq(error.message) + expect(event.attributes["exception.stacktrace"]).to be_a(String) + expect(event.attributes["appsignal.alert_this_error"]).to eq(true) + expected_params = { "arguments" => %w[post_id_123 user_id_123] } + expected_params["keyword_arguments"] = {} if DependencyHelper.que2_present? + expect(JSON.parse(root_span.attributes["appsignal.function.parameters"])) + .to eq(expected_params) + expect(root_span.attributes["appsignal.tag.attempts"]).to eq(0) + expect(root_span.attributes["appsignal.tag.id"]).to eq(123) + expect(root_span.attributes["appsignal.tag.priority"]).to eq(100) + expect(root_span.attributes["appsignal.tag.queue"]).to eq("dfl") + expect(root_span.attributes["appsignal.tag.run_at"]).to eq(fixed_time.to_s) + expect(last_transaction).to be_completed + end end end context "with error" do let(:error) { ExampleStandardError.new("oh no!") } - it "reports errors and not re-raise them" do + before do allow(instance).to receive(:run).and_raise(error) + end + + def perform + perform_que_job(instance) + end - expect { perform_que_job(instance) }.to change { created_transactions.length }.by(1) + # Que 0.x handles the error inside its own `_run` and counts the failed + # attempt there. That happens before the plugin reads the job attributes, + # so it reports one attempt. Que 1 and up leave the count to the worker, + # so they still report none here. + let(:expected_attempts) { DependencyHelper.que1_present? ? 0 : 1 } + + describe "reports errors and does not re-raise them" do + it "in agent mode", :agent_mode do + start_agent + expect { perform }.to change { created_transactions.length }.by(1) + + transaction = last_transaction + expect(transaction).to have_id + expect(transaction).to have_action("MyQueJob#run") + expect(transaction).to have_namespace(Appsignal::Transaction::BACKGROUND_JOB) + expect(transaction).to have_error(error.class.name, error.message) + expect(transaction).to include_params( + "arguments" => %w[post_id_123 user_id_123] + ) + expect(transaction).to include_tags( + "attempts" => expected_attempts, + "id" => 123, + "priority" => 100, + "queue" => "dfl", + "run_at" => fixed_time.to_s + ) + expect(transaction).to be_completed + end - transaction = last_transaction - expect(transaction).to have_id - expect(transaction).to have_action("MyQueJob#run") - expect(transaction).to have_namespace(Appsignal::Transaction::BACKGROUND_JOB) - expect(transaction).to have_error(error.class.name, error.message) - expect(transaction).to include_params( - "arguments" => %w[post_id_123 user_id_123] - ) - expect(transaction).to include_tags( - # Que 0.x handles the error inside its own `_run` and counts the - # failed attempt there. That happens before the plugin reads the job - # attributes, so it reports one attempt. Que 1 and up leave the - # count to the worker, so they still report none here. - "attempts" => DependencyHelper.que1_present? ? 0 : 1, - "id" => 123, - "priority" => 100, - "queue" => "dfl", - "run_at" => fixed_time.to_s - ) - expect(transaction).to be_completed + it "in collector mode", :collector_mode do + start_collector_agent + expect { perform }.to change { created_transactions.length }.by(1) + + expect(root_span.kind).to eq(:consumer) + expect(root_span.attributes["appsignal.action_name"]).to eq("MyQueJob#run") + expect(root_span.attributes["appsignal.namespace"]) + .to eq("background") + event = exception_events.find { |e| e.attributes["exception.type"] == error.class.name } + expect(event).not_to be_nil + expect(event.attributes["exception.message"]).to eq(error.message) + expect(event.attributes["exception.stacktrace"]).to be_a(String) + expect(event.attributes["appsignal.alert_this_error"]).to eq(true) + expected_params = { "arguments" => %w[post_id_123 user_id_123] } + expected_params["keyword_arguments"] = {} if DependencyHelper.que2_present? + expect(JSON.parse(root_span.attributes["appsignal.function.parameters"])) + .to eq(expected_params) + expect(root_span.attributes["appsignal.tag.attempts"]).to eq(expected_attempts) + expect(root_span.attributes["appsignal.tag.id"]).to eq(123) + expect(root_span.attributes["appsignal.tag.priority"]).to eq(100) + expect(root_span.attributes["appsignal.tag.queue"]).to eq("dfl") + expect(root_span.attributes["appsignal.tag.run_at"]).to eq(fixed_time.to_s) + expect(last_transaction).to be_completed + end end end @@ -158,13 +264,31 @@ def run(post_id, user_id: nil) end end - it "reports keyword arguments as parameters" do + def perform perform_que_job(instance) + end - expect(last_transaction).to include_params( - "arguments" => %w[post_id_123], - "keyword_arguments" => { "user_id" => "user_id_123" } - ) + describe "reports keyword arguments as parameters" do + it "in agent mode", :agent_mode do + start_agent + perform + + expect(last_transaction).to include_params( + "arguments" => %w[post_id_123], + "keyword_arguments" => { "user_id" => "user_id_123" } + ) + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + + expect(JSON.parse(root_span.attributes["appsignal.function.parameters"])) + .to eq( + "arguments" => %w[post_id_123], + "keyword_arguments" => { "user_id" => "user_id_123" } + ) + end end end end @@ -178,17 +302,119 @@ def run(*_args) end end - it "uses the custom action" do + def perform perform_que_job(instance) + end + + describe "uses the custom action" do + it "in agent mode", :agent_mode do + start_agent + perform + + transaction = last_transaction + expect(transaction).to have_action("MyCustomJob#perform") + expect(transaction).to be_completed + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform - transaction = last_transaction - expect(transaction).to have_action("MyCustomJob#perform") - expect(transaction).to be_completed + expect(root_span.attributes["appsignal.action_name"]) + .to eq("MyCustomJob#perform") + expect(last_transaction).to be_completed + end + end + end + + # Que only has tags from version 1.0 on, and they are the only carrier the + # trace context can ride on, so this is Que 1 and up only. + context "with incoming trace context", :if => DependencyHelper.que1_present? do + let(:trace_id_hex) { "0af7651916cd43dd8448eb211c80319c" } + let(:span_id_hex) { "b7ad6b7169203331" } + let(:traceparent) { "00-#{trace_id_hex}-#{span_id_hex}-01" } + # OpenTelemetry's Que instrumentation carries the trace context as + # "key:value" tag strings under the job's `data` attribute. + let(:job_attrs) do + super().merge(:data => { :tags => ["traceparent:#{traceparent}"] }) + end + + def perform + perform_que_job(instance) + end + + it "in agent mode", :agent_mode do + start_agent + expect { perform }.to change { created_transactions.length }.by(1) + expect(last_transaction).to be_completed + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + + # The job continues the enqueuer's trace as a child and links back to it. + expect(root_span.kind).to eq(:consumer) + expect(root_span.hex_trace_id).to eq(trace_id_hex) + expect(root_span.parent_span_id.unpack1("H*")).to eq(span_id_hex) + expect(root_span.links.size).to eq(1) + link_context = root_span.links.first.span_context + expect(link_context.hex_trace_id).to eq(trace_id_hex) + expect(link_context.hex_span_id).to eq(span_id_hex) + end + end + + # Only Que 2's `bulk_enqueue` writes the bulk tag, but reading it back is + # the same on every version that has tags, so this runs on Que 1 too. + context "with incoming trace context from a bulk enqueue", + :if => DependencyHelper.que1_present? do + let(:trace_id_hex) { "0af7651916cd43dd8448eb211c80319c" } + let(:span_id_hex) { "b7ad6b7169203331" } + let(:job_attrs) do + super().merge( + :data => { + :tags => [ + "traceparent:00-#{trace_id_hex}-#{span_id_hex}-01", + Appsignal::Integrations::QueTraceContext::BULK_TAG + ] + } + ) + end + + def perform + perform_que_job(instance) + end + + it "in agent mode", :agent_mode do + start_agent + expect { perform }.to change { created_transactions.length }.by(1) + expect(last_transaction).to be_completed + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + + # Every job in the batch shares the one producer span, so the job only + # links back to it. It gets its own trace instead of hanging the whole + # batch off that span. + expect(root_span.kind).to eq(:consumer) + expect(root_span.parent_span_id).to eq(::OpenTelemetry::Trace::INVALID_SPAN_ID) + expect(root_span.hex_trace_id).to_not eq(trace_id_hex) + + expect(root_span.links.size).to eq(1) + link_context = root_span.links.first.span_context + expect(link_context.hex_trace_id).to eq(trace_id_hex) + expect(link_context.hex_span_id).to eq(span_id_hex) end end end end + # Enqueue-side propagation reads context from the job's tags. The carrier + # (tags serialized into the job's `data`) is identical on Que 1 and Que 2, so + # this is covered on both versions. Que 0.x has no tags, so there the enqueue + # is only recorded as an event and nothing is propagated. describe Appsignal::Integrations::QueClientPlugin do let(:job) do Class.new(::Que::Job) do @@ -212,10 +438,7 @@ def self.name allow(Que).to receive(:adapter) .and_return(double(:wake_worker_after_commit => nil)) end - - start_agent end - around { |example| keep_transactions { example.run } } # The arguments are the fifth value Que passes to its `:insert_job` query on # every version. Que 1 and up serialise them to JSON first; Que 0.x hands @@ -233,29 +456,30 @@ def enqueued_tags data ? JSON.parse(data)["tags"] : nil end - # Que 0.x has no `job_options` keyword and no tags at all. It reads its - # scheduling options from top-level keys in a trailing hash instead. So the - # tags are only passed, and only asserted, on Que 1 and up. - def enqueue + # Que 0.x has no `job_options` keyword and no tags. It reads its scheduling + # options from top-level keys in a trailing hash instead. So the tags are + # only passed on Que 1 and up. + def enqueue(tags: ["user:42"]) if DependencyHelper.que1_present? - job.enqueue("post_id_123", :job_options => { :tags => ["user:42"] }) + job.enqueue("post_id_123", :job_options => { :tags => tags }) else job.enqueue("post_id_123") end end - # Whatever the plugin does, it must forward the enqueue call to Que - # unchanged. This matters most on Que 0.x, which turns any keyword argument - # it does not recognise into an extra job argument. That means a keyword the - # plugin adds to the call itself, such as an empty `job_options` default, - # would be persisted as a job argument and break the job when it runs. - def expect_job_to_be_enqueued_unchanged + # Whatever the plugin records or injects, it must leave the job's own + # arguments alone. This matters most on Que 0.x, which turns any keyword + # argument it does not recognise into an extra job argument. A keyword the + # plugin adds to the call itself, such as a `job_options` the caller never + # passed, would be persisted as a job argument and break the job when it + # runs. + def expect_job_arguments_untouched expect(enqueued_args).to eq(["post_id_123"]) - expect(enqueued_tags).to eq(["user:42"]) if DependencyHelper.que1_present? end context "with an active transaction" do - it "records an enqueue event and leaves the job's arguments untouched" do + it "in agent mode", :agent_mode do + start_agent transaction = http_request_transaction set_current_transaction(transaction) @@ -265,35 +489,169 @@ def expect_job_to_be_enqueued_unchanged event = transaction.to_h["events"].find { |e| e["name"] == "enqueue.que" } expect(event).to_not be_nil expect(event["title"]).to eq("enqueue MyQueJob job") - expect_job_to_be_enqueued_unchanged + # No wire context in agent mode; only the user's own tag persists. + expect(enqueued_tags).to eq(["user:42"]) if DependencyHelper.que1_present? + expect_job_arguments_untouched + end + + it "in collector mode", :collector_mode do + start_collector_agent + transaction = http_request_transaction + set_current_transaction(transaction) + + enqueue + Appsignal::Transaction.complete_current! + + # The enqueue is a producer event span under the active transaction, + # named after the job being enqueued. + producer = event_span_for("enqueue.que") + expect(producer.name).to eq("enqueue.que (enqueue MyQueJob job)") + expect(scope_of(producer)).to eq(["appsignal-ruby/que", Appsignal::VERSION]) + expect(producer.kind).to eq(:producer) + expect(producer.attributes["messaging.system"]).to eq("que") + expect(producer.attributes["messaging.operation.name"]).to eq("enqueue") + expect(producer.attributes["messaging.operation.type"]).to eq("send") + # Que only records the queue on the job itself. An enqueue that does not + # name one gets Que's default, which this integration cannot see, so no + # queue is reported. + expect(producer.attributes).to_not have_key("messaging.destination.name") + expect(producer.parent_span_id).to eq(root_span.span_id) + + if DependencyHelper.que1_present? + # The job carries the producer span's context as a traceparent tag, + # alongside the user's own tag. + expect(enqueued_tags).to include("user:42") + expect(enqueued_tags) + .to include("traceparent:00-#{producer.hex_trace_id}-#{producer.hex_span_id}-01") + end + expect_job_arguments_untouched + end + + it "skips propagation rather than break the enqueue when tags are full", + :collector_mode, :if => DependencyHelper.que1_present? do + start_collector_agent + set_current_transaction(http_request_transaction) + + # Already at Que's 5-tag limit; adding trace context would exceed it, so + # propagation is skipped and the enqueue still succeeds unchanged. + full = %w[t1 t2 t3 t4 t5] + expect { enqueue(:tags => full) }.to_not raise_error + Appsignal::Transaction.complete_current! + + expect(enqueued_tags).to eq(full) + end + + it "propagates on the last free tag and adds no bulk tag", + :collector_mode, :if => DependencyHelper.que1_present? do + start_collector_agent + set_current_transaction(http_request_transaction) + + # A job enqueued on its own needs no bulk tag, so one free tag is enough + # to propagate. The batch path needs two and would skip propagation here. + enqueue(:tags => %w[t1 t2 t3 t4]) + Appsignal::Transaction.complete_current! + + expect(enqueued_tags).to include(a_string_starting_with("traceparent:")) + expect(enqueued_tags) + .to_not include(Appsignal::Integrations::QueTraceContext::BULK_TAG) end end context "without an active transaction" do - it "is a transparent pass-through" do + it "in agent mode", :agent_mode do + start_agent + + # No transaction to attach to: a transparent pass-through. expect { enqueue }.to_not raise_error + expect(enqueued_tags).to eq(["user:42"]) if DependencyHelper.que1_present? + expect_job_arguments_untouched + end + + it "in collector mode", :collector_mode do + start_collector_agent - expect_job_to_be_enqueued_unchanged + enqueue + + # No transaction to attach to: nothing recorded, nothing injected. + expect(event_spans_for("enqueue.que")).to be_empty + expect(enqueued_tags).to eq(["user:42"]) if DependencyHelper.que1_present? + expect_job_arguments_untouched end end context "when job enqueue events are suppressed" do # As happens under Active Job, which records the enqueue itself. - it "passes through without recording the enqueue" do + def enqueue_suppressed(transaction) + transaction.suppress_job_enqueue_events { enqueue } + end + + it "in agent mode", :agent_mode do + start_agent transaction = http_request_transaction set_current_transaction(transaction) - transaction.suppress_job_enqueue_events { enqueue } + enqueue_suppressed(transaction) # The outer integration records the enqueue, so this one doesn't. event_names = transaction.to_h["events"].map { |event| event["name"] } expect(event_names).to_not include("enqueue.que") - expect_job_to_be_enqueued_unchanged + expect_job_arguments_untouched + end + + it "in collector mode", :collector_mode do + start_collector_agent + transaction = http_request_transaction + set_current_transaction(transaction) + + enqueue_suppressed(transaction) + Appsignal::Transaction.complete_current! + + # No producer span for the suppressed enqueue... + expect(event_spans_for("enqueue.que")).to be_empty + # ...but the trace context is still injected so the job links back. + if DependencyHelper.que1_present? + expect(enqueued_tags).to include(a_string_starting_with("traceparent:")) + end + expect_job_arguments_untouched + end + end + + context "when enqueue instrumentation is disabled" do + let(:start_agent_args) do + { :options => { :enable_job_enqueue_instrumentation => false } } + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + transaction = http_request_transaction + set_current_transaction(transaction) + + enqueue + + event_names = transaction.to_h["events"].map { |event| event["name"] } + expect(event_names).to_not include("enqueue.que") + expect_job_arguments_untouched + end + + it "in collector mode", :collector_mode do + start_collector_agent + transaction = http_request_transaction + set_current_transaction(transaction) + + enqueue + Appsignal::Transaction.complete_current! + + # No enqueue event means no producer span, so there is nothing for the + # performing job to link back to and no trace context is written. The + # job starts its own trace instead of linking to the web request. + expect(event_spans_for("enqueue.que")).to be_empty + expect(enqueued_tags).to eq(["user:42"]) if DependencyHelper.que1_present? + expect_job_arguments_untouched end end - # `bulk_enqueue` is Que 2 only. The whole batch records a single - # `bulk_enqueue.que` event; the inner enqueues are pass-throughs. + # `bulk_enqueue` is Que 2 only. The whole batch shares one `job_options`, so + # it records a single producer event and the inner enqueues are pass-throughs. describe "#bulk_enqueue", :if => DependencyHelper.que2_present? do before do # Que's bulk path constantizes the job class by name, so it needs a real @@ -313,35 +671,117 @@ def bulk_enqueue(tags: ["user:42"]) end end - it "records one bulk_enqueue event for the whole batch" do - transaction = http_request_transaction - set_current_transaction(transaction) + context "with an active transaction" do + it "records one producer event for the batch in agent mode", :agent_mode do + start_agent + transaction = http_request_transaction + set_current_transaction(transaction) - bulk_enqueue + bulk_enqueue - # One event for the whole batch, titled after the job -- the inner - # enqueues don't add their own. - bulk_events = - transaction.to_h["events"].select { |e| e["name"] == "bulk_enqueue.que" } - expect(bulk_events.size).to eq(1) - expect(bulk_events.first["title"]).to eq("bulk enqueue MyQueJob jobs") - event_names = transaction.to_h["events"].map { |event| event["name"] } - expect(event_names).to_not include("enqueue.que") - expect(enqueued_tags).to eq(["user:42"]) + # One event for the whole batch, titled after the job -- the inner + # enqueues don't add their own. + bulk_events = + transaction.to_h["events"].select { |e| e["name"] == "bulk_enqueue.que" } + expect(bulk_events.size).to eq(1) + expect(bulk_events.first["title"]).to eq("bulk enqueue MyQueJob jobs") + event_names = transaction.to_h["events"].map { |event| event["name"] } + expect(event_names).to_not include("enqueue.que") + expect(enqueued_tags).to eq(["user:42"]) + end + + it "injects the batch's context once in collector mode", :collector_mode do + start_collector_agent + transaction = http_request_transaction + set_current_transaction(transaction) + + bulk_enqueue + Appsignal::Transaction.complete_current! + + producers = event_spans_for("bulk_enqueue.que") + expect(producers.size).to eq(1) + producer = producers.first + expect(producer.name).to eq("bulk_enqueue.que (bulk enqueue MyQueJob jobs)") + expect(producer.kind).to eq(:producer) + expect(producer.attributes["messaging.system"]).to eq("que") + expect(producer.attributes["messaging.operation.name"]).to eq("enqueue") + expect(producer.attributes["messaging.operation.type"]).to eq("send") + # As with a single enqueue, a batch that does not name a queue gets + # Que's default, which this integration cannot see. + expect(producer.attributes).to_not have_key("messaging.destination.name") + expect(producer.parent_span_id).to eq(root_span.span_id) + + # Every job in the batch carries the one producer span's context, plus + # the tag marking it as one of a batch so the worker only links back. + expect(enqueued_tags).to include("user:42") + expect(enqueued_tags) + .to include("traceparent:00-#{producer.hex_trace_id}-#{producer.hex_span_id}-01") + expect(enqueued_tags) + .to include(Appsignal::Integrations::QueTraceContext::BULK_TAG) + end + + it "skips propagation rather than break the enqueue when tags are full", + :collector_mode do + start_collector_agent + set_current_transaction(http_request_transaction) + + full = %w[t1 t2 t3 t4 t5] + expect { bulk_enqueue(:tags => full) }.to_not raise_error + Appsignal::Transaction.complete_current! + + expect(enqueued_tags).to eq(full) + end + + it "skips propagation when only the trace context would fit", + :collector_mode do + start_collector_agent + set_current_transaction(http_request_transaction) + + # There is room for the trace context but not for the bulk tag as well. + # Propagating without that tag would make the worker parent every job in + # the batch to the one producer span, so nothing is propagated at all. + tags = %w[t1 t2 t3 t4] + bulk_enqueue(:tags => tags) + Appsignal::Transaction.complete_current! + + expect(enqueued_tags).to eq(tags) + end end context "when job enqueue events are suppressed" do # As happens under Active Job, which records the enqueue itself. - it "passes through without recording the enqueue" do + def bulk_enqueue_suppressed(transaction) + transaction.suppress_job_enqueue_events { bulk_enqueue } + end + + it "in agent mode", :agent_mode do + start_agent transaction = http_request_transaction set_current_transaction(transaction) - transaction.suppress_job_enqueue_events { bulk_enqueue } + bulk_enqueue_suppressed(transaction) # The outer integration records the enqueue, so this one doesn't. event_names = transaction.to_h["events"].map { |event| event["name"] } expect(event_names).to_not include("bulk_enqueue.que") end + + it "in collector mode", :collector_mode do + start_collector_agent + transaction = http_request_transaction + set_current_transaction(transaction) + + bulk_enqueue_suppressed(transaction) + Appsignal::Transaction.complete_current! + + # No producer span for the suppressed batch... + expect(event_spans_for("bulk_enqueue.que")).to be_empty + # ...but the trace context is still injected so the jobs link back, and + # the batch is still marked so they link instead of parenting. + expect(enqueued_tags).to include(a_string_starting_with("traceparent:")) + expect(enqueued_tags) + .to include(Appsignal::Integrations::QueTraceContext::BULK_TAG) + end end end end diff --git a/spec/lib/appsignal/integrations/railtie_spec.rb b/spec/lib/appsignal/integrations/railtie_spec.rb index dbff4aeea..3fc4ca3a2 100644 --- a/spec/lib/appsignal/integrations/railtie_spec.rb +++ b/spec/lib/appsignal/integrations/railtie_spec.rb @@ -254,25 +254,63 @@ def subscribe(subscriber) if Rails.respond_to?(:error) describe "Rails error reporter" do - before { start_agent } - around { |example| keep_transactions { example.run } } - - it "reports the error when the error is not handled (reraises the error)" do - with_rails_error_reporter do - expect do - Rails.error.record { raise ExampleStandardError, "error message" } - end.to raise_error(ExampleStandardError, "error message") + describe "reports the error when the error is not handled (reraises the error)" do + def perform + with_rails_error_reporter do + expect do + Rails.error.record { raise ExampleStandardError, "error message" } + end.to raise_error(ExampleStandardError, "error message") + end + end + + it "in agent mode", :agent_mode do + start_agent + perform + + expect(last_transaction).to have_error("ExampleStandardError", "error message") end - expect(last_transaction).to have_error("ExampleStandardError", "error message") + it "in collector mode", :collector_mode do + start_collector_agent + perform + + event = root_span.events.find { |e| e.name == "exception" } + expect(event).not_to be_nil + expect(event.attributes["exception.type"]).to eq("ExampleStandardError") + expect(event.attributes["exception.message"]).to eq("error message") + expect(event.attributes["exception.stacktrace"]).to be_a(String) + expect(event.attributes["appsignal.alert_this_error"]).to eq(true) + expect(root_span.status.code).to eq(::OpenTelemetry::Trace::Status::ERROR) + expect(scope_of(root_span)).to eq(["appsignal-ruby/rails", Appsignal::VERSION]) + end end - it "reports the error when the error is handled (not reraised)" do - with_rails_error_reporter do - Rails.error.handle { raise ExampleStandardError, "error message" } + describe "reports the error when the error is handled (not reraised)" do + def perform + with_rails_error_reporter do + Rails.error.handle { raise ExampleStandardError, "error message" } + end + end + + it "in agent mode", :agent_mode do + start_agent + perform + + expect(last_transaction).to have_error("ExampleStandardError", "error message") end - expect(last_transaction).to have_error("ExampleStandardError", "error message") + it "in collector mode", :collector_mode do + start_collector_agent + perform + + event = root_span.events.find { |e| e.name == "exception" } + expect(event).not_to be_nil + expect(event.attributes["exception.type"]).to eq("ExampleStandardError") + expect(event.attributes["exception.message"]).to eq("error message") + expect(event.attributes["exception.stacktrace"]).to be_a(String) + expect(event.attributes["appsignal.alert_this_error"]).to eq(true) + expect(root_span.status.code).to eq(::OpenTelemetry::Trace::Status::ERROR) + end end context "Sidekiq internal errors" do @@ -281,37 +319,92 @@ def subscribe(subscriber) require "sidekiq/job_retry" end - it "ignores Sidekiq::JobRetry::Handled errors" do - with_rails_error_reporter do - Rails.error.handle { raise Sidekiq::JobRetry::Handled, "error message" } + describe "ignores Sidekiq::JobRetry::Handled errors" do + def perform + with_rails_error_reporter do + Rails.error.handle { raise Sidekiq::JobRetry::Handled, "error message" } + end end - expect(last_transaction).to_not have_error + it "in agent mode", :agent_mode do + start_agent + perform + + expect(last_transaction).to_not have_error + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + + expect(exception_events).to be_empty + end end - it "ignores Sidekiq::JobRetry::Skip errors" do - with_rails_error_reporter do - Rails.error.handle { raise Sidekiq::JobRetry::Skip, "error message" } + describe "ignores Sidekiq::JobRetry::Skip errors" do + def perform + with_rails_error_reporter do + Rails.error.handle { raise Sidekiq::JobRetry::Skip, "error message" } + end + end + + it "in agent mode", :agent_mode do + start_agent + perform + + expect(last_transaction).to_not have_error end - expect(last_transaction).to_not have_error + it "in collector mode", :collector_mode do + start_collector_agent + perform + + expect(exception_events).to be_empty + end end - it "doesn't crash when no Sidekiq error classes are found" do - hide_const("Sidekiq::JobRetry") - with_rails_error_reporter do - Rails.error.handle { raise ExampleStandardError, "error message" } + describe "doesn't crash when no Sidekiq error classes are found" do + def perform + hide_const("Sidekiq::JobRetry") + with_rails_error_reporter do + Rails.error.handle { raise ExampleStandardError, "error message" } + end end - expect(last_transaction).to have_error("ExampleStandardError", "error message") + it "in agent mode", :agent_mode do + start_agent + perform + + expect(last_transaction).to have_error("ExampleStandardError", "error message") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + + event = root_span.events.find { |e| e.name == "exception" } + expect(event).not_to be_nil + expect(event.attributes["exception.type"]).to eq("ExampleStandardError") + expect(event.attributes["exception.message"]).to eq("error message") + expect(event.attributes["exception.stacktrace"]).to be_a(String) + expect(event.attributes["appsignal.alert_this_error"]).to eq(true) + expect(root_span.status.code).to eq(::OpenTelemetry::Trace::Status::ERROR) + end end end context "when no transaction is active" do - it "reports the error on a new transaction" do - with_rails_error_reporter do - expect do + describe "reports the error on a new transaction" do + def perform + with_rails_error_reporter do Rails.error.handle { raise ExampleStandardError, "error message" } + end + end + + it "in agent mode", :agent_mode do + start_agent + expect do + perform end.to change { created_transactions.count }.by(1) transaction = last_transaction @@ -319,158 +412,349 @@ def subscribe(subscriber) expect(transaction).to_not have_action expect(transaction).to have_error("ExampleStandardError", "error message") end + + it "in collector mode", :collector_mode do + start_collector_agent + expect do + perform + end.to change { created_transactions.count }.by(1) + + expect(root_span.kind).to eq(:server) + expect(root_span.attributes["appsignal.namespace"]) + .to eq("web") + expect(root_span.attributes).to_not have_key("appsignal.action_name") + event = root_span.events.find { |e| e.name == "exception" } + expect(event).not_to be_nil + expect(event.attributes["exception.type"]).to eq("ExampleStandardError") + expect(event.attributes["exception.message"]).to eq("error message") + expect(event.attributes["exception.stacktrace"]).to be_a(String) + expect(event.attributes["appsignal.alert_this_error"]).to eq(true) + expect(root_span.status.code).to eq(::OpenTelemetry::Trace::Status::ERROR) + end end end context "when a transaction is active" do - it "reports the error on the transaction when a transaction is active" do - current_transaction = http_request_transaction - current_transaction.set_namespace "custom" - current_transaction.set_action "CustomAction" - current_transaction.add_tags(:duplicated_tag => "duplicated value") - - with_rails_error_reporter do - with_current_transaction current_transaction do - Rails.error.handle { raise ExampleStandardError, "error message" } - expect do - current_transaction.complete - end.to_not(change { created_transactions.count }) - - transaction = current_transaction - expect(transaction).to have_namespace("custom") - expect(transaction).to have_action("CustomAction") - expect(transaction).to have_error("ExampleStandardError", "error message") - expect(transaction).to include_tags( - "reported_by" => "rails_error_reporter", - "duplicated_tag" => "duplicated value", - "severity" => "warning" - ) + describe "reports the error on the transaction when a transaction is active" do + def perform(current_transaction) + with_rails_error_reporter do + with_current_transaction current_transaction do + Rails.error.handle { raise ExampleStandardError, "error message" } + end end end - end - context "when the current transaction has an error" do - it "reports the error on a new transaction" do + it "in agent mode", :agent_mode do + start_agent current_transaction = http_request_transaction current_transaction.set_namespace "custom" current_transaction.set_action "CustomAction" current_transaction.add_tags(:duplicated_tag => "duplicated value") - current_transaction.add_error(ExampleStandardError.new("error message")) + expect do + perform(current_transaction) + end.to_not(change { created_transactions.count }) + current_transaction.complete + + expect(current_transaction).to have_namespace("custom") + expect(current_transaction).to have_action("CustomAction") + expect(current_transaction).to have_error("ExampleStandardError", "error message") + expect(current_transaction).to include_tags( + "reported_by" => "rails_error_reporter", + "duplicated_tag" => "duplicated value", + "severity" => "warning" + ) + end + + it "in collector mode", :collector_mode do + start_collector_agent + current_transaction = http_request_transaction + current_transaction.set_namespace "custom" + current_transaction.set_action "CustomAction" + current_transaction.add_tags(:duplicated_tag => "duplicated value") + + expect do + perform(current_transaction) + end.to_not(change { created_transactions.count }) + current_transaction.complete + + expect(root_span.attributes["appsignal.namespace"]).to eq("custom") + expect(root_span.attributes["appsignal.action_name"]).to eq("CustomAction") + event = root_span.events.find { |e| e.name == "exception" } + expect(event).not_to be_nil + expect(event.attributes["exception.type"]).to eq("ExampleStandardError") + expect(event.attributes["exception.message"]).to eq("error message") + expect(event.attributes["exception.stacktrace"]).to be_a(String) + expect(event.attributes["appsignal.alert_this_error"]).to eq(true) + expect(root_span.status.code).to eq(::OpenTelemetry::Trace::Status::ERROR) + expect(root_span.attributes["appsignal.tag.reported_by"]) + .to eq("rails_error_reporter") + expect(root_span.attributes["appsignal.tag.duplicated_tag"]) + .to eq("duplicated value") + expect(root_span.attributes["appsignal.tag.severity"]).to eq("warning") + end + end + + context "when the current transaction has an error" do + describe "reports the error (new transaction in agent, span in collector)" do + it "in agent mode", :agent_mode do + start_agent + current_transaction = http_request_transaction + current_transaction.set_namespace "custom" + current_transaction.set_action "CustomAction" + current_transaction.add_tags(:duplicated_tag => "duplicated value") + current_transaction.add_error(ExampleStandardError.new("error message")) + + with_rails_error_reporter do + with_current_transaction current_transaction do + Rails.error.handle { raise ExampleStandardError, "other message" } + expect do + current_transaction.complete + end.to change { created_transactions.count }.by(1) + + expect(current_transaction) + .to_not include_tags("reported_by" => "rails_error_reporter") + + transaction = last_transaction + expect(transaction).to have_namespace("custom") + expect(transaction).to have_action("CustomAction") + expect(transaction).to have_error("ExampleStandardError", "other message") + expect(transaction).to include_tags( + "reported_by" => "rails_error_reporter", + "duplicated_tag" => "duplicated value", + "severity" => "warning" + ) + end + end + end + + it "in collector mode", :collector_mode do + start_collector_agent + current_transaction = http_request_transaction + current_transaction.set_namespace "custom" + current_transaction.set_action "CustomAction" + current_transaction.add_tags(:duplicated_tag => "duplicated value") + current_transaction.add_error(ExampleStandardError.new("error message")) + + with_rails_error_reporter do + with_current_transaction current_transaction do + Rails.error.handle { raise ExampleStandardError, "other message" } + # In collector mode both errors collapse onto one span — no + # duplicate transaction is created. + expect do + current_transaction.complete + end.to_not(change { created_transactions.count }) + + expect(root_span.attributes["appsignal.namespace"]).to eq("custom") + expect(root_span.attributes["appsignal.action_name"]).to eq("CustomAction") + # Both errors collapse onto one root span as two exception events. + root_spans = span_exporter.finished_spans.select do |s| + [:server, :consumer].include?(s.kind) + end + expect(root_spans.size).to eq(1) + events = root_spans.first.events.select { |e| e.name == "exception" } + expect(events.map { |e| e.attributes["exception.message"] }) + .to contain_exactly("error message", "other message") + expect(root_span.status.code).to eq(::OpenTelemetry::Trace::Status::ERROR) + expect(root_span.attributes["appsignal.tag.reported_by"]) + .to eq("rails_error_reporter") + expect(root_span.attributes["appsignal.tag.duplicated_tag"]) + .to eq("duplicated value") + expect(root_span.attributes["appsignal.tag.severity"]).to eq("warning") + end + end + end + end + + describe "reports the error on a new transaction with the given context (agent) / merges context onto the span (collector)" do # rubocop:disable Layout/LineLength + it "in agent mode", :agent_mode do + start_agent + current_transaction = http_request_transaction + current_transaction.set_namespace "custom" + current_transaction.set_action "CustomAction" + current_transaction.add_tags(:duplicated_tag => "duplicated value") + current_transaction.add_custom_data(:original => "custom value") + current_transaction.add_error(ExampleStandardError.new("error message")) + + with_rails_error_reporter do + with_current_transaction current_transaction do + given_context = { + :appsignal => { + :namespace => "context", + :action => "ContextAction", + :custom_data => { :context => "context data" } + + } + } + Rails.error.handle(:context => given_context) do + raise ExampleStandardError, "other message" + end + expect do + current_transaction.complete + end.to change { created_transactions.count }.by(1) + + transaction = last_transaction + expect(transaction).to have_namespace("context") + expect(transaction).to have_action("ContextAction") + expect(transaction).to have_error("ExampleStandardError", "other message") + expect(transaction).to include_tags( + "reported_by" => "rails_error_reporter", + "duplicated_tag" => "duplicated value", + "severity" => "warning" + ) + expect(transaction).to include_custom_data( + "original" => "custom value", + "context" => "context data" + ) + end + end + end + + it "in collector mode", :collector_mode do + start_collector_agent + current_transaction = http_request_transaction + current_transaction.set_namespace "custom" + current_transaction.set_action "CustomAction" + current_transaction.add_tags(:duplicated_tag => "duplicated value") + current_transaction.add_custom_data(:original => "custom value") + current_transaction.add_error(ExampleStandardError.new("error message")) + + with_rails_error_reporter do + with_current_transaction current_transaction do + given_context = { + :appsignal => { + :namespace => "context", + :action => "ContextAction", + :custom_data => { :context => "context data" } + } + } + Rails.error.handle(:context => given_context) do + raise ExampleStandardError, "other message" + end + # In collector mode both errors collapse onto one span — no + # duplicate transaction is created. The reporter's block + # overrides namespace/action on the existing span. + expect do + current_transaction.complete + end.to_not(change { created_transactions.count }) + + expect(root_span.attributes["appsignal.namespace"]).to eq("context") + expect(root_span.attributes["appsignal.action_name"]).to eq("ContextAction") + # Both errors collapse onto one root span as two exception events. + root_spans = span_exporter.finished_spans.select do |s| + [:server, :consumer].include?(s.kind) + end + expect(root_spans.size).to eq(1) + events = root_spans.first.events.select { |e| e.name == "exception" } + expect(events.map { |e| e.attributes["exception.message"] }) + .to contain_exactly("error message", "other message") + expect(root_span.status.code).to eq(::OpenTelemetry::Trace::Status::ERROR) + expect(root_span.attributes["appsignal.tag.reported_by"]) + .to eq("rails_error_reporter") + expect(root_span.attributes["appsignal.tag.duplicated_tag"]) + .to eq("duplicated value") + expect(root_span.attributes["appsignal.tag.severity"]).to eq("warning") + custom_data = JSON.parse(root_span.attributes["appsignal.custom_data"]) + expect(custom_data).to include( + "original" => "custom value", + "context" => "context data" + ) + end + end + end + end + end + + describe "overwrites duplicate tags with tags from context" do + def perform(current_transaction) with_rails_error_reporter do with_current_transaction current_transaction do - Rails.error.handle { raise ExampleStandardError, "other message" } - expect do - current_transaction.complete - end.to change { created_transactions.count }.by(1) - - expect(current_transaction) - .to_not include_tags("reported_by" => "rails_error_reporter") - - transaction = last_transaction - expect(transaction).to have_namespace("custom") - expect(transaction).to have_action("CustomAction") - expect(transaction).to have_error("ExampleStandardError", "other message") - expect(transaction).to include_tags( - "reported_by" => "rails_error_reporter", - "duplicated_tag" => "duplicated value", - "severity" => "warning" - ) + given_context = { :tag1 => "value1", :tag2 => "value2" } + Rails.error.handle(:context => given_context) { raise ExampleStandardError } + current_transaction.complete end end end - it "reports the error on a new transaction with the given context" do + it "in agent mode", :agent_mode do + start_agent current_transaction = http_request_transaction - current_transaction.set_namespace "custom" - current_transaction.set_action "CustomAction" - current_transaction.add_tags(:duplicated_tag => "duplicated value") - current_transaction.add_custom_data(:original => "custom value") - current_transaction.add_error(ExampleStandardError.new("error message")) + current_transaction.add_tags(:tag1 => "duplicated value") + + perform(current_transaction) + expect(current_transaction).to include_tags( + "reported_by" => "rails_error_reporter", + "tag1" => "value1", + "tag2" => "value2", + "severity" => "warning" + ) + end + + it "in collector mode", :collector_mode do + start_collector_agent + current_transaction = http_request_transaction + current_transaction.add_tags(:tag1 => "duplicated value") + + perform(current_transaction) + + expect(root_span.attributes["appsignal.tag.reported_by"]) + .to eq("rails_error_reporter") + expect(root_span.attributes["appsignal.tag.tag1"]).to eq("value1") + expect(root_span.attributes["appsignal.tag.tag2"]).to eq("value2") + expect(root_span.attributes["appsignal.tag.severity"]).to eq("warning") + end + end + + describe "sets namespace, action and custom data with values from context" do + def perform(current_transaction) with_rails_error_reporter do with_current_transaction current_transaction do given_context = { :appsignal => { :namespace => "context", :action => "ContextAction", - :custom_data => { :context => "context data" } - + :custom_data => { :data => "context data" } } } - Rails.error.handle(:context => given_context) do - raise ExampleStandardError, "other message" - end - expect do - current_transaction.complete - end.to change { created_transactions.count }.by(1) - - transaction = last_transaction - expect(transaction).to have_namespace("context") - expect(transaction).to have_action("ContextAction") - expect(transaction).to have_error("ExampleStandardError", "other message") - expect(transaction).to include_tags( - "reported_by" => "rails_error_reporter", - "duplicated_tag" => "duplicated value", - "severity" => "warning" - ) - expect(transaction).to include_custom_data( - "original" => "custom value", - "context" => "context data" - ) + Rails.error.handle(:context => given_context) { raise ExampleStandardError } + current_transaction.complete end end end - end - it "overwrites duplicate tags with tags from context" do - current_transaction = http_request_transaction - current_transaction.add_tags(:tag1 => "duplicated value") + it "in agent mode", :agent_mode do + start_agent + current_transaction = http_request_transaction + current_transaction.set_namespace "custom" + current_transaction.set_action "CustomAction" - with_rails_error_reporter do - with_current_transaction current_transaction do - given_context = { :tag1 => "value1", :tag2 => "value2" } - Rails.error.handle(:context => given_context) { raise ExampleStandardError } - current_transaction.complete - - expect(current_transaction).to include_tags( - "reported_by" => "rails_error_reporter", - "tag1" => "value1", - "tag2" => "value2", - "severity" => "warning" - ) - end + perform(current_transaction) + + expect(current_transaction).to have_namespace("context") + expect(current_transaction).to have_action("ContextAction") + expect(current_transaction).to include_custom_data("data" => "context data") end - end - it "sets namespace, action and custom data with values from context" do - current_transaction = http_request_transaction - current_transaction.set_namespace "custom" - current_transaction.set_action "CustomAction" + it "in collector mode", :collector_mode do + start_collector_agent + current_transaction = http_request_transaction + current_transaction.set_namespace "custom" + current_transaction.set_action "CustomAction" - with_rails_error_reporter do - with_current_transaction current_transaction do - given_context = { - :appsignal => { - :namespace => "context", - :action => "ContextAction", - :custom_data => { :data => "context data" } - } - } - Rails.error.handle(:context => given_context) { raise ExampleStandardError } - current_transaction.complete + perform(current_transaction) - expect(current_transaction).to have_namespace("context") - expect(current_transaction).to have_action("ContextAction") - expect(current_transaction).to include_custom_data("data" => "context data") - end + expect(root_span.attributes["appsignal.namespace"]).to eq("context") + expect(root_span.attributes["appsignal.action_name"]).to eq("ContextAction") + expect(JSON.parse(root_span.attributes["appsignal.custom_data"])) + .to include("data" => "context data") end end end if DependencyHelper.rails7_1_present? - it "sets the namespace to 'runner' if the source is the Rails runner" do - expect do + describe "sets the namespace to 'runner' if the source is the Rails runner" do + def perform with_rails_error_reporter do expect do Rails.error.record(:source => "application.runner.railties") do @@ -478,35 +762,82 @@ def subscribe(subscriber) end end.to raise_error(ExampleStandardError, "error message") end - end.to change { created_transactions.count }.by(1) + end - transaction = last_transaction - expect(transaction).to have_namespace("runner") - expect(transaction).to_not have_action - expect(transaction).to have_error("ExampleStandardError", "error message") - expect(transaction).to include_tags( - "reported_by" => "rails_error_reporter", - "source" => "application.runner.railties" - ) + it "in agent mode", :agent_mode do + start_agent + expect do + perform + end.to change { created_transactions.count }.by(1) + + transaction = last_transaction + expect(transaction).to have_namespace("runner") + expect(transaction).to_not have_action + expect(transaction).to have_error("ExampleStandardError", "error message") + expect(transaction).to include_tags( + "reported_by" => "rails_error_reporter", + "source" => "application.runner.railties" + ) + end + + it "in collector mode", :collector_mode do + start_collector_agent + expect do + perform + end.to change { created_transactions.count }.by(1) + + expect(root_span.kind).to eq(:server) + expect(root_span.attributes["appsignal.namespace"]).to eq("runner") + expect(root_span.attributes).to_not have_key("appsignal.action_name") + event = root_span.events.find { |e| e.name == "exception" } + expect(event).not_to be_nil + expect(event.attributes["exception.type"]).to eq("ExampleStandardError") + expect(event.attributes["exception.message"]).to eq("error message") + expect(event.attributes["exception.stacktrace"]).to be_a(String) + expect(event.attributes["appsignal.alert_this_error"]).to eq(true) + expect(root_span.status.code).to eq(::OpenTelemetry::Trace::Status::ERROR) + expect(root_span.attributes["appsignal.tag.reported_by"]) + .to eq("rails_error_reporter") + expect(root_span.attributes["appsignal.tag.source"]) + .to eq("application.runner.railties") + end end end - it "sets the error context as tags" do - given_context = { - :appsignal => { :something => "not used" }, # Not set as tag - :tag1 => "value1", - :tag2 => "value2" - } - with_rails_error_reporter do - Rails.error.handle(:context => given_context) { raise ExampleStandardError } + describe "sets the error context as tags" do + def perform + given_context = { + :appsignal => { :something => "not used" }, # Not set as tag + :tag1 => "value1", + :tag2 => "value2" + } + with_rails_error_reporter do + Rails.error.handle(:context => given_context) { raise ExampleStandardError } + end end - expect(last_transaction).to include_tags( - "reported_by" => "rails_error_reporter", - "tag1" => "value1", - "tag2" => "value2", - "severity" => "warning" - ) + it "in agent mode", :agent_mode do + start_agent + perform + + expect(last_transaction).to include_tags( + "reported_by" => "rails_error_reporter", + "tag1" => "value1", + "tag2" => "value2", + "severity" => "warning" + ) + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + + expect(root_span.attributes["appsignal.tag.reported_by"]) + .to eq("rails_error_reporter") + expect(root_span.attributes["appsignal.tag.tag1"]).to eq("value1") + expect(root_span.attributes["appsignal.tag.tag2"]).to eq("value2") + expect(root_span.attributes["appsignal.tag.severity"]).to eq("warning") + end end end end diff --git a/spec/lib/appsignal/integrations/resque_spec.rb b/spec/lib/appsignal/integrations/resque_spec.rb index 16208e081..638f074e7 100644 --- a/spec/lib/appsignal/integrations/resque_spec.rb +++ b/spec/lib/appsignal/integrations/resque_spec.rb @@ -2,18 +2,12 @@ if DependencyHelper.resque_present? describe Appsignal::Integrations::ResqueIntegration do - def perform_rescue_job(klass, options = {}) - payload = { "class" => klass.to_s }.merge(options) - job = ::Resque::Job.new(queue, payload) - keep_transactions { job.perform } - end - let(:queue) { "default" } let(:namespace) { Appsignal::Transaction::BACKGROUND_JOB } let(:options) { {} } - before do - start_agent(:options => options) + let(:start_agent_args) { { :options => options } } + before do stub_const("ResqueTestJob", Class.new do def self.perform(*_args) end @@ -24,32 +18,113 @@ def self.perform raise "resque job error" end end) + end - expect(Appsignal).to receive(:stop) # Resque calls stop after every job + def perform_rescue_job(klass, job_options = {}) + payload = { "class" => klass.to_s }.merge(job_options) + job = ::Resque::Job.new(queue, payload) + keep_transactions { job.perform } end - around do |example| - keep_transactions { example.run } + + describe "tracks a transaction on perform" do + def perform + perform_rescue_job(ResqueTestJob) + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + expect(Appsignal).to receive(:stop) + perform + + transaction = last_transaction + expect(transaction).to have_id + expect(transaction).to have_namespace(namespace) + expect(transaction).to have_action("ResqueTestJob#perform") + expect(transaction).to_not have_error + expect(transaction).to_not include_metadata + expect(transaction).to_not include_breadcrumbs + expect(transaction).to include_tags("queue" => queue) + expect(transaction).to include_event("name" => "perform.resque") + end + + it "in collector mode", :collector_mode do + start_collector_agent + expect(Appsignal).to receive(:stop) + perform + + expect(root_span.kind).to eq(:consumer) + expect(root_span.attributes["messaging.system"]).to eq("resque") + expect(root_span.attributes["messaging.operation.name"]).to eq("perform") + expect(root_span.attributes["messaging.operation.type"]).to eq("process") + expect(root_span.attributes["messaging.destination.name"]).to eq("default") + expect(root_span.attributes["appsignal.namespace"]).to eq("background") + expect(root_span.attributes["appsignal.action_name"]).to eq("ResqueTestJob#perform") + expect(exception_events).to be_empty + expect(root_span.attributes).to_not have_key("appsignal.tag.metadata_key") + expect(root_span.attributes["appsignal.tag.queue"]).to eq(queue) + span = event_spans.find { |s| s.name == "perform.resque" } + expect(span.attributes["messaging.system"]).to eq("resque") + expect(span.attributes["messaging.operation.name"]).to eq("perform") + expect(span.attributes["messaging.operation.type"]).to eq("process") + expect(span.attributes["messaging.destination.name"]).to eq("default") + expect(span).not_to be_nil + expect(span.parent_span_id).to eq(root_span.span_id) + expect(scope_of(root_span)).to eq(["appsignal-ruby/resque", Appsignal::VERSION]) + expect(scope_of(span)).to eq(["appsignal-ruby/resque", Appsignal::VERSION]) + end end - it "tracks a transaction on perform" do - perform_rescue_job(ResqueTestJob) - - transaction = last_transaction - expect(transaction).to have_id - expect(transaction).to have_namespace(namespace) - expect(transaction).to have_action("ResqueTestJob#perform") - expect(transaction).to_not have_error - expect(transaction).to_not include_metadata - expect(transaction).to_not include_breadcrumbs - expect(transaction).to include_tags("queue" => queue) - expect(transaction).to include_event("name" => "perform.resque") + describe "with incoming trace context" do + let(:trace_id_hex) { "0af7651916cd43dd8448eb211c80319c" } + let(:span_id_hex) { "b7ad6b7169203331" } + let(:traceparent) { "00-#{trace_id_hex}-#{span_id_hex}-01" } + + def perform + perform_rescue_job(ResqueTestJob, "traceparent" => traceparent) + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + expect(Appsignal).to receive(:stop) + perform + + # The trace header doesn't leak into the transaction as metadata or tags. + transaction = last_transaction + expect(transaction).to_not include_metadata + expect(transaction).to include_tags("queue" => queue) + expect(transaction).to_not include_tags("traceparent" => traceparent) + end + + it "in collector mode", :collector_mode do + start_collector_agent + expect(Appsignal).to receive(:stop) + perform + + # The job continues the enqueuer's trace as a child and links back to it. + expect(root_span.kind).to eq(:consumer) + expect(root_span.hex_trace_id).to eq(trace_id_hex) + expect(root_span.parent_span_id.unpack1("H*")).to eq(span_id_hex) + expect(root_span.links.size).to eq(1) + link_context = root_span.links.first.span_context + expect(link_context.hex_trace_id).to eq(trace_id_hex) + expect(link_context.hex_span_id).to eq(span_id_hex) + + # The trace header doesn't leak into the trace as a tag. + expect(root_span.attributes).to_not have_key("appsignal.tag.traceparent") + end end - context "with error" do - it "tracks the error on the transaction" do + describe "tracks the error on the transaction" do + def perform expect do perform_rescue_job(ResqueErrorTestJob) end.to raise_error(RuntimeError, "resque job error") + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + expect(Appsignal).to receive(:stop) + perform transaction = last_transaction expect(transaction).to have_id @@ -61,12 +136,35 @@ def self.perform expect(transaction).to include_tags("queue" => queue) expect(transaction).to include_event("name" => "perform.resque") end + + it "in collector mode", :collector_mode do + start_collector_agent + expect(Appsignal).to receive(:stop) + perform + + expect(root_span.kind).to eq(:consumer) + expect(root_span.attributes["appsignal.namespace"]).to eq("background") + expect(root_span.attributes["appsignal.action_name"]).to eq("ResqueErrorTestJob#perform") + event = root_span.events.find { |e| e.name == "exception" } + expect(event).not_to be_nil + expect(event.attributes["exception.type"]).to eq("RuntimeError") + expect(event.attributes["exception.message"]).to eq("resque job error") + expect(event.attributes["exception.stacktrace"]).to be_a(String) + expect(event.attributes["appsignal.alert_this_error"]).to eq(true) + expect(root_span.status.code).to eq(::OpenTelemetry::Trace::Status::ERROR) + expect(root_span.attributes["appsignal.tag.queue"]).to eq(queue) + span = event_spans.find { |s| s.name == "perform.resque" } + expect(span).not_to be_nil + expect(span.parent_span_id).to eq(root_span.span_id) + expect(scope_of(root_span)).to eq(["appsignal-ruby/resque", Appsignal::VERSION]) + expect(scope_of(span)).to eq(["appsignal-ruby/resque", Appsignal::VERSION]) + end end - context "with arguments" do + describe "filters out configured arguments" do let(:options) { { :filter_parameters => ["foo"] } } - it "filters out configured arguments" do + def perform perform_rescue_job( ResqueTestJob, "args" => [ @@ -78,6 +176,12 @@ def self.perform } ] ) + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + expect(Appsignal).to receive(:stop) + perform transaction = last_transaction expect(transaction).to have_id @@ -99,9 +203,182 @@ def self.perform ] ) end + + it "in collector mode", :collector_mode do + start_collector_agent + expect(Appsignal).to receive(:stop) + perform + + expect(root_span.attributes["appsignal.namespace"]).to eq("background") + expect(root_span.attributes["appsignal.action_name"]).to eq("ResqueTestJob#perform") + expect(exception_events).to be_empty + expect(root_span.attributes["appsignal.tag.queue"]).to eq(queue) + expect(event_spans.map(&:name)).to include("perform.resque") + params = JSON.parse(root_span.attributes["appsignal.function.parameters"]) + expect(params).to eq( + [ + "foo", + { + "foo" => "[FILTERED]", + "bar" => "Bar", + "baz" => { "1" => "foo" } + } + ] + ) + end end - context "with active job" do + describe Appsignal::Integrations::ResquePushIntegration do + # A stand-in for the `Resque` singleton with the integration prepended. + # Its `push` records the pushed item so we can inspect what was written. + let(:resque) do + Class.new do + attr_reader :pushed + + def push(queue, item) + @pushed = [queue, item] + :pushed + end + + prepend Appsignal::Integrations::ResquePushIntegration + end.new + end + let(:item) { { "class" => "ResqueTestJob", "args" => [] } } + + def enqueue + resque.push("default", item) + end + + context "with an active transaction" do + it "in agent mode", :agent_mode do + start_agent + transaction = http_request_transaction + set_current_transaction(transaction) + + expect(enqueue).to eq(:pushed) + + # Records an enqueue event on the transaction, titled after the job; + # no wire context in agent mode. + event = transaction.to_h["events"].find { |e| e["name"] == "enqueue.resque" } + expect(event).to_not be_nil + expect(event["title"]).to eq("enqueue ResqueTestJob job") + expect(item).to_not have_key("traceparent") + end + + it "in collector mode", :collector_mode do + start_collector_agent + transaction = http_request_transaction + set_current_transaction(transaction) + + expect(enqueue).to eq(:pushed) + Appsignal::Transaction.complete_current! + + # The enqueue is a producer event span under the active transaction, + # named after the job being enqueued. + producer = event_span_for("enqueue.resque") + expect(producer.name).to eq("enqueue.resque (enqueue ResqueTestJob job)") + expect(scope_of(producer)).to eq(["appsignal-ruby/resque", Appsignal::VERSION]) + expect(producer.kind).to eq(:producer) + expect(producer.attributes["messaging.system"]).to eq("resque") + expect(producer.attributes["messaging.operation.name"]).to eq("enqueue") + expect(producer.attributes["messaging.operation.type"]).to eq("send") + expect(producer.attributes["messaging.destination.name"]).to eq("default") + expect(producer.parent_span_id).to eq(root_span.span_id) + + # The job carries the producer span's trace context, so the job that + # performs can link back to it. + expect(item["traceparent"]) + .to eq("00-#{producer.hex_trace_id}-#{producer.hex_span_id}-01") + end + end + + context "without an active transaction" do + it "in agent mode", :agent_mode do + start_agent + + # A transparent pass-through: the job hash is untouched. + expect(enqueue).to eq(:pushed) + expect(item).to_not have_key("traceparent") + end + + it "in collector mode", :collector_mode do + start_collector_agent + + # No transaction to attach the event to, so nothing is emitted and the + # job hash is untouched. + expect(enqueue).to eq(:pushed) + expect(event_spans_for("enqueue.resque")).to be_empty + expect(item).to_not have_key("traceparent") + end + end + + context "when job enqueue events are suppressed" do + # As happens under Active Job, which records the enqueue itself. + def enqueue_suppressed(transaction) + transaction.suppress_job_enqueue_events { enqueue } + end + + it "in agent mode", :agent_mode do + start_agent + transaction = http_request_transaction + set_current_transaction(transaction) + + expect(enqueue_suppressed(transaction)).to eq(:pushed) + + # The outer integration records the enqueue, so this one doesn't. + event_names = transaction.to_h["events"].map { |event| event["name"] } + expect(event_names).to_not include("enqueue.resque") + end + + it "in collector mode", :collector_mode do + start_collector_agent + transaction = http_request_transaction + set_current_transaction(transaction) + + expect(enqueue_suppressed(transaction)).to eq(:pushed) + Appsignal::Transaction.complete_current! + + # No producer span for the suppressed enqueue... + expect(event_spans_for("enqueue.resque")).to be_empty + # ...but the trace context is still injected so the job links back. + expect(item).to have_key("traceparent") + end + end + + context "when enqueue instrumentation is disabled" do + let(:start_agent_args) do + { :options => { :enable_job_enqueue_instrumentation => false } } + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + transaction = http_request_transaction + set_current_transaction(transaction) + + expect(enqueue).to eq(:pushed) + + event_names = transaction.to_h["events"].map { |event| event["name"] } + expect(event_names).to_not include("enqueue.resque") + end + + it "in collector mode", :collector_mode do + start_collector_agent + transaction = http_request_transaction + set_current_transaction(transaction) + + expect(enqueue).to eq(:pushed) + Appsignal::Transaction.complete_current! + + # No enqueue event means no producer span, so there is nothing for the + # performing job to link back to and no trace context is written. The + # job starts its own trace instead of linking to the web request. + expect(event_spans_for("enqueue.resque")).to be_empty + expect(item).to_not have_key("traceparent") + end + end + end + + describe "does not set arguments for ActiveJob" do before do stub_const("ActiveJob::QueueAdapters::ResqueAdapter::JobWrapper", Class.new do class << self @@ -114,7 +391,7 @@ def perform(job_data) end) end - it "does not set arguments but lets the ActiveJob integration handle it" do + def perform perform_rescue_job( ResqueTestJob, "class" => "ActiveJob::QueueAdapters::ResqueAdapter::JobWrapper", @@ -125,6 +402,12 @@ def perform(job_data) } ] ) + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + expect(Appsignal).to receive(:stop) + perform transaction = last_transaction expect(transaction).to have_id @@ -137,67 +420,19 @@ def perform(job_data) expect(transaction).to include_event("name" => "perform.resque") expect(transaction).to_not include_params end - end - end - - describe Appsignal::Integrations::ResquePushIntegration do - # A stand-in for the `Resque` singleton with the integration prepended. - # Its `push` records the pushed item so we can inspect what was written. - let(:resque) do - Class.new do - attr_reader :pushed - - def push(queue, item) - @pushed = [queue, item] - :pushed - end - - prepend Appsignal::Integrations::ResquePushIntegration - end.new - end - let(:item) { { "class" => "ResqueTestJob", "args" => [] } } - - before { start_agent } - around { |example| keep_transactions { example.run } } - - def enqueue - resque.push("default", item) - end - - context "with an active transaction" do - it "records an enqueue event and leaves the job untouched" do - transaction = http_request_transaction - set_current_transaction(transaction) - - expect(enqueue).to eq(:pushed) - - # Records an enqueue event on the transaction, titled after the job. - event = transaction.to_h["events"].find { |e| e["name"] == "enqueue.resque" } - expect(event).to_not be_nil - expect(event["title"]).to eq("enqueue ResqueTestJob job") - expect(item).to eq("class" => "ResqueTestJob", "args" => []) - end - end - - context "without an active transaction" do - it "is a transparent pass-through" do - expect(enqueue).to eq(:pushed) - expect(item).to eq("class" => "ResqueTestJob", "args" => []) - end - end - - context "when job enqueue events are suppressed" do - # As happens under Active Job, which records the enqueue itself. - it "passes through without recording the enqueue" do - transaction = http_request_transaction - set_current_transaction(transaction) - result = transaction.suppress_job_enqueue_events { enqueue } - expect(result).to eq(:pushed) + it "in collector mode", :collector_mode do + start_collector_agent + expect(Appsignal).to receive(:stop) + perform - # The outer integration records the enqueue, so this one doesn't. - event_names = transaction.to_h["events"].map { |event| event["name"] } - expect(event_names).to_not include("enqueue.resque") + expect(root_span.attributes["appsignal.namespace"]).to eq("background") + expect(root_span.attributes["appsignal.action_name"]) + .to eq("ResqueTestJobByActiveJob#perform") + expect(exception_events).to be_empty + expect(root_span.attributes["appsignal.tag.queue"]).to eq(queue) + expect(event_spans.map(&:name)).to include("perform.resque") + expect(root_span.attributes).to_not have_key("appsignal.function.parameters") end end end diff --git a/spec/lib/appsignal/integrations/shoryuken_client_spec.rb b/spec/lib/appsignal/integrations/shoryuken_client_spec.rb index e425318c9..79bbf41b7 100644 --- a/spec/lib/appsignal/integrations/shoryuken_client_spec.rb +++ b/spec/lib/appsignal/integrations/shoryuken_client_spec.rb @@ -3,16 +3,12 @@ require "appsignal/integrations/shoryuken" # Integration test against the real Shoryuken gem and a stubbed AWS SQS client - # (the shoryuken_spec.rb suite drives the middleware with doubles). Verifies, - # end to end, that the hook registers the client middleware on the real send - # path and that an enqueue records an `enqueue.shoryuken` event -- the - # registration the doubled suite can't prove. + # (the shoryuken_spec.rb suite drives the middleware with doubles). Verifies, end + # to end, that the hook registers the client middleware on the real send path and + # that it writes trace context onto a live outgoing message -- the registration + # the doubled suite can't prove. describe "Shoryuken client integration" do - before do - start_agent - Appsignal::Hooks::ShoryukenHook.new.install - end - around { |example| keep_transactions { example.run } } + before { Appsignal::Hooks::ShoryukenHook.new.install } after do ::Shoryuken.client_middleware.remove(Appsignal::Integrations::ShoryukenClientMiddleware) @@ -29,14 +25,54 @@ end let(:queue) { Shoryuken::Queue.new(sqs_client, "test-queue") } - it "records an enqueue event through the real send path" do + # Sends a real message through Shoryuken's send path and returns the params + # the SQS client was called with. + def send_message + sent = nil + allow(sqs_client).to receive(:send_message).and_wrap_original do |original, params| + sent = params + original.call(params) + end + queue.send_message(:message_body => "foo") + sent + end + + it "in agent mode", :agent_mode do + start_agent transaction = http_request_transaction set_current_transaction(transaction) - queue.send_message(:message_body => "foo") + sent = send_message event_names = transaction.to_h["events"].map { |event| event["name"] } expect(event_names).to include("enqueue.shoryuken") + expect(sent).to_not have_key(:message_attributes) + end + + it "in collector mode", :collector_mode do + start_collector_agent + transaction = http_request_transaction + set_current_transaction(transaction) + + sent = send_message + Appsignal::Transaction.complete_current! + + # A raw `send_message` has no worker class, so the title names the queue. + producer = event_span_for("enqueue.shoryuken") + expect(producer.name).to eq("enqueue.shoryuken (enqueue on test-queue)") + expect(producer.kind).to eq(:producer) + expect(producer.attributes["messaging.system"]).to eq("aws_sqs") + expect(producer.attributes["messaging.operation.name"]).to eq("enqueue") + expect(producer.attributes["messaging.operation.type"]).to eq("send") + expect(producer.attributes["messaging.destination.name"]).to eq("test-queue") + + # The middleware the hook registered injected the producer span's trace + # context onto the real outgoing message, wire-equivalent to OpenTelemetry's + # aws-sdk instrumentation. + expect(sent[:message_attributes]["traceparent"]).to eq( + :string_value => "00-#{producer.hex_trace_id}-#{producer.hex_span_id}-01", + :data_type => "String" + ) end end end diff --git a/spec/lib/appsignal/integrations/shoryuken_spec.rb b/spec/lib/appsignal/integrations/shoryuken_spec.rb index bcf1ad9d1..1e1d9d642 100644 --- a/spec/lib/appsignal/integrations/shoryuken_spec.rb +++ b/spec/lib/appsignal/integrations/shoryuken_spec.rb @@ -7,11 +7,15 @@ class DemoShoryukenWorker let(:time) { "2010-01-01 10:01:00UTC" } let(:worker_instance) { DemoShoryukenWorker.new } let(:queue) { "some-funky-queue-name" } - let(:sqs_msg) { double(:message_id => "msg1", :attributes => {}) } + let(:sqs_msg) { double(:message_id => "msg1", :attributes => {}, :message_attributes => {}) } let(:body) { {} } let(:options) { {} } - before { start_agent(:options => options) } - around { |example| keep_transactions { example.run } } + + # Pass the example's options through to the mode contexts' `start_agent`. In + # collector mode `start_collector_agent` merges these on top of the + # `collector_endpoint`, so options like `:filter_parameters` apply in both + # modes. + let(:start_agent_args) { { :options => options } } def perform_shoryuken_job(&block) block ||= lambda {} @@ -27,46 +31,113 @@ def perform_shoryuken_job(&block) end context "with a performance call" do - let(:sent_timestamp) { Time.parse("1976-11-18 0:00:00UTC").to_i * 1000 } + let(:sent_timestamp) { Time.parse("2024-11-18 0:00:00UTC").to_i * 1000 } let(:sqs_msg) do - double(:message_id => "msg1", :attributes => { "SentTimestamp" => sent_timestamp }) + double( + :message_id => "msg1", + :attributes => { "SentTimestamp" => sent_timestamp }, + :message_attributes => {} + ) end context "with complex argument" do let(:body) { { :foo => "Foo", :bar => "Bar" } } - it "wraps the job in a transaction with the correct params" do - expect { perform_shoryuken_job }.to change { created_transactions.length }.by(1) + describe "wraps the job in a transaction" do + def perform + perform_shoryuken_job + end - transaction = last_transaction - expect(transaction).to have_id - expect(transaction).to have_namespace(Appsignal::Transaction::BACKGROUND_JOB) - expect(transaction).to have_action("DemoShoryukenWorker#perform") - expect(transaction).to_not have_error - expect(transaction).to include_event( - "body" => "", - "body_format" => Appsignal::EventFormatter::DEFAULT, - "count" => 1, - "name" => "perform_job.shoryuken", - "title" => "" - ) - expect(transaction).to include_params("foo" => "Foo", "bar" => "Bar") - expect(transaction).to include_tags( - "message_id" => "msg1", - "queue" => queue, - "SentTimestamp" => sent_timestamp - ) - expect(transaction).to have_queue_start(sent_timestamp) - expect(transaction).to be_completed + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + expect { perform }.to change { created_transactions.length }.by(1) + + transaction = last_transaction + expect(transaction).to have_id + expect(transaction).to have_namespace(Appsignal::Transaction::BACKGROUND_JOB) + expect(transaction).to have_action("DemoShoryukenWorker#perform") + expect(transaction).to_not have_error + expect(transaction).to include_event( + "body" => "", + "body_format" => Appsignal::EventFormatter::DEFAULT, + "count" => 1, + "name" => "perform_job.shoryuken", + "title" => "" + ) + expect(transaction).to include_params("foo" => "Foo", "bar" => "Bar") + expect(transaction).to include_tags( + "message_id" => "msg1", + "queue" => queue, + "SentTimestamp" => sent_timestamp + ) + expect(transaction).to have_queue_start(sent_timestamp) + expect(transaction).to be_completed + end + + it "in collector mode", :collector_mode do + start_collector_agent + expect { perform }.to change { created_transactions.length }.by(1) + + expect(root_span.kind).to eq(:consumer) + expect(root_span.attributes["messaging.system"]).to eq("aws_sqs") + expect(root_span.attributes["messaging.operation.name"]).to eq("perform") + expect(root_span.attributes["messaging.operation.type"]).to eq("process") + expect(root_span.attributes["messaging.destination.name"]).to eq("some-funky-queue-name") + expect(root_span.attributes["appsignal.namespace"]) + .to eq("background") + expect(root_span.name).to eq("DemoShoryukenWorker#perform") + expect(root_span.attributes["appsignal.action_name"]) + .to eq("DemoShoryukenWorker#perform") + expect(exception_events).to be_empty + span = event_spans.find { |s| s.name == "perform_job.shoryuken" } + expect(span.attributes["messaging.system"]).to eq("aws_sqs") + expect(span.attributes["messaging.operation.name"]).to eq("perform") + expect(span.attributes["messaging.operation.type"]).to eq("process") + expect(span.attributes["messaging.destination.name"]).to eq("some-funky-queue-name") + # A single message is not a batch, so the conventions say a span for + # one must not carry a message count. + expect(root_span.attributes).to_not have_key("messaging.batch.message_count") + expect(span.attributes).to_not have_key("messaging.batch.message_count") + expect(span).not_to be_nil + expect(span.parent_span_id).to eq(root_span.span_id) + expect(span.attributes).not_to have_key("appsignal.body") + expect(event_category(span)).to eq("perform_job.shoryuken") + expect(scope_of(root_span)).to eq(["appsignal-ruby/shoryuken", Appsignal::VERSION]) + expect(scope_of(span)).to eq(["appsignal-ruby/shoryuken", Appsignal::VERSION]) + expect(JSON.parse(root_span.attributes["appsignal.function.parameters"])) + .to eq("foo" => "Foo", "bar" => "Bar") + expect(root_span.attributes["appsignal.tag.message_id"]).to eq("msg1") + expect(root_span.attributes["appsignal.tag.queue"]).to eq(queue) + expect(root_span.attributes["appsignal.tag.SentTimestamp"]).to eq(sent_timestamp) + queue_event = Array(root_span.events).find { |e| e.name == "appsignal.queue_start" } + expect(queue_event.timestamp) + .to eq((Time.at(sent_timestamp / 1000.0).to_r * 1_000_000_000).to_i) + expect(last_transaction).to be_completed + end end context "with parameter filtering" do let(:options) { { :filter_parameters => ["foo"] } } - it "filters selected arguments" do - perform_shoryuken_job + describe "filters selected arguments" do + def perform + perform_shoryuken_job + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + + expect(last_transaction).to include_params("foo" => "[FILTERED]", "bar" => "Bar") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform - expect(last_transaction).to include_params("foo" => "[FILTERED]", "bar" => "Bar") + expect(JSON.parse(root_span.attributes["appsignal.function.parameters"])) + .to eq("foo" => "[FILTERED]", "bar" => "Bar") + end end end end @@ -74,38 +145,140 @@ def perform_shoryuken_job(&block) context "with a string as an argument" do let(:body) { "foo bar" } - it "handles string arguments" do - perform_shoryuken_job + describe "handles string arguments" do + def perform + perform_shoryuken_job + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform - expect(last_transaction).to include_params("params" => body) + expect(last_transaction).to include_params("params" => body) + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + + expect(JSON.parse(root_span.attributes["appsignal.function.parameters"])) + .to eq("params" => body) + end end end context "with primitive type as argument" do let(:body) { 1 } - it "handles primitive types as arguments" do + describe "handles primitive types as arguments" do + def perform + perform_shoryuken_job + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + + expect(last_transaction).to include_params("params" => body) + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + + expect(JSON.parse(root_span.attributes["appsignal.function.parameters"])) + .to eq("params" => body) + end + end + end + end + + context "with incoming trace context" do + let(:trace_id_hex) { "0af7651916cd43dd8448eb211c80319c" } + let(:span_id_hex) { "b7ad6b7169203331" } + let(:traceparent) { "00-#{trace_id_hex}-#{span_id_hex}-01" } + let(:sqs_msg) do + double( + :message_id => "msg1", + :attributes => {}, + :message_attributes => { + "traceparent" => { :string_value => traceparent, :data_type => "String" } + } + ) + end + + describe "links the transaction back to the enqueuer" do + def perform perform_shoryuken_job + end - expect(last_transaction).to include_params("params" => body) + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + + # The trace header doesn't leak into the transaction as a tag. + expect(last_transaction).to_not include_tags("traceparent" => traceparent) + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + + # The job continues the enqueuer's trace as a child and links back to it. + expect(root_span.kind).to eq(:consumer) + expect(root_span.hex_trace_id).to eq(trace_id_hex) + expect(root_span.parent_span_id.unpack1("H*")).to eq(span_id_hex) + expect(root_span.links.size).to eq(1) + link_context = root_span.links.first.span_context + expect(link_context.hex_trace_id).to eq(trace_id_hex) + expect(link_context.hex_span_id).to eq(span_id_hex) + + # The trace header doesn't leak into the trace as a tag. + expect(root_span.attributes).to_not have_key("appsignal.tag.traceparent") end end end context "with exception" do - it "sets the exception on the transaction" do - expect do + describe "sets the exception on the transaction" do + def perform + perform_shoryuken_job { raise ExampleException, "error message" } + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) expect do - perform_shoryuken_job { raise ExampleException, "error message" } - end.to raise_error(ExampleException) - end.to change { created_transactions.length }.by(1) + expect { perform }.to raise_error(ExampleException) + end.to change { created_transactions.length }.by(1) - transaction = last_transaction - expect(transaction).to have_id - expect(transaction).to have_action("DemoShoryukenWorker#perform") - expect(transaction).to have_namespace(Appsignal::Transaction::BACKGROUND_JOB) - expect(transaction).to have_error("ExampleException", "error message") - expect(transaction).to be_completed + transaction = last_transaction + expect(transaction).to have_id + expect(transaction).to have_action("DemoShoryukenWorker#perform") + expect(transaction).to have_namespace(Appsignal::Transaction::BACKGROUND_JOB) + expect(transaction).to have_error("ExampleException", "error message") + expect(transaction).to be_completed + end + + it "in collector mode", :collector_mode do + start_collector_agent + expect do + expect { perform }.to raise_error(ExampleException) + end.to change { created_transactions.length }.by(1) + + expect(root_span.kind).to eq(:consumer) + expect(root_span.attributes["appsignal.action_name"]) + .to eq("DemoShoryukenWorker#perform") + expect(root_span.attributes["appsignal.namespace"]) + .to eq("background") + + error_event = exception_events + .find { |e| e.attributes["exception.type"] == "ExampleException" } + expect(error_event).not_to be_nil + expect(error_event.attributes["exception.message"]).to eq("error message") + expect(error_event.attributes["exception.stacktrace"]).to be_a(String) + expect(error_event.attributes["appsignal.alert_this_error"]).to eq(true) + expect(last_transaction).to be_completed + end end end @@ -115,7 +288,7 @@ def perform_shoryuken_job(&block) double( :message_id => "msg2", :attributes => { - "SentTimestamp" => (Time.parse("1976-11-18 01:00:00UTC").to_i * 1000).to_s + "SentTimestamp" => (Time.parse("2024-11-18 01:00:00UTC").to_i * 1000).to_s } ), double( @@ -130,44 +303,84 @@ def perform_shoryuken_job(&block) { :id => "123", :foo => "Foo", :bar => "Bar" } ] end - let(:sent_timestamp) { Time.parse("1976-11-18 01:00:00UTC").to_i * 1000 } + let(:sent_timestamp) { Time.parse("2024-11-18 01:00:00UTC").to_i * 1000 } - it "creates a transaction for the batch" do - expect do + describe "creates a transaction for the batch" do + def perform perform_shoryuken_job {} # rubocop:disable Lint/EmptyBlock - end.to change { created_transactions.length }.by(1) - - transaction = last_transaction - expect(transaction).to have_id - expect(transaction).to have_action("DemoShoryukenWorker#perform") - expect(transaction).to have_namespace(Appsignal::Transaction::BACKGROUND_JOB) - expect(transaction).to_not have_error - expect(transaction).to include_event( - "body" => "", - "body_format" => Appsignal::EventFormatter::DEFAULT, - "count" => 1, - "name" => "perform_job.shoryuken", - "title" => "" - ) - expect(transaction).to include_params( - "msg2" => "foo bar", - "msg1" => { "id" => "123", "foo" => "Foo", "bar" => "Bar" } - ) - expect(transaction).to include_tags( - "batch" => true, - "queue" => "some-funky-queue-name", - "SentTimestamp" => sent_timestamp.to_s # Earliest/oldest timestamp from messages - ) - # Queue time based on earliest/oldest timestamp from messages - expect(transaction).to have_queue_start(sent_timestamp) + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + expect { perform }.to change { created_transactions.length }.by(1) + + transaction = last_transaction + expect(transaction).to have_id + expect(transaction).to have_action("DemoShoryukenWorker#perform") + expect(transaction).to have_namespace(Appsignal::Transaction::BACKGROUND_JOB) + expect(transaction).to_not have_error + expect(transaction).to include_event( + "body" => "", + "body_format" => Appsignal::EventFormatter::DEFAULT, + "count" => 1, + "name" => "perform_job.shoryuken", + "title" => "" + ) + expect(transaction).to include_params( + "msg2" => "foo bar", + "msg1" => { "id" => "123", "foo" => "Foo", "bar" => "Bar" } + ) + expect(transaction).to include_tags( + "batch" => true, + "queue" => "some-funky-queue-name", + "SentTimestamp" => sent_timestamp.to_s # Earliest/oldest timestamp from messages + ) + # Queue time based on earliest/oldest timestamp from messages + expect(transaction).to have_queue_start(sent_timestamp) + end + + it "in collector mode", :collector_mode do + start_collector_agent + expect { perform }.to change { created_transactions.length }.by(1) + + expect(root_span.kind).to eq(:consumer) + expect(root_span.name).to eq("DemoShoryukenWorker#perform") + expect(root_span.attributes["appsignal.action_name"]) + .to eq("DemoShoryukenWorker#perform") + expect(root_span.attributes["appsignal.namespace"]) + .to eq("background") + expect(exception_events).to be_empty + span = event_spans.find { |s| s.name == "perform_job.shoryuken" } + expect(span).not_to be_nil + expect(span.parent_span_id).to eq(root_span.span_id) + expect(span.attributes).not_to have_key("appsignal.body") + expect(event_category(span)).to eq("perform_job.shoryuken") + expect(JSON.parse(root_span.attributes["appsignal.function.parameters"])) + .to eq( + "msg2" => "foo bar", + "msg1" => { "id" => "123", "foo" => "Foo", "bar" => "Bar" } + ) + expect(root_span.attributes["appsignal.tag.batch"]).to eq(true) + expect(root_span.attributes["appsignal.tag.queue"]).to eq("some-funky-queue-name") + # This call covers a batch, so it says how many messages are in it. + expect(root_span.attributes["messaging.batch.message_count"]).to eq(2) + expect(span.attributes["messaging.batch.message_count"]).to eq(2) + # Earliest/oldest timestamp from messages + expect(root_span.attributes["appsignal.tag.SentTimestamp"]) + .to eq(sent_timestamp.to_s) + queue_event = Array(root_span.events).find { |e| e.name == "appsignal.queue_start" } + expect(queue_event.timestamp) + .to eq((Time.at(sent_timestamp / 1000.0).to_r * 1_000_000_000).to_i) + + # A batch carries messages from multiple traces, so it is not linked back. + expect(Array(root_span.links)).to be_empty + end end end end describe Appsignal::Integrations::ShoryukenClientMiddleware do let(:options) { { :message_body => "foo" } } - before { start_agent } - around { |example| keep_transactions { example.run } } def enqueue(&block) block ||= lambda {} @@ -175,9 +388,16 @@ def enqueue(&block) end context "with an active transaction" do + def perform + transaction = http_request_transaction + set_current_transaction(transaction) + enqueue + transaction + end + # Enqueuing through a Shoryuken worker carries the worker class in the # `shoryuken_class` message attribute, so the event is titled after it. - context "enqueued through a worker" do + describe "enqueued through a worker" do let(:options) do { :message_body => "foo", @@ -188,55 +408,155 @@ def enqueue(&block) } end - it "records the enqueue under the transaction, titled after the worker" do - transaction = http_request_transaction - set_current_transaction(transaction) - - enqueue + it "in agent mode", :agent_mode do + start_agent + transaction = perform + # Records an enqueue event on the transaction, titled after the worker; + # no wire context in agent mode. event = transaction.to_h["events"].find { |e| e["name"] == "enqueue.shoryuken" } expect(event).to_not be_nil expect(event["title"]).to eq("enqueue MyShoryukenWorker job") + expect(options[:message_attributes]).to_not have_key("traceparent") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + Appsignal::Transaction.complete_current! + + # The enqueue is a producer event span under the active transaction, + # named after the worker being enqueued. + producer = event_span_for("enqueue.shoryuken") + expect(producer.name).to eq("enqueue.shoryuken (enqueue MyShoryukenWorker job)") + expect(scope_of(producer)).to eq(["appsignal-ruby/shoryuken", Appsignal::VERSION]) + expect(producer.kind).to eq(:producer) + expect(producer.attributes["messaging.system"]).to eq("aws_sqs") + expect(producer.attributes["messaging.operation.name"]).to eq("enqueue") + expect(producer.attributes["messaging.operation.type"]).to eq("send") + expect(producer.attributes["messaging.destination.name"]).to eq("my-queue") + expect(producer.parent_span_id).to eq(root_span.span_id) + + # The message carries the producer span's trace context as an SQS message + # attribute, wire-equivalent to OpenTelemetry's aws-sdk instrumentation. + expect(options[:message_attributes]["traceparent"]).to eq( + :string_value => "00-#{producer.hex_trace_id}-#{producer.hex_span_id}-01", + :data_type => "String" + ) end end # A raw `send_message` enqueue has no worker class, so the event falls back # to naming the queue it was sent to. - context "enqueued as a raw message" do + describe "enqueued as a raw message" do let(:options) do { :message_body => "foo", :queue_url => "https://sqs.us-east-1.amazonaws.com/0/my-queue" } end - it "records the enqueue under the transaction, titled after the queue" do - transaction = http_request_transaction - set_current_transaction(transaction) - - enqueue + it "in agent mode", :agent_mode do + start_agent + transaction = perform event = transaction.to_h["events"].find { |e| e["name"] == "enqueue.shoryuken" } expect(event).to_not be_nil expect(event["title"]).to eq("enqueue on my-queue") end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + Appsignal::Transaction.complete_current! + + producer = event_span_for("enqueue.shoryuken") + expect(producer).to_not be_nil + expect(producer.name).to eq("enqueue.shoryuken (enqueue on my-queue)") + end end end context "without an active transaction" do - it "passes through without recording" do - expect { |block| enqueue(&block) }.to yield_control + describe "passes through without recording or injecting" do + it "in agent mode", :agent_mode do + start_agent + + enqueue + expect(options).to_not have_key(:message_attributes) + end + + it "in collector mode", :collector_mode do + start_collector_agent + + # No transaction to attach the event to, so nothing is emitted and the + # outgoing options are untouched. + enqueue + expect(event_spans_for("enqueue.shoryuken")).to be_empty + expect(options).to_not have_key(:message_attributes) + end end end context "when job enqueue events are suppressed" do # As happens under Active Job, which records the enqueue itself. - it "passes through without recording the enqueue" do + def enqueue_suppressed(transaction) + transaction.suppress_job_enqueue_events { enqueue } + end + + it "in agent mode", :agent_mode do + start_agent transaction = http_request_transaction set_current_transaction(transaction) - transaction.suppress_job_enqueue_events { enqueue } + enqueue_suppressed(transaction) # The outer integration records the enqueue, so this one doesn't. event_names = transaction.to_h["events"].map { |event| event["name"] } expect(event_names).to_not include("enqueue.shoryuken") end + + it "in collector mode", :collector_mode do + start_collector_agent + transaction = http_request_transaction + set_current_transaction(transaction) + + enqueue_suppressed(transaction) + Appsignal::Transaction.complete_current! + + # No producer span for the suppressed enqueue... + expect(event_spans_for("enqueue.shoryuken")).to be_empty + # ...but the trace context is still injected so the job links back. + expect(options[:message_attributes]).to have_key("traceparent") + end + end + + context "when enqueue instrumentation is disabled" do + let(:start_agent_args) do + { :options => { :enable_job_enqueue_instrumentation => false } } + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + transaction = http_request_transaction + set_current_transaction(transaction) + + enqueue + + event_names = transaction.to_h["events"].map { |event| event["name"] } + expect(event_names).to_not include("enqueue.shoryuken") + end + + it "in collector mode", :collector_mode do + start_collector_agent + transaction = http_request_transaction + set_current_transaction(transaction) + + enqueue + Appsignal::Transaction.complete_current! + + # No enqueue event means no producer span, so there is nothing for the + # performing job to link back to and no trace context is written. The job + # starts its own trace instead of linking to the web request. + expect(event_spans_for("enqueue.shoryuken")).to be_empty + expect(options).to_not have_key(:message_attributes) + end end end diff --git a/spec/lib/appsignal/integrations/sidekiq_spec.rb b/spec/lib/appsignal/integrations/sidekiq_spec.rb index 4ed5fe207..a6b7be961 100644 --- a/spec/lib/appsignal/integrations/sidekiq_spec.rb +++ b/spec/lib/appsignal/integrations/sidekiq_spec.rb @@ -3,10 +3,7 @@ describe Appsignal::Integrations::SidekiqDeathHandler do let(:options) { {} } - before do - stub_const("Sidekiq::VERSION", "7.1.0") - start_agent(:options => options) - end + let(:start_agent_args) { { :options => options } } around { |example| keep_transactions { example.run } } let(:exception) do @@ -16,53 +13,87 @@ end let(:job_context) { {} } let(:transaction) { http_request_transaction } - before { set_current_transaction(transaction) } - def call_handler + def perform + set_current_transaction(transaction) expect do described_class.new.call(job_context, exception) end.to_not(change { created_transactions.count }) end - def expect_error_on_transaction - expect(last_transaction).to have_error("ExampleStandardError", "uh oh") - end - - def expect_no_error_on_transaction - expect(last_transaction).to_not have_error - end - context "when sidekiq_report_errors = none" do let(:options) { { :sidekiq_report_errors => "none" } } - before { call_handler } - it "doesn't track the error on the transaction" do - expect_no_error_on_transaction + describe "doesn't track the error on the transaction" do + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + + expect(last_transaction).to_not have_error + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + expect(exception_events).to be_empty + end end end context "when sidekiq_report_errors = all" do let(:options) { { :sidekiq_report_errors => "all" } } - before { call_handler } - it "doesn't track the error on the transaction" do - expect_no_error_on_transaction + describe "doesn't track the error on the transaction" do + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + + expect(last_transaction).to_not have_error + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + expect(exception_events).to be_empty + end end end context "when sidekiq_report_errors = discard" do let(:options) { { :sidekiq_report_errors => "discard" } } - before { call_handler } - it "records each occurrence of the error on the transaction" do - expect_error_on_transaction + describe "records the error on the transaction" do + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + + expect(last_transaction).to have_error("ExampleStandardError", "uh oh") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + event = root_span.events.find { |e| e.name == "exception" } + expect(event).not_to be_nil + expect(event.attributes["exception.type"]).to eq("ExampleStandardError") + expect(event.attributes["exception.message"]).to eq("uh oh") + expect(event.attributes["exception.stacktrace"]).to be_a(String) + expect(event.attributes["appsignal.alert_this_error"]).to eq(true) + expect(root_span.status.code).to eq(::OpenTelemetry::Trace::Status::ERROR) + end end end end describe Appsignal::Integrations::SidekiqErrorHandler do let(:options) { {} } - before { start_agent(:options => options) } + let(:start_agent_args) { { :options => options } } around { |example| keep_transactions { example.run } } let(:exception) do @@ -79,43 +110,137 @@ def expect_no_error_on_transaction } end - def expect_report_internal_error + def perform expect do described_class.new.call(exception, job_context) end.to(change { created_transactions.count }.by(1)) - - transaction = last_transaction - expect(transaction).to have_action("SidekiqInternal") - expect(transaction).to have_error("ExampleStandardError", "uh oh") - expect(transaction).to include_params( - "jobstr" => "{ bad json }" - ) - expect(transaction).to include_metadata( - "sidekiq_error" => "Sidekiq internal error!" - ) end context "when sidekiq_report_errors = none" do let(:options) { { :sidekiq_report_errors => "none" } } - it "tracks the error on a new transaction" do - expect_report_internal_error + describe "tracks the error on a new transaction" do + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + + transaction = last_transaction + expect(transaction).to have_action("SidekiqInternal") + expect(transaction).to have_error("ExampleStandardError", "uh oh") + expect(transaction).to include_params( + "jobstr" => "{ bad json }" + ) + expect(transaction).to include_metadata( + "sidekiq_error" => "Sidekiq internal error!" + ) + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + + expect(root_span.kind).to eq(:consumer) + expect(root_span.attributes["messaging.system"]).to eq("sidekiq") + expect(root_span.attributes["messaging.operation.name"]).to eq("perform") + expect(root_span.attributes["messaging.operation.type"]).to eq("process") + # This transaction stands in for a job that could not be processed, + # so there is no job and no queue to name. + expect(root_span.attributes).to_not have_key("messaging.destination.name") + expect(scope_of(root_span)).to eq(["appsignal-ruby/sidekiq", Appsignal::VERSION]) + expect(root_span.attributes["appsignal.action_name"]) + .to eq("SidekiqInternal") + event = root_span.events.find { |e| e.name == "exception" } + expect(event).not_to be_nil + expect(event.attributes["exception.type"]).to eq("ExampleStandardError") + expect(event.attributes["exception.message"]).to eq("uh oh") + expect(event.attributes["exception.stacktrace"]).to be_a(String) + expect(event.attributes["appsignal.alert_this_error"]).to eq(true) + expect(root_span.status.code).to eq(::OpenTelemetry::Trace::Status::ERROR) + expect(JSON.parse(root_span.attributes["appsignal.function.parameters"])) + .to include("jobstr" => "{ bad json }") + expect(root_span.attributes["appsignal.tag.sidekiq_error"]) + .to eq("Sidekiq internal error!") + end end end context "when sidekiq_report_errors = all" do let(:options) { { :sidekiq_report_errors => "all" } } - it "tracks the error on a new transaction" do - expect_report_internal_error + describe "tracks the error on a new transaction" do + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + + transaction = last_transaction + expect(transaction).to have_action("SidekiqInternal") + expect(transaction).to have_error("ExampleStandardError", "uh oh") + expect(transaction).to include_params( + "jobstr" => "{ bad json }" + ) + expect(transaction).to include_metadata( + "sidekiq_error" => "Sidekiq internal error!" + ) + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + + expect(root_span.attributes["appsignal.action_name"]) + .to eq("SidekiqInternal") + event = root_span.events.find { |e| e.name == "exception" } + expect(event).not_to be_nil + expect(event.attributes["exception.type"]).to eq("ExampleStandardError") + expect(event.attributes["exception.message"]).to eq("uh oh") + expect(event.attributes["exception.stacktrace"]).to be_a(String) + expect(event.attributes["appsignal.alert_this_error"]).to eq(true) + expect(root_span.status.code).to eq(::OpenTelemetry::Trace::Status::ERROR) + expect(JSON.parse(root_span.attributes["appsignal.function.parameters"])) + .to include("jobstr" => "{ bad json }") + expect(root_span.attributes["appsignal.tag.sidekiq_error"]) + .to eq("Sidekiq internal error!") + end end end context "when sidekiq_report_errors = discard" do let(:options) { { :sidekiq_report_errors => "discard" } } - it "tracks the error on a new transaction" do - expect_report_internal_error + describe "tracks the error on a new transaction" do + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + + transaction = last_transaction + expect(transaction).to have_action("SidekiqInternal") + expect(transaction).to have_error("ExampleStandardError", "uh oh") + expect(transaction).to include_params( + "jobstr" => "{ bad json }" + ) + expect(transaction).to include_metadata( + "sidekiq_error" => "Sidekiq internal error!" + ) + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + + expect(root_span.attributes["appsignal.action_name"]) + .to eq("SidekiqInternal") + event = root_span.events.find { |e| e.name == "exception" } + expect(event).not_to be_nil + expect(event.attributes["exception.type"]).to eq("ExampleStandardError") + expect(event.attributes["exception.message"]).to eq("uh oh") + expect(event.attributes["exception.stacktrace"]).to be_a(String) + expect(event.attributes["appsignal.alert_this_error"]).to eq(true) + expect(root_span.status.code).to eq(::OpenTelemetry::Trace::Status::ERROR) + expect(JSON.parse(root_span.attributes["appsignal.function.parameters"])) + .to include("jobstr" => "{ bad json }") + expect(root_span.attributes["appsignal.tag.sidekiq_error"]) + .to eq("Sidekiq internal error!") + end end end end @@ -123,52 +248,84 @@ def expect_report_internal_error context "when error is a job error" do let(:sidekiq_context) { { :job => {} } } let(:transaction) { http_request_transaction } - before do + + def perform transaction.set_action("existing transaction action") set_current_transaction(transaction) - end - - def call_handler expect do described_class.new.call(exception, sidekiq_context) end.to_not(change { created_transactions.count }) end - def expect_error_on_transaction - expect(last_transaction).to have_error("ExampleStandardError", "uh oh") - end - - def expect_no_error_on_transaction - expect(last_transaction).to_not have_error - end - context "when sidekiq_report_errors = none" do let(:options) { { :sidekiq_report_errors => "none" } } - before { call_handler } - it "doesn't track the error on the transaction" do - expect_no_error_on_transaction - expect(last_transaction).to be_completed + describe "doesn't track the error and completes the transaction" do + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + + expect(last_transaction).to_not have_error + expect(last_transaction).to be_completed + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + + expect(exception_events).to be_empty + expect(transaction).to be_completed + end end end context "when sidekiq_report_errors = all" do let(:options) { { :sidekiq_report_errors => "all" } } - before { call_handler } - it "records each occurrence of the error on the transaction" do - expect_error_on_transaction - expect(last_transaction).to be_completed + describe "records the error and completes the transaction" do + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + + expect(last_transaction).to have_error("ExampleStandardError", "uh oh") + expect(last_transaction).to be_completed + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + + event = root_span.events.find { |e| e.name == "exception" } + expect(event).not_to be_nil + expect(event.attributes["exception.type"]).to eq("ExampleStandardError") + expect(event.attributes["exception.message"]).to eq("uh oh") + expect(event.attributes["exception.stacktrace"]).to be_a(String) + expect(event.attributes["appsignal.alert_this_error"]).to eq(true) + expect(root_span.status.code).to eq(::OpenTelemetry::Trace::Status::ERROR) + expect(transaction).to be_completed + end end end context "when sidekiq_report_errors = discard" do let(:options) { { :sidekiq_report_errors => "discard" } } - before { call_handler } - it "doesn't track the error on the transaction" do - expect_no_error_on_transaction - expect(last_transaction).to be_completed + describe "doesn't track the error and completes the transaction" do + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + + expect(last_transaction).to_not have_error + expect(last_transaction).to be_completed + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + + expect(exception_events).to be_empty + expect(transaction).to be_completed + end end end end @@ -176,25 +333,54 @@ def expect_no_error_on_transaction describe Appsignal::Integrations::SidekiqClientMiddleware do let(:plugin) { described_class.new } - let(:job) { { "class" => "TestClass", "args" => [] } } - before { start_agent } - around { |example| keep_transactions { example.run } } + # Sidekiq fills the queue in before it calls the client middleware, so the + # job hash the middleware is given always names one. + let(:job) { { "class" => "TestClass", "args" => [], "queue" => "default" } } def enqueue plugin.call("TestClass", job, "default", nil) { :enqueued } end context "with an active transaction" do - it "records the enqueue under the transaction" do + it "in agent mode", :agent_mode do + start_agent transaction = http_request_transaction set_current_transaction(transaction) expect(enqueue).to eq(:enqueued) - # Records an enqueue event on the transaction, titled after the job. + # Records an enqueue event on the transaction, titled after the job; + # no wire context in agent mode. event = transaction.to_h["events"].find { |e| e["name"] == "enqueue.sidekiq" } expect(event).to_not be_nil expect(event["title"]).to eq("enqueue TestClass job") + expect(job).to_not have_key("traceparent") + end + + it "in collector mode", :collector_mode do + start_collector_agent + transaction = http_request_transaction + set_current_transaction(transaction) + + expect(enqueue).to eq(:enqueued) + Appsignal::Transaction.complete_current! + + # The enqueue is a producer event span under the active transaction, + # named after the job being enqueued. + producer = event_span_for("enqueue.sidekiq") + expect(producer.name).to eq("enqueue.sidekiq (enqueue TestClass job)") + expect(producer.kind).to eq(:producer) + expect(producer.attributes["messaging.system"]).to eq("sidekiq") + expect(producer.attributes["messaging.operation.name"]).to eq("enqueue") + expect(producer.attributes["messaging.operation.type"]).to eq("send") + expect(producer.attributes["messaging.destination.name"]).to eq("default") + expect(producer.parent_span_id).to eq(root_span.span_id) + expect(scope_of(producer)).to eq(["appsignal-ruby/sidekiq", Appsignal::VERSION]) + + # The job carries the producer span's trace context, so the job that + # performs can link back to it. + expect(job["traceparent"]) + .to eq("00-#{producer.hex_trace_id}-#{producer.hex_span_id}-01") end end @@ -259,27 +445,90 @@ def enqueue end context "without an active transaction" do - it "passes through without recording" do - expect { |block| plugin.call("TestClass", job, "default", nil, &block) } - .to yield_control + it "in agent mode", :agent_mode do + start_agent + # A transparent pass-through: nothing is recorded and the job hash is + # untouched. + expect(enqueue).to eq(:enqueued) expect(created_transactions).to be_empty + expect(job).to_not have_key("traceparent") + end + + it "in collector mode", :collector_mode do + start_collector_agent + + # No transaction to attach the event to, so nothing is emitted and the + # job hash is untouched. + expect(enqueue).to eq(:enqueued) + expect(event_spans_for("enqueue.sidekiq")).to be_empty + expect(job).to_not have_key("traceparent") end end context "when job enqueue events are suppressed" do # As happens under Active Job, which records the enqueue itself. - it "passes through without recording the enqueue" do + def enqueue_suppressed(transaction) + transaction.suppress_job_enqueue_events { enqueue } + end + + it "in agent mode", :agent_mode do + start_agent transaction = http_request_transaction set_current_transaction(transaction) - result = transaction.suppress_job_enqueue_events { enqueue } - expect(result).to eq(:enqueued) + expect(enqueue_suppressed(transaction)).to eq(:enqueued) # The outer integration records the enqueue, so this one doesn't. event_names = transaction.to_h["events"].map { |event| event["name"] } expect(event_names).to_not include("enqueue.sidekiq") end + + it "in collector mode", :collector_mode do + start_collector_agent + transaction = http_request_transaction + set_current_transaction(transaction) + + expect(enqueue_suppressed(transaction)).to eq(:enqueued) + Appsignal::Transaction.complete_current! + + # No producer span for the suppressed enqueue... + expect(event_spans_for("enqueue.sidekiq")).to be_empty + # ...but the trace context is still injected so the job links back. + expect(job).to have_key("traceparent") + end + end + + context "when enqueue instrumentation is disabled" do + let(:start_agent_args) do + { :options => { :enable_job_enqueue_instrumentation => false } } + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + transaction = http_request_transaction + set_current_transaction(transaction) + + expect(enqueue).to eq(:enqueued) + + event_names = transaction.to_h["events"].map { |event| event["name"] } + expect(event_names).to_not include("enqueue.sidekiq") + end + + it "in collector mode", :collector_mode do + start_collector_agent + transaction = http_request_transaction + set_current_transaction(transaction) + + expect(enqueue).to eq(:enqueued) + Appsignal::Transaction.complete_current! + + # No enqueue event means no producer span, so there is nothing for the + # performing job to link back to and no trace context is written. The + # job starts its own trace instead of linking to the web request. + expect(event_spans_for("enqueue.sidekiq")).to be_empty + expect(job).to_not have_key("traceparent") + end end end @@ -331,9 +580,7 @@ class DelayedTestClass; end end let(:plugin) { Appsignal::Integrations::SidekiqMiddleware.new } let(:options) { {} } - before do - start_agent(:options => options) - end + let(:start_agent_args) { { :options => options } } around { |example| keep_transactions { example.run } } def expect_no_yaml_parse_error(logs) @@ -341,31 +588,126 @@ def expect_no_yaml_parse_error(logs) end describe "internal Sidekiq job values" do - it "does not save internal Sidekiq values as metadata on transaction" do - perform_sidekiq_job + describe "does not save internal Sidekiq values as metadata on transaction" do + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform_sidekiq_job + + transaction_hash = transaction.to_h + expect(transaction_hash["metadata"].keys) + .to_not include(*Appsignal::Integrations::SidekiqMiddleware::EXCLUDED_JOB_KEYS) + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform_sidekiq_job + + excluded = Appsignal::Integrations::SidekiqMiddleware::EXCLUDED_JOB_KEYS + excluded.each do |key| + expect(root_span.attributes).to_not have_key("appsignal.tag.#{key}") + end + end + end + end + + describe "with incoming trace context" do + let(:trace_id_hex) { "0af7651916cd43dd8448eb211c80319c" } + let(:span_id_hex) { "b7ad6b7169203331" } + let(:traceparent) { "00-#{trace_id_hex}-#{span_id_hex}-01" } + + # A job continues the enqueuer's trace as a child span and also links back + # to the enqueuing span. + def expect_parented_and_linked_to_remote + expect(root_span.kind).to eq(:consumer) + expect(root_span.hex_trace_id).to eq(trace_id_hex) + expect(root_span.parent_span_id.unpack1("H*")).to eq(span_id_hex) + expect(root_span.links.size).to eq(1) + link_context = root_span.links.first.span_context + expect(link_context.hex_trace_id).to eq(trace_id_hex) + expect(link_context.hex_span_id).to eq(span_id_hex) + end + + context "with a top-level traceparent (Sidekiq style)" do + let(:item) { super().merge("traceparent" => traceparent) } + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform_sidekiq_job + + # The trace header doesn't leak into the transaction as metadata. + expect(transaction.to_h["metadata"].keys).to_not include("traceparent") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform_sidekiq_job + + expect_parented_and_linked_to_remote + end + end + + context "with a traceparent nested under __otel_headers (ActiveJob style)" do + # OpenTelemetry's ActiveJob instrumentation runs the headers through + # ActiveJob's argument serializer, so they arrive as an array of + # [key, value] pairs, not a hash. + let(:item) { super().merge("__otel_headers" => [["traceparent", traceparent]]) } - transaction_hash = transaction.to_h - expect(transaction_hash["metadata"].keys) - .to_not include(*Appsignal::Integrations::SidekiqMiddleware::EXCLUDED_JOB_KEYS) + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform_sidekiq_job + + expect(transaction.to_h["metadata"].keys).to_not include("__otel_headers") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform_sidekiq_job + + expect_parented_and_linked_to_remote + end end end context "with parameter filtering" do let(:options) { { :filter_parameters => ["foo"] } } - it "filters selected arguments" do - perform_sidekiq_job + describe "filters selected arguments" do + def perform + perform_sidekiq_job + end - expect(transaction).to include_params( - [ - "foo", - { - "foo" => "[FILTERED]", - "bar" => "Bar", - "baz" => { "1" => "foo" } - } - ] - ) + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + + expect(transaction).to include_params( + [ + "foo", + { + "foo" => "[FILTERED]", + "bar" => "Bar", + "baz" => { "1" => "foo" } + } + ] + ) + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + + params = JSON.parse(root_span.attributes["appsignal.function.parameters"]) + expect(params).to eq( + [ + "foo", + { + "foo" => "[FILTERED]", + "bar" => "Bar", + "baz" => { "1" => "foo" } + } + ] + ) + end end end @@ -375,10 +717,25 @@ def expect_no_yaml_parse_error(logs) item["args"] << "super secret value" # Last argument will be replaced end - it "replaces the last argument (the secret bag) with an [encrypted data] string" do - perform_sidekiq_job + describe "replaces the last argument (the secret bag) with an [encrypted data] string" do + def perform + perform_sidekiq_job + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + + expect(transaction).to include_params(expected_args << "[encrypted data]") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform - expect(transaction).to include_params(expected_args << "[encrypted data]") + params = JSON.parse(root_span.attributes["appsignal.function.parameters"]) + expect(params).to eq(expected_args << "[encrypted data]") + end end end @@ -398,22 +755,57 @@ def expect_no_yaml_parse_error(logs) } end - it "uses the delayed class and method name for the action" do - perform_sidekiq_job + describe "uses the delayed class and method name for the action" do + def perform + perform_sidekiq_job + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + + expect(transaction).to have_action("DelayedTestClass.foo_method") + expect(transaction).to include_params([{ "bar" => "baz" }]) + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform - expect(transaction).to have_action("DelayedTestClass.foo_method") - expect(transaction).to include_params([{ "bar" => "baz" }]) + expect(root_span.attributes["appsignal.action_name"]) + .to eq("DelayedTestClass.foo_method") + params = JSON.parse(root_span.attributes["appsignal.function.parameters"]) + expect(params).to eq([{ "bar" => "baz" }]) + end end context "when job arguments is a malformed YAML object" do before { item["args"] = [] } - it "logs a warning and uses the default argument" do - logs = capture_logs { perform_sidekiq_job } + describe "logs a warning and uses the default argument" do + def perform + capture_logs { perform_sidekiq_job } + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + logs = perform - expect(transaction).to have_action("Sidekiq::Extensions::DelayedClass#perform") - expect(transaction).to include_params([]) - expect(logs).to contains_log(:warn, "Unable to load YAML") + expect(transaction).to have_action("Sidekiq::Extensions::DelayedClass#perform") + expect(transaction).to include_params([]) + expect(logs).to contains_log(:warn, "Unable to load YAML") + end + + it "in collector mode", :collector_mode do + start_collector_agent + logs = perform + + expect(root_span.attributes["appsignal.action_name"]) + .to eq("Sidekiq::Extensions::DelayedClass#perform") + params = JSON.parse(root_span.attributes["appsignal.function.parameters"]) + expect(params).to eq([]) + expect(logs).to contains_log(:warn, "Unable to load YAML") + end end end end @@ -434,22 +826,57 @@ def expect_no_yaml_parse_error(logs) } end - it "uses the delayed class and method name for the action" do - perform_sidekiq_job + describe "uses the delayed class and method name for the action" do + def perform + perform_sidekiq_job + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + + expect(transaction).to have_action("DelayedTestClass#foo_method") + expect(transaction).to include_params([{ "bar" => "baz" }]) + end - expect(transaction).to have_action("DelayedTestClass#foo_method") - expect(transaction).to include_params([{ "bar" => "baz" }]) + it "in collector mode", :collector_mode do + start_collector_agent + perform + + expect(root_span.attributes["appsignal.action_name"]) + .to eq("DelayedTestClass#foo_method") + params = JSON.parse(root_span.attributes["appsignal.function.parameters"]) + expect(params).to eq([{ "bar" => "baz" }]) + end end context "when job arguments is a malformed YAML object" do before { item["args"] = [] } - it "logs a warning and uses the default argument" do - logs = capture_logs { perform_sidekiq_job } + describe "logs a warning and uses the default argument" do + def perform + capture_logs { perform_sidekiq_job } + end - expect(transaction).to have_action("Sidekiq::Extensions::DelayedModel#perform") - expect(transaction).to include_params([]) - expect(logs).to contains_log(:warn, "Unable to load YAML") + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + logs = perform + + expect(transaction).to have_action("Sidekiq::Extensions::DelayedModel#perform") + expect(transaction).to include_params([]) + expect(logs).to contains_log(:warn, "Unable to load YAML") + end + + it "in collector mode", :collector_mode do + start_collector_agent + logs = perform + + expect(root_span.attributes["appsignal.action_name"]) + .to eq("Sidekiq::Extensions::DelayedModel#perform") + params = JSON.parse(root_span.attributes["appsignal.function.parameters"]) + expect(params).to eq([]) + expect(logs).to contains_log(:warn, "Unable to load YAML") + end end end end @@ -457,37 +884,81 @@ def expect_no_yaml_parse_error(logs) context "with an error" do let(:error) { ExampleException } - it "creates a transaction and adds the error" do - # TODO: additional curly brackets required for issue - # https://github.com/rspec/rspec-mocks/issues/1460 - expect(Appsignal).to receive(:increment_counter) - .with("sidekiq_queue_job_count", 1, { :queue => "default", :status => :failed }) - expect(Appsignal).to receive(:increment_counter) - .with("sidekiq_queue_job_count", 1, { :queue => "default", :status => :processed }) - expect(Appsignal).to receive(:increment_counter) - .with("sidekiq_worker_job_count", 1, - { :worker => "TestClass#perform", :queue => "default", :status => :failed }) - expect(Appsignal).to receive(:increment_counter) - .with("sidekiq_worker_job_count", 1, - { :worker => "TestClass#perform", :queue => "default", :status => :processed }) - expect do - perform_sidekiq_job { raise error, "uh oh" } - end.to raise_error(error) - - expect(transaction).to have_id - expect(transaction).to have_namespace(namespace) - expect(transaction).to have_action("TestClass#perform") - expect(transaction).to have_error("ExampleException", "uh oh") - expect(transaction).to include_metadata( - "extra" => "data", - "queue" => "default", - "retry_count" => "0" - ) - expect(transaction).to_not include_environment - expect(transaction).to include_params(expected_args) - expect(transaction).to include_tags("request_id" => jid) - expect(transaction).to_not include_breadcrumbs - expect_transaction_to_have_sidekiq_event(transaction) + describe "creates a transaction and adds the error" do + def perform + # TODO: additional curly brackets required for issue + # https://github.com/rspec/rspec-mocks/issues/1460 + expect(Appsignal).to receive(:increment_counter) + .with("sidekiq_queue_job_count", 1, { :queue => "default", :status => :failed }) + expect(Appsignal).to receive(:increment_counter) + .with("sidekiq_queue_job_count", 1, { :queue => "default", :status => :processed }) + expect(Appsignal).to receive(:increment_counter) + .with("sidekiq_worker_job_count", 1, + { :worker => "TestClass#perform", :queue => "default", :status => :failed }) + expect(Appsignal).to receive(:increment_counter) + .with("sidekiq_worker_job_count", 1, + { :worker => "TestClass#perform", :queue => "default", :status => :processed }) + expect do + perform_sidekiq_job { raise error, "uh oh" } + end.to raise_error(error) + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + + expect(transaction).to have_id + expect(transaction).to have_namespace(namespace) + expect(transaction).to have_action("TestClass#perform") + expect(transaction).to have_error("ExampleException", "uh oh") + expect(transaction).to include_metadata( + "extra" => "data", + "queue" => "default", + "retry_count" => "0" + ) + expect(transaction).to_not include_environment + expect(transaction).to include_params(expected_args) + expect(transaction).to include_tags("request_id" => jid) + expect(transaction).to_not include_breadcrumbs + expect_transaction_to_have_sidekiq_event(transaction) + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + + expect(root_span.kind).to eq(:consumer) + expect(root_span.attributes["appsignal.namespace"]).to eq("background") + expect(root_span.attributes["appsignal.action_name"]).to eq("TestClass#perform") + event = root_span.events.find { |e| e.name == "exception" } + expect(event).not_to be_nil + expect(event.attributes["exception.type"]).to eq("ExampleException") + expect(event.attributes["exception.message"]).to eq("uh oh") + expect(event.attributes["exception.stacktrace"]).to be_a(String) + expect(event.attributes["appsignal.alert_this_error"]).to eq(true) + expect(root_span.status.code).to eq(::OpenTelemetry::Trace::Status::ERROR) + expect(root_span.attributes["appsignal.tag.extra"]).to eq("data") + expect(root_span.attributes["appsignal.tag.queue"]).to eq("default") + expect(root_span.attributes["appsignal.tag.retry_count"]).to eq("0") + expect(JSON.parse(root_span.attributes["appsignal.function.parameters"])) + .to eq(expected_args) + expect(root_span.attributes["appsignal.tag.request_id"]).to eq(jid) + expect(event_spans.size).to eq(1) + span = event_spans.find { |s| s.name == "perform_job.sidekiq" } + expect(span.attributes["messaging.system"]).to eq("sidekiq") + expect(span.attributes["messaging.operation.name"]).to eq("perform") + expect(span.attributes["messaging.operation.type"]).to eq("process") + expect(span.attributes["messaging.destination.name"]).to eq("default") + expect(span).not_to be_nil + expect(span.parent_span_id).to eq(root_span.span_id) + expect(span.attributes).not_to have_key("appsignal.body") + expect(event_category(span)).to eq("perform_job.sidekiq") + + # Both the job's root span and its perform event carry the Sidekiq + # instrumentation scope. + expect(scope_of(root_span)).to eq(["appsignal-ruby/sidekiq", Appsignal::VERSION]) + expect(scope_of(span)).to eq(["appsignal-ruby/sidekiq", Appsignal::VERSION]) + end end end @@ -495,55 +966,111 @@ def expect_no_yaml_parse_error(logs) context "with Rails error reporter" do include RailsHelper - it "reports the worker name as the action, copies the namespace and tags" do - expect do - with_rails_error_reporter do - perform_sidekiq_job do - Appsignal.tag_job("test_tag" => "value") - Rails.error.handle do - raise ExampleStandardError, "error message" + describe "reports the worker name as the action, copies the namespace and tags" do + def perform + expect do + with_rails_error_reporter do + perform_sidekiq_job do + Appsignal.tag_job("test_tag" => "value") + Rails.error.handle do + raise ExampleStandardError, "error message" + end end end - end - end.to change { created_transactions.count }.by(1) + end.to change { created_transactions.count }.by(1) + end - tags = { "test_tag" => "value" } - transaction = last_transaction + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform - expect(transaction).to have_namespace("background_job") - expect(transaction).to have_action("TestClass#perform") - expect(transaction).to have_error("ExampleStandardError", "error message") - expect(transaction).to include_tags(tags) + tags = { "test_tag" => "value" } + transaction = last_transaction + + expect(transaction).to have_namespace("background_job") + expect(transaction).to have_action("TestClass#perform") + expect(transaction).to have_error("ExampleStandardError", "error message") + expect(transaction).to include_tags(tags) + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + + expect(root_span.kind).to eq(:consumer) + expect(root_span.attributes["appsignal.namespace"]).to eq("background") + expect(root_span.attributes["appsignal.action_name"]).to eq("TestClass#perform") + event = exception_events + .find { |e| e.attributes["exception.type"] == "ExampleStandardError" } + expect(event).not_to be_nil + expect(event.attributes["exception.message"]).to eq("error message") + expect(event.attributes["exception.stacktrace"]).to be_a(String) + expect(event.attributes["appsignal.alert_this_error"]).to eq(true) + expect(root_span.attributes["appsignal.tag.test_tag"]).to eq("value") + end end end end context "without an error" do - it "creates a transaction with events" do - # TODO: additional curly brackets required for issue - # https://github.com/rspec/rspec-mocks/issues/1460 - expect(Appsignal).to receive(:increment_counter) - .with("sidekiq_queue_job_count", 1, { :queue => "default", :status => :processed }) - expect(Appsignal).to receive(:increment_counter) - .with("sidekiq_worker_job_count", 1, - { :worker => "TestClass#perform", :queue => "default", :status => :processed }) - perform_sidekiq_job - - expect(transaction).to have_id - expect(transaction).to have_namespace(namespace) - expect(transaction).to have_action("TestClass#perform") - expect(transaction).to_not have_error - expect(transaction).to include_tags("request_id" => jid) - expect(transaction).to_not include_environment - expect(transaction).to_not include_breadcrumbs - expect(transaction).to_not include_params(expected_args) - expect(transaction).to include_metadata( - "extra" => "data", - "queue" => "default", - "retry_count" => "0" - ) - expect(transaction).to have_queue_start(Time.parse("2001-01-01 10:00:00UTC").to_i * 1000) - expect_transaction_to_have_sidekiq_event(transaction) + describe "creates a transaction with events" do + def perform + # TODO: additional curly brackets required for issue + # https://github.com/rspec/rspec-mocks/issues/1460 + expect(Appsignal).to receive(:increment_counter) + .with("sidekiq_queue_job_count", 1, { :queue => "default", :status => :processed }) + expect(Appsignal).to receive(:increment_counter) + .with("sidekiq_worker_job_count", 1, + { :worker => "TestClass#perform", :queue => "default", :status => :processed }) + perform_sidekiq_job + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + + expect(transaction).to have_id + expect(transaction).to have_namespace(namespace) + expect(transaction).to have_action("TestClass#perform") + expect(transaction).to_not have_error + expect(transaction).to include_tags("request_id" => jid) + expect(transaction).to_not include_environment + expect(transaction).to_not include_breadcrumbs + expect(transaction).to_not include_params(expected_args) + expect(transaction).to include_metadata( + "extra" => "data", + "queue" => "default", + "retry_count" => "0" + ) + expect(transaction).to have_queue_start( + Time.parse("2001-01-01 10:00:00UTC").to_i * 1000 + ) + expect_transaction_to_have_sidekiq_event(transaction) + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + + expect(root_span.kind).to eq(:consumer) + expect(root_span.attributes["appsignal.namespace"]).to eq("background") + expect(root_span.attributes["appsignal.action_name"]).to eq("TestClass#perform") + expect(exception_events).to be_empty + expect(root_span.attributes["appsignal.tag.request_id"]).to eq(jid) + expect(root_span.attributes["appsignal.tag.extra"]).to eq("data") + expect(root_span.attributes["appsignal.tag.queue"]).to eq("default") + expect(root_span.attributes["appsignal.tag.retry_count"]).to eq("0") + queue_event = Array(root_span.events).find { |e| e.name == "appsignal.queue_start" } + expect(queue_event.timestamp).to eq( + (Time.at(Time.parse("2001-01-01 10:00:00UTC").to_i).to_r * 1_000_000_000).to_i + ) + expect(event_spans.size).to eq(1) + span = event_spans.find { |s| s.name == "perform_job.sidekiq" } + expect(span).not_to be_nil + expect(span.parent_span_id).to eq(root_span.span_id) + expect(span.attributes).not_to have_key("appsignal.body") + expect(event_category(span)).to eq("perform_job.sidekiq") + end end end @@ -638,8 +1165,8 @@ def expect_transaction_to_have_sidekiq_event(transaction) end end around { |example| keep_transactions { example.run } } + before do - start_agent Appsignal.internal_logger = test_logger(log) ActiveJob::Base.queue_adapter = :sidekiq @@ -665,35 +1192,19 @@ def perform(*_args) end end - it "reports the transaction from the ActiveJob integration" do - perform_activejob_sidekiq_job(ActiveJobSidekiqTestJob, given_args) - - transaction = last_transaction - expect(transaction).to have_namespace(namespace) - expect(transaction).to have_action("ActiveJobSidekiqTestJob#perform") - expect(transaction).to_not have_error - expect(transaction).to include_metadata("queue" => "default") - expect(transaction).to_not include_environment - expect(transaction).to include_params([expected_args]) - expect(transaction).to include_tags(expected_tags.merge("queue" => "default")) - expect(transaction).to have_queue_start(time.to_i * 1000) - - events = transaction.to_h["events"] - .sort_by { |e| e["start"] } - .map { |event| event["name"] } - expect(events).to eq(expected_perform_events) - end + describe "reports the transaction from the ActiveJob integration" do + def perform + perform_activejob_sidekiq_job(ActiveJobSidekiqTestJob, given_args) + end - context "with error" do - it "reports the error on the transaction from the ActiveRecord integration" do - expect do - perform_activejob_sidekiq_job(ActiveJobSidekiqErrorTestJob, given_args) - end.to raise_error(RuntimeError, "uh oh") + it "in agent mode", :agent_mode do + start_agent + perform transaction = last_transaction expect(transaction).to have_namespace(namespace) - expect(transaction).to have_action("ActiveJobSidekiqErrorTestJob#perform") - expect(transaction).to have_error("RuntimeError", "uh oh") + expect(transaction).to have_action("ActiveJobSidekiqTestJob#perform") + expect(transaction).to_not have_error expect(transaction).to include_metadata("queue" => "default") expect(transaction).to_not include_environment expect(transaction).to include_params([expected_args]) @@ -705,6 +1216,85 @@ def perform(*_args) .map { |event| event["name"] } expect(events).to eq(expected_perform_events) end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + + expect(root_span.kind).to eq(:consumer) + expect(root_span.attributes["appsignal.namespace"]).to eq("background") + expect(root_span.attributes["appsignal.action_name"]) + .to eq("ActiveJobSidekiqTestJob#perform") + expect(exception_events).to be_empty + expect(root_span.attributes["appsignal.tag.queue"]).to eq("default") + expect(JSON.parse(root_span.attributes["appsignal.function.parameters"])) + .to eq([expected_args]) + expect(root_span.attributes["appsignal.tag.executions"]).to eq(1) + queue_event = Array(root_span.events).find { |e| e.name == "appsignal.queue_start" } + expect(queue_event.timestamp) + .to eq((Time.at(time.to_i).to_r * 1_000_000_000).to_i) + # The job is enqueued without an active transaction here, so no + # enqueue event/producer span is recorded -- only the perform events. + expect(event_spans.map(&:name)).to match_array(expected_perform_events) + sidekiq_span = event_spans.find { |s| s.name == "perform_job.sidekiq" } + expect(sidekiq_span).not_to be_nil + expect(sidekiq_span.parent_span_id).to eq(root_span.span_id) + end + end + + context "with error" do + describe "reports the error on the transaction from the ActiveRecord integration" do + def perform + expect do + perform_activejob_sidekiq_job(ActiveJobSidekiqErrorTestJob, given_args) + end.to raise_error(RuntimeError, "uh oh") + end + + it "in agent mode", :agent_mode do + start_agent + perform + + transaction = last_transaction + expect(transaction).to have_namespace(namespace) + expect(transaction).to have_action("ActiveJobSidekiqErrorTestJob#perform") + expect(transaction).to have_error("RuntimeError", "uh oh") + expect(transaction).to include_metadata("queue" => "default") + expect(transaction).to_not include_environment + expect(transaction).to include_params([expected_args]) + expect(transaction).to include_tags(expected_tags.merge("queue" => "default")) + expect(transaction).to have_queue_start(time.to_i * 1000) + + events = transaction.to_h["events"] + .sort_by { |e| e["start"] } + .map { |event| event["name"] } + expect(events).to eq(expected_perform_events) + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + + expect(root_span.kind).to eq(:consumer) + expect(root_span.attributes["appsignal.namespace"]).to eq("background") + expect(root_span.attributes["appsignal.action_name"]) + .to eq("ActiveJobSidekiqErrorTestJob#perform") + expect(exception_events.size).to be >= 1 + event = exception_events.find { |e| e.attributes["exception.type"] == "RuntimeError" } + expect(event).not_to be_nil + expect(event.attributes["exception.message"]).to eq("uh oh") + expect(event.attributes["exception.stacktrace"]).to be_a(String) + expect(event.attributes["appsignal.alert_this_error"]).to eq(true) + expect(root_span.attributes["appsignal.tag.queue"]).to eq("default") + queue_event = Array(root_span.events).find { |e| e.name == "appsignal.queue_start" } + expect(queue_event.timestamp) + .to eq((Time.at(time.to_i).to_r * 1_000_000_000).to_i) + expect(JSON.parse(root_span.attributes["appsignal.function.parameters"])) + .to eq([expected_args]) + sidekiq_span = event_spans.find { |s| s.name == "perform_job.sidekiq" } + expect(sidekiq_span).not_to be_nil + expect(sidekiq_span.parent_span_id).to eq(root_span.span_id) + end + end end context "with ActionMailer" do @@ -717,15 +1307,34 @@ def welcome(*args) end end - it "reports ActionMailer data on the transaction" do - perform_mailer(ActionMailerSidekiqTestJob, :welcome, given_args) + describe "reports ActionMailer data on the transaction" do + def perform + perform_mailer(ActionMailerSidekiqTestJob, :welcome, given_args) + end - transaction = last_transaction - expect(transaction).to have_action("ActionMailerSidekiqTestJob#welcome") - expect(transaction).to include_params( - ["ActionMailerSidekiqTestJob", "welcome", - "deliver_now"] + expected_wrapped_args - ) + it "in agent mode", :agent_mode do + start_agent + perform + + transaction = last_transaction + expect(transaction).to have_action("ActionMailerSidekiqTestJob#welcome") + expect(transaction).to include_params( + ["ActionMailerSidekiqTestJob", "welcome", + "deliver_now"] + expected_wrapped_args + ) + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + + expect(root_span.attributes["appsignal.action_name"]) + .to eq("ActionMailerSidekiqTestJob#welcome") + expected_params = + ["ActionMailerSidekiqTestJob", "welcome", "deliver_now"] + expected_wrapped_args + expect(JSON.parse(root_span.attributes["appsignal.function.parameters"])) + .to eq(expected_params) + end end end diff --git a/spec/lib/appsignal/integrations/webmachine_spec.rb b/spec/lib/appsignal/integrations/webmachine_spec.rb index 471e563fe..5c744e5b4 100644 --- a/spec/lib/appsignal/integrations/webmachine_spec.rb +++ b/spec/lib/appsignal/integrations/webmachine_spec.rb @@ -21,6 +21,7 @@ def headers { "REQUEST_METHOD" => "GET", "PATH_INFO" => "/some/path", + "HTTP_ACCEPT" => "application/json", "ignored_header" => "something" }, nil @@ -46,17 +47,71 @@ def self.name let(:resource_instance) { resource.new(request, response) } let(:response) { Webmachine::Response.new } let(:fsm) { Webmachine::Decision::FSM.new(resource_instance, request, response) } - before { start_agent } - around { |example| keep_transactions { example.run } } describe "#run" do - it "creates a transaction" do - expect { fsm.run }.to(change { created_transactions.count }.by(1)) + def perform + fsm.run end - it "sets the action" do - fsm.run - expect(last_transaction).to have_action("MyResource#GET") + it_in_both_modes "creates a transaction" do + expect { perform }.to(change { created_transactions.count }.by(1)) + end + + describe "sets the action" do + it "in agent mode", :agent_mode do + start_agent + perform + expect(last_transaction).to have_action("MyResource#GET") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + expect(root_span.name).to eq("MyResource#GET") + expect(root_span.kind).to eq(:server) + expect(root_span.attributes["appsignal.action_name"]).to eq("MyResource#GET") + expect(scope_of(root_span)).to eq(["appsignal-ruby/webmachine", Appsignal::VERSION]) + end + end + + describe "marking the transaction as an incoming HTTP request" do + # These describe the request as a whole, so they belong on the + # transaction's span and on none of the events recorded within it. + it "sets the request attributes on the transaction span only", :collector_mode do + start_collector_agent + perform + + expect(root_span.attributes["http.request.method"]).to eq("GET") + # The path and scheme come off the request's URI, which Webmachine + # builds from the full request URL. The query string is a separate + # attribute, so it must not end up in the path. + expect(root_span.attributes["url.path"]).to eq("/foo") + expect(root_span.attributes["url.scheme"]).to eq("http") + expect(root_span.attributes["url.query"]) + .to eq("param1=value1¶m2=value2") + expect(event_spans).to_not be_empty + event_spans.each do |span| + expect(span.attributes).to_not have_key("http.request.method") + expect(span.attributes).to_not have_key("url.path") + expect(span.attributes).to_not have_key("url.scheme") + expect(span.attributes).to_not have_key("url.query") + end + end + end + + describe "describing the response" do + # The event has closed by the time the response code is known, which is + # what makes the transaction's own span the one this lands on. + it "sets the response status on the transaction span only", :collector_mode do + start_collector_agent + perform + + expect(root_span.attributes["http.response.status_code"]).to eq(200) + expect(event_spans).to_not be_empty + event_spans.each do |span| + expect(span.attributes).to_not have_key("http.response.status_code") + end + end end context "with action already set" do @@ -69,52 +124,155 @@ def to_html end end - it "doesn't overwrite the action" do - fsm.run - expect(last_transaction).to have_action("Custom Action") + describe "doesn't overwrite the action" do + it "in agent mode", :agent_mode do + start_agent + perform + expect(last_transaction).to have_action("Custom Action") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + expect(root_span.name).to eq("Custom Action") + expect(root_span.attributes["appsignal.action_name"]).to eq("Custom Action") + end end end - it "records an instrumentation event" do - fsm.run - expect(last_transaction).to include_event("name" => "process_action.webmachine") + describe "records an instrumentation event" do + it "in agent mode", :agent_mode do + start_agent + perform + expect(last_transaction).to include_event("name" => "process_action.webmachine") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + span = event_spans.find { |s| s.name == "process_action.webmachine" } + expect(span).not_to be_nil + expect(span.parent_span_id).to eq(root_span.span_id) + end end - it "sets the params" do - fsm.run - expect(last_transaction).to include_params("param1" => "value1", "param2" => "value2") + describe "sets the query parameters" do + it "in agent mode", :agent_mode do + start_agent + perform + expect(last_transaction).to include_params("param1" => "value1", "param2" => "value2") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + params = JSON.parse(root_span.attributes["appsignal.request.query_parameters"]) + expect(params).to include("param1" => "value1", "param2" => "value2") + end end - it "sets the headers" do - fsm.run - expect(last_transaction).to include_environment( - "REQUEST_METHOD" => "GET", - "PATH_INFO" => "/some/path" - ) + describe "sets the headers" do + it "in agent mode", :agent_mode do + start_agent + perform + expect(last_transaction).to include_environment( + "REQUEST_METHOD" => "GET", + "PATH_INFO" => "/some/path", + "HTTP_ACCEPT" => "application/json" + ) + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + # Only true HTTP headers map to `http.request.header.*`; the non-header + # CGI vars (REQUEST_METHOD, PATH_INFO) are intentionally dropped. + expect(root_span.attributes["http.request.header.accept"]).to eq("application/json") + expect(root_span.attributes.keys).to_not include("http.request.header.request-method") + end end - it "closes the transaction" do - fsm.run + it_in_both_modes "closes the transaction" do + perform expect(last_transaction).to be_completed expect(current_transaction?).to be_falsy end + describe "incoming trace context" do + let(:trace_id_hex) { "0af7651916cd43dd8448eb211c80319c" } + let(:span_id_hex) { "b7ad6b7169203331" } + + it "continues the upstream trace when a traceparent is present", :collector_mode do + # The real Webmachine adapter builds a case-insensitive + # `Webmachine::Headers`; the plain Hash here uses the same lowercased + # header name, which the default getter reads identically. + request.headers["traceparent"] = "00-#{trace_id_hex}-#{span_id_hex}-01" + start_collector_agent + perform + + expect(root_span.kind).to eq(:server) + expect(root_span.hex_trace_id).to eq(trace_id_hex) + expect(root_span.parent_span_id.unpack1("H*")).to eq(span_id_hex) + end + + it "starts a fresh root trace when no traceparent is present", :collector_mode do + start_collector_agent + perform + + expect(root_span.parent_span_id).to eq(::OpenTelemetry::Trace::INVALID_SPAN_ID) + end + end + context "with parent transaction" do let(:transaction) { http_request_transaction } - before { set_current_transaction(transaction) } + # The parent is set inside each example rather than in a `before`: in + # collector mode the transaction must be created after the example body + # has enabled collector mode (via `start_collector_agent`), so it gets + # the OpenTelemetry backend. - it "sets the action" do - fsm.run - expect(last_transaction).to have_action("MyResource#GET") + describe "sets the action" do + it "in agent mode", :agent_mode do + start_agent + set_current_transaction(transaction) + perform + expect(last_transaction).to have_action("MyResource#GET") + end + + it "in collector mode", :collector_mode do + start_collector_agent + set_current_transaction(transaction) + perform + # The parent transaction is not closed by `fsm.run`; finish it so + # its span is exported. + transaction.complete + expect(root_span.name).to eq("MyResource#GET") + expect(root_span.attributes["appsignal.action_name"]).to eq("MyResource#GET") + end end - it "sets the params" do - fsm.run - last_transaction._sample - expect(last_transaction).to include_params("param1" => "value1", "param2" => "value2") + describe "sets the query parameters" do + it "in agent mode", :agent_mode do + start_agent + set_current_transaction(transaction) + perform + last_transaction._sample + expect(last_transaction).to include_params("param1" => "value1", "param2" => "value2") + end + + it "in collector mode", :collector_mode do + start_collector_agent + set_current_transaction(transaction) + perform + # The parent transaction is not closed by `fsm.run`; finish it so + # its span is exported. + transaction.complete + params = JSON.parse(root_span.attributes["appsignal.request.query_parameters"]) + expect(params).to include("param1" => "value1", "param2" => "value2") + end end - it "does not close the transaction" do + it_in_both_modes "does not close the transaction" do + set_current_transaction(transaction) expect(last_transaction).to_not be_completed end end @@ -124,12 +282,32 @@ def to_html let(:error) { ExampleException.new("error message") } let(:transaction) { http_request_transaction } - it "tracks the error" do - with_current_transaction(transaction) do - fsm.send(:handle_exceptions) { raise error } + describe "tracks the error" do + it "in agent mode", :agent_mode do + start_agent + with_current_transaction(transaction) do + fsm.send(:handle_exceptions) { raise error } + end + + expect(last_transaction).to have_error("ExampleException", "error message") end - expect(last_transaction).to have_error("ExampleException", "error message") + it "in collector mode", :collector_mode do + start_collector_agent + with_current_transaction(transaction) do + fsm.send(:handle_exceptions) { raise error } + end + # Not completed by `handle_exceptions`; finish it to export the span. + transaction.complete + + event = root_span.events.find { |e| e.name == "exception" } + expect(event).not_to be_nil + expect(event.attributes["exception.type"]).to eq("ExampleException") + expect(event.attributes["exception.message"]).to eq("error message") + expect(event.attributes["exception.stacktrace"]).to be_a(String) + expect(event.attributes["appsignal.alert_this_error"]).to eq(true) + expect(root_span.status.code).to eq(::OpenTelemetry::Trace::Status::ERROR) + end end end end diff --git a/spec/lib/appsignal/loaders/grape_spec.rb b/spec/lib/appsignal/loaders/grape_spec.rb index 439910b54..4be6f83cb 100644 --- a/spec/lib/appsignal/loaders/grape_spec.rb +++ b/spec/lib/appsignal/loaders/grape_spec.rb @@ -1,5 +1,5 @@ if DependencyHelper.grape_present? - describe "Appsignal::Loaders::PadrinoLoader" do + describe "Appsignal::Loaders::GrapeLoader" do describe "#on_load" do it "ensures the Grape middleware is loaded" do load_loader(:grape) diff --git a/spec/lib/appsignal/loaders/hanami_spec.rb b/spec/lib/appsignal/loaders/hanami_spec.rb index 5b3e93f2e..5c7383ab6 100644 --- a/spec/lib/appsignal/loaders/hanami_spec.rb +++ b/spec/lib/appsignal/loaders/hanami_spec.rb @@ -66,11 +66,9 @@ def hanami_middleware_options describe "Appsignal::Loaders::HanamiLoader::HanamiIntegration" do let(:transaction) { http_request_transaction } let(:app) { HanamiApp::Actions::Books::Index } - around { |example| keep_transactions { example.run } } before do expect(::Hanami.app.config).to receive(:root).and_return(project_fixture_path) Appsignal.load(:hanami) - start_agent end def make_request(env) @@ -82,10 +80,25 @@ def make_request(env) context "without an active transaction" do let(:env) { {} } - it "does not set the action name" do - make_request(env) + describe "does not set the action name" do + def perform + make_request(env) + end + + it "in agent mode", :agent_mode do + start_agent + perform - expect(transaction).to_not have_action + expect(transaction).to_not have_action + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + expect(root_span.attributes).to_not have_key("appsignal.action_name") + end end end @@ -93,17 +106,49 @@ def make_request(env) let(:env) { { Appsignal::Rack::APPSIGNAL_TRANSACTION => transaction } } if DependencyHelper.hanami2_2_present? - it "does not set an action name on the transaction" do - # This is done by the middleware instead - make_request(env) + # The action name is set by the middleware instead. + describe "does not set an action name on the transaction" do + def perform + make_request(env) + end - expect(transaction).to_not have_action + it "in agent mode", :agent_mode do + start_agent + perform + + expect(transaction).to_not have_action + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + expect(root_span.attributes).to_not have_key("appsignal.action_name") + end end else - it "sets action name on the transaction" do - make_request(env) + describe "sets action name on the transaction" do + def perform + make_request(env) + end + + it "in agent mode", :agent_mode do + start_agent + perform + + expect(transaction).to have_action("HanamiApp::Actions::Books::Index") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete - expect(transaction).to have_action("HanamiApp::Actions::Books::Index") + expect(root_span.name).to eq("HanamiApp::Actions::Books::Index") + expect(root_span.attributes["appsignal.action_name"]) + .to eq("HanamiApp::Actions::Books::Index") + end end end end diff --git a/spec/lib/appsignal/loaders/padrino_spec.rb b/spec/lib/appsignal/loaders/padrino_spec.rb index 0ceb319e3..d9298c700 100644 --- a/spec/lib/appsignal/loaders/padrino_spec.rb +++ b/spec/lib/appsignal/loaders/padrino_spec.rb @@ -48,7 +48,10 @@ def uninstall_padrino_integration [ Appsignal::Rack::SinatraBaseInstrumentation, [ - { :instrument_event_name => "process_action.padrino" } + { + :instrument_event_name => "process_action.padrino", + :opentelemetry_scope => ["appsignal-ruby/padrino", Appsignal::VERSION] + } ], nil ] @@ -66,7 +69,6 @@ class PadrinoClassWithRouter let(:env) { {} } # TODO: use an instance double let(:settings) { double(:name => "TestApp") } - around { |example| keep_transactions { example.run } } describe "routes" do let(:env) do @@ -114,9 +116,12 @@ def fetch_body(body) context "when AppSignal is not active" do let(:path) { "/foo" } + let(:appsignal_env) { :inactive_env } + # Pass the inactive env through to the mode contexts' `start_agent`. + let(:start_agent_args) { { :env => appsignal_env } } before { app.controllers { get(:foo) { "content" } } } - it "does not instrument the request" do + it_in_both_modes "does not instrument the request" do expect do expect(response).to match_response(200, "content") end.to_not(change { created_transactions.count }) @@ -124,18 +129,41 @@ def fetch_body(body) end context "when AppSignal is active" do - let(:transaction) { http_request_transaction } - before do - start_agent - set_current_transaction(transaction) + # The Padrino integration sets the action on the current transaction, + # so build it in the example body (after the agent starts, so it is + # backed by the right backend) and set it as current before the + # request. `response` triggers `app.call(env)`. + def perform(status, body) + set_current_transaction(http_request_transaction) + expect(response).to match_response(status, body) + end + + # In collector mode the action lands as the OTel span name and the + # `appsignal.action_name` attribute. Complete the transaction first so + # the root span is exported and readable. + def expect_collector_action(action) + Appsignal::Transaction.complete_current! + expect(root_span.name).to eq(action) + expect(root_span.attributes["appsignal.action_name"]).to eq(action) end context "with not existing route" do let(:path) { "/404" } - it "instruments the request" do - expect(response).to match_response(404, /^GET /404/) - expect(last_transaction).to have_action("PadrinoTestApp#unknown") + describe "sets the action to the app name and unknown action" do + it "in agent mode", :agent_mode do + start_agent + perform(404, /^GET /404/) + + expect(last_transaction).to have_action("PadrinoTestApp#unknown") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform(404, /^GET /404/) + + expect_collector_action("PadrinoTestApp#unknown") + end end end @@ -146,9 +174,21 @@ def fetch_body(body) app.controllers { get(:static) { "Static!" } } end - it "does not instrument the request" do - expect(response).to match_response(200, "Static!") - expect(last_transaction).to_not have_action + describe "does not set an action name" do + it "in agent mode", :agent_mode do + start_agent + perform(200, "Static!") + + expect(last_transaction).to_not have_action + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform(200, "Static!") + Appsignal::Transaction.complete_current! + + expect(root_span.attributes).to_not have_key("appsignal.action_name") + end end end @@ -160,9 +200,20 @@ def fetch_body(body) app.controllers { get(:my_original_path, :with => :id) { "content" } } end - it "falls back on Sinatra::Request#route_obj.original_path" do - expect(response).to match_response(200, "content") - expect(last_transaction).to have_action("PadrinoTestApp:/my_original_path/:id") + describe "falls back on Sinatra::Request#route_obj.original_path" do + it "in agent mode", :agent_mode do + start_agent + perform(200, "content") + + expect(last_transaction).to have_action("PadrinoTestApp:/my_original_path/:id") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform(200, "content") + + expect_collector_action("PadrinoTestApp:/my_original_path/:id") + end end end @@ -174,17 +225,25 @@ def fetch_body(body) app.controllers { get(:my_original_path) { "content" } } end - it "falls back on app name" do - expect(response).to match_response(200, "content") - expect(last_transaction).to have_action("PadrinoTestApp#unknown") + describe "falls back on app name" do + it "in agent mode", :agent_mode do + start_agent + perform(200, "content") + + expect(last_transaction).to have_action("PadrinoTestApp#unknown") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform(200, "content") + + expect_collector_action("PadrinoTestApp#unknown") + end end end context "with existing route" do let(:path) { "/" } - def make_request - expect(response).to match_response(200, "content") - end context "with action name as symbol" do context "with :index helper" do @@ -193,9 +252,20 @@ def make_request app.controllers { get(:index) { "content" } } end - it "sets the action with the app name and action name" do - make_request - expect(last_transaction).to have_action("PadrinoTestApp:#index") + describe "sets the action with the app name and action name" do + it "in agent mode", :agent_mode do + start_agent + perform(200, "content") + + expect(last_transaction).to have_action("PadrinoTestApp:#index") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform(200, "content") + + expect_collector_action("PadrinoTestApp:#index") + end end end @@ -205,9 +275,20 @@ def make_request app.controllers { get(:foo) { "content" } } end - it "sets the action with the app name and action name" do - make_request - expect(last_transaction).to have_action("PadrinoTestApp:#foo") + describe "sets the action with the app name and action name" do + it "in agent mode", :agent_mode do + start_agent + perform(200, "content") + + expect(last_transaction).to have_action("PadrinoTestApp:#foo") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform(200, "content") + + expect_collector_action("PadrinoTestApp:#foo") + end end end end @@ -219,9 +300,20 @@ def make_request app.controllers { get("/") { "content" } } end - it "sets the action with the app name and action path" do - make_request - expect(last_transaction).to have_action("PadrinoTestApp:#/") + describe "sets the action with the app name and action path" do + it "in agent mode", :agent_mode do + start_agent + perform(200, "content") + + expect(last_transaction).to have_action("PadrinoTestApp:#/") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform(200, "content") + + expect_collector_action("PadrinoTestApp:#/") + end end end @@ -231,9 +323,20 @@ def make_request app.controllers { get("/foo") { "content" } } end - it "sets the action with the app name and action path" do - make_request - expect(last_transaction).to have_action("PadrinoTestApp:#/foo") + describe "sets the action with the app name and action path" do + it "in agent mode", :agent_mode do + start_agent + perform(200, "content") + + expect(last_transaction).to have_action("PadrinoTestApp:#/foo") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform(200, "content") + + expect_collector_action("PadrinoTestApp:#/foo") + end end end end @@ -247,9 +350,20 @@ def make_request app.controllers(:my_controller) { get(:index) { "content" } } end - it "sets the action with the app name, controller name and action name" do - make_request - expect(last_transaction).to have_action("PadrinoTestApp:my_controller#index") + describe "sets the action with the app name, controller name and action name" do + it "in agent mode", :agent_mode do + start_agent + perform(200, "content") + + expect(last_transaction).to have_action("PadrinoTestApp:my_controller#index") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform(200, "content") + + expect_collector_action("PadrinoTestApp:my_controller#index") + end end end @@ -259,9 +373,20 @@ def make_request app.controllers("/my_controller") { get(:index) { "content" } } end - it "sets the action with the app name, controller name and action path" do - make_request - expect(last_transaction).to have_action("PadrinoTestApp:/my_controller#index") + describe "sets the action with the app name, controller name and action path" do + it "in agent mode", :agent_mode do + start_agent + perform(200, "content") + + expect(last_transaction).to have_action("PadrinoTestApp:/my_controller#index") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform(200, "content") + + expect_collector_action("PadrinoTestApp:/my_controller#index") + end end end end diff --git a/spec/lib/appsignal/logger/opentelemetry_backend_spec.rb b/spec/lib/appsignal/logger/opentelemetry_backend_spec.rb new file mode 100644 index 000000000..a1de148d4 --- /dev/null +++ b/spec/lib/appsignal/logger/opentelemetry_backend_spec.rb @@ -0,0 +1,141 @@ +# frozen_string_literal: true + +require "opentelemetry/sdk" if DependencyHelper.opentelemetry_present? +require "opentelemetry-logs-sdk" if DependencyHelper.opentelemetry_present? + +describe Appsignal::Logger::OpenTelemetryBackend, :if => DependencyHelper.opentelemetry_present? do + let(:exporter) { ::OpenTelemetry::SDK::Logs::Export::InMemoryLogRecordExporter.new } + let(:logger_provider) do + provider = ::OpenTelemetry::SDK::Logs::LoggerProvider.new + provider.add_log_record_processor( + ::OpenTelemetry::SDK::Logs::Export::SimpleLogRecordProcessor.new(exporter) + ) + provider + end + + before do + ::OpenTelemetry.logger_provider = logger_provider + end + + def emitted_records + exporter.emitted_log_records + end + + describe ".emit" do + it "emits a log record carrying the formatted body and severity" do + described_class.emit("my-group", ::Logger::INFO, Appsignal::Logger::JSON, "hello world", {}) + + record = emitted_records.first + expect(record.body).to eq("hello world") + expect(record.severity_number).to eq(9) + expect(record.severity_text).to eq("INFO") + end + + it "attaches appsignal.group and appsignal.format on every record" do + described_class.emit( + "my-group", + ::Logger::WARN, + Appsignal::Logger::LOGFMT, + "msg", + {} + ) + + attrs = emitted_records.first.attributes + expect(attrs["appsignal.group"]).to eq("my-group") + expect(attrs["appsignal.format"]).to eq("logfmt") + end + + it "maps every supported format flag to its lowercase name" do + { + Appsignal::Logger::PLAINTEXT => "plaintext", + Appsignal::Logger::LOGFMT => "logfmt", + Appsignal::Logger::JSON => "json", + Appsignal::Logger::AUTODETECT => "autodetect" + }.each do |flag, name| + described_class.emit("g", ::Logger::INFO, flag, "m", {}) + expect(emitted_records.last.attributes["appsignal.format"]).to eq(name) + end + end + + it "carries user attributes through with coerced keys and values" do + described_class.emit( + "g", + ::Logger::INFO, + Appsignal::Logger::AUTODETECT, + "msg", + { + :string => "value", + "symbol" => :sym, + :integer => 42, + :float => 1.5, + :truthy => true, + :falsy => false, + :other => Time.utc(2026, 1, 2, 3, 4, 5) + } + ) + + attrs = emitted_records.first.attributes + expect(attrs).to include( + "string" => "value", + "symbol" => "sym", + "integer" => 42, + "float" => 1.5, + "truthy" => true, + "falsy" => false, + "other" => "2026-01-02 03:04:05 UTC" + ) + end + + it "does not let user attributes override the appsignal.* keys" do + described_class.emit( + "the-group", + ::Logger::INFO, + Appsignal::Logger::JSON, + "msg", + { "appsignal.group" => "spoofed", "appsignal.format" => "spoofed" } + ) + + attrs = emitted_records.first.attributes + expect(attrs["appsignal.group"]).to eq("the-group") + expect(attrs["appsignal.format"]).to eq("json") + end + + it "maps every Ruby Logger severity to the right OTel SeverityNumber" do + expected = { + ::Logger::DEBUG => [5, "DEBUG"], + ::Logger::INFO => [9, "INFO"], + ::Logger::WARN => [13, "WARN"], + ::Logger::ERROR => [17, "ERROR"], + ::Logger::FATAL => [21, "FATAL"] + } + expected.each do |severity, (number, text)| + described_class.emit("g", severity, Appsignal::Logger::PLAINTEXT, "m", {}) + record = emitted_records.last + expect(record.severity_number).to eq(number) + expect(record.severity_text).to eq(text) + end + end + + it "uses the 'appsignal-logger' instrumentation scope name" do + described_class.emit("g", ::Logger::INFO, Appsignal::Logger::PLAINTEXT, "msg", {}) + + expect(emitted_records.first.instrumentation_scope.name).to eq("appsignal-logger") + end + end + + describe "logger resolution" do + it "picks up a swapped logger provider on the next emit" do + described_class.emit("g", ::Logger::INFO, Appsignal::Logger::PLAINTEXT, "a", {}) + + new_provider = ::OpenTelemetry::SDK::Logs::LoggerProvider.new + new_exporter = ::OpenTelemetry::SDK::Logs::Export::InMemoryLogRecordExporter.new + new_provider.add_log_record_processor( + ::OpenTelemetry::SDK::Logs::Export::SimpleLogRecordProcessor.new(new_exporter) + ) + ::OpenTelemetry.logger_provider = new_provider + + described_class.emit("g", ::Logger::INFO, Appsignal::Logger::PLAINTEXT, "b", {}) + expect(new_exporter.emitted_log_records.map(&:body)).to eq(["b"]) + end + end +end diff --git a/spec/lib/appsignal/logger_spec.rb b/spec/lib/appsignal/logger_spec.rb index 21da245b4..f6e050e4d 100644 --- a/spec/lib/appsignal/logger_spec.rb +++ b/spec/lib/appsignal/logger_spec.rb @@ -1,132 +1,272 @@ shared_examples "tagged logging" do - it "logs messages with tags from logger.tagged" do - expect(Appsignal::Extension).to receive(:log) - .with( - "group", - 3, - 3, - "[My tag] [My other tag] Some message\n", - Appsignal::Utils::Data.generate({}) - ) - - logger.tagged("My tag", "My other tag") do - logger.info("Some message") + describe "with tags from logger.tagged" do + def perform + logger.tagged("My tag", "My other tag") do + logger.info("Some message") + end + end + + it "in agent mode", :agent_mode do + start_agent + expect(Appsignal::Extension).to receive(:log) + .with( + "group", + 3, + 3, + "[My tag] [My other tag] Some message\n", + Appsignal::Utils::Data.generate({}) + ) + perform + end + + it "in collector mode", :collector_mode do + start_collector_agent + expect(Appsignal::Logger::OpenTelemetryBackend).to receive(:emit) + .with( + "group", + ::Logger::INFO, + Appsignal::Logger::AUTODETECT, + "[My tag] [My other tag] Some message\n", + {} + ) + perform end end - it "logs messages with nested tags from logger.tagged" do - expect(Appsignal::Extension).to receive(:log) - .with( - "group", - 3, - 3, - "[My tag] [My other tag] [Nested tag] [Nested other tag] Some message\n", - Appsignal::Utils::Data.generate({}) - ) - - logger.tagged("My tag", "My other tag") do - logger.tagged("Nested tag", "Nested other tag") do - logger.info("Some message") + describe "with nested tags from logger.tagged" do + def perform + logger.tagged("My tag", "My other tag") do + logger.tagged("Nested tag", "Nested other tag") do + logger.info("Some message") + end end end + + it "in agent mode", :agent_mode do + start_agent + expect(Appsignal::Extension).to receive(:log) + .with( + "group", + 3, + 3, + "[My tag] [My other tag] [Nested tag] [Nested other tag] Some message\n", + Appsignal::Utils::Data.generate({}) + ) + perform + end + + it "in collector mode", :collector_mode do + start_collector_agent + expect(Appsignal::Logger::OpenTelemetryBackend).to receive(:emit) + .with( + "group", + ::Logger::INFO, + Appsignal::Logger::AUTODETECT, + "[My tag] [My other tag] [Nested tag] [Nested other tag] Some message\n", + {} + ) + perform + end end - it "logs messages with tags from Rails.application.config.log_tags" do - allow(Appsignal::Extension).to receive(:log) - - # This is how Rails sets the `log_tags` values - logger.push_tags(["Request tag", "Second tag"]) - logger.tagged("First message", "My other tag") { logger.info("Some message") } - expect(Appsignal::Extension).to have_received(:log) - .with( - "group", - 3, - 3, - "[Request tag] [Second tag] [First message] [My other tag] Some message\n", - Appsignal::Utils::Data.generate({}) - ) - - # Logs all messsages within the time between `push_tags` and `pop_tags` - # with the same set tags - logger.tagged("Second message") { logger.info("Some message") } - expect(Appsignal::Extension).to have_received(:log) - .with( - "group", - 3, - 3, - "[Request tag] [Second tag] [Second message] Some message\n", - Appsignal::Utils::Data.generate({}) - ) - - # This is how Rails clears the `log_tags` values - # It will no longer includes those tags in new log messages - logger.pop_tags(2) - logger.tagged("Third message") { logger.info("Some message") } - expect(Appsignal::Extension).to have_received(:log) - .with( - "group", - 3, - 3, - "[Third message] Some message\n", - Appsignal::Utils::Data.generate({}) - ) + describe "with tags from Rails.application.config.log_tags" do + it "in agent mode", :agent_mode do + start_agent + allow(Appsignal::Extension).to receive(:log) + + logger.push_tags(["Request tag", "Second tag"]) + logger.tagged("First message", "My other tag") { logger.info("Some message") } + expect(Appsignal::Extension).to have_received(:log) + .with( + "group", + 3, + 3, + "[Request tag] [Second tag] [First message] [My other tag] Some message\n", + Appsignal::Utils::Data.generate({}) + ) + + logger.tagged("Second message") { logger.info("Some message") } + expect(Appsignal::Extension).to have_received(:log) + .with( + "group", + 3, + 3, + "[Request tag] [Second tag] [Second message] Some message\n", + Appsignal::Utils::Data.generate({}) + ) + + logger.pop_tags(2) + logger.tagged("Third message") { logger.info("Some message") } + expect(Appsignal::Extension).to have_received(:log) + .with( + "group", + 3, + 3, + "[Third message] Some message\n", + Appsignal::Utils::Data.generate({}) + ) + end + + it "in collector mode", :collector_mode do + start_collector_agent + allow(Appsignal::Logger::OpenTelemetryBackend).to receive(:emit) + + logger.push_tags(["Request tag", "Second tag"]) + logger.tagged("First message", "My other tag") { logger.info("Some message") } + expect(Appsignal::Logger::OpenTelemetryBackend).to have_received(:emit) + .with( + "group", + ::Logger::INFO, + Appsignal::Logger::AUTODETECT, + "[Request tag] [Second tag] [First message] [My other tag] Some message\n", + {} + ) + + logger.tagged("Second message") { logger.info("Some message") } + expect(Appsignal::Logger::OpenTelemetryBackend).to have_received(:emit) + .with( + "group", + ::Logger::INFO, + Appsignal::Logger::AUTODETECT, + "[Request tag] [Second tag] [Second message] Some message\n", + {} + ) + + logger.pop_tags(2) + logger.tagged("Third message") { logger.info("Some message") } + expect(Appsignal::Logger::OpenTelemetryBackend).to have_received(:emit) + .with( + "group", + ::Logger::INFO, + Appsignal::Logger::AUTODETECT, + "[Third message] Some message\n", + {} + ) + end end - it "logs messages with tags from Rails 8 application.config.log_tags" do - allow(Appsignal::Extension).to receive(:log) - - # This is how Rails sets the `log_tags` values - logger.push_tags("Request tag", "Second tag") - logger.tagged("First message", "My other tag") { logger.info("Some message") } - expect(Appsignal::Extension).to have_received(:log) - .with( - "group", - 3, - 3, - "[Request tag] [Second tag] [First message] [My other tag] Some message\n", - Appsignal::Utils::Data.generate({}) - ) + describe "with tags from Rails 8 application.config.log_tags" do + def perform + logger.push_tags("Request tag", "Second tag") + logger.tagged("First message", "My other tag") { logger.info("Some message") } + end + + it "in agent mode", :agent_mode do + start_agent + allow(Appsignal::Extension).to receive(:log) + perform + expect(Appsignal::Extension).to have_received(:log) + .with( + "group", + 3, + 3, + "[Request tag] [Second tag] [First message] [My other tag] Some message\n", + Appsignal::Utils::Data.generate({}) + ) + end + + it "in collector mode", :collector_mode do + start_collector_agent + allow(Appsignal::Logger::OpenTelemetryBackend).to receive(:emit) + perform + expect(Appsignal::Logger::OpenTelemetryBackend).to have_received(:emit) + .with( + "group", + ::Logger::INFO, + Appsignal::Logger::AUTODETECT, + "[Request tag] [Second tag] [First message] [My other tag] Some message\n", + {} + ) + end end - it "clears all tags with clear_tags!" do - allow(Appsignal::Extension).to receive(:log) - - # This is how Rails sets the `log_tags` values - logger.push_tags(["Request tag", "Second tag"]) - logger.tagged("First message", "My other tag") { logger.info("Some message") } - expect(Appsignal::Extension).to have_received(:log) - .with( - "group", - 3, - 3, - "[Request tag] [Second tag] [First message] [My other tag] Some message\n", - Appsignal::Utils::Data.generate({}) - ) - - logger.clear_tags! - logger.tagged("First message", "My other tag") { logger.info("Some message") } - expect(Appsignal::Extension).to have_received(:log) - .with( - "group", - 3, - 3, - "[First message] [My other tag] Some message\n", - Appsignal::Utils::Data.generate({}) - ) + describe "clearing all tags with clear_tags!" do + it "in agent mode", :agent_mode do + start_agent + allow(Appsignal::Extension).to receive(:log) + + logger.push_tags(["Request tag", "Second tag"]) + logger.tagged("First message", "My other tag") { logger.info("Some message") } + expect(Appsignal::Extension).to have_received(:log) + .with( + "group", + 3, + 3, + "[Request tag] [Second tag] [First message] [My other tag] Some message\n", + Appsignal::Utils::Data.generate({}) + ) + + logger.clear_tags! + logger.tagged("First message", "My other tag") { logger.info("Some message") } + expect(Appsignal::Extension).to have_received(:log) + .with( + "group", + 3, + 3, + "[First message] [My other tag] Some message\n", + Appsignal::Utils::Data.generate({}) + ) + end + + it "in collector mode", :collector_mode do + start_collector_agent + allow(Appsignal::Logger::OpenTelemetryBackend).to receive(:emit) + + logger.push_tags(["Request tag", "Second tag"]) + logger.tagged("First message", "My other tag") { logger.info("Some message") } + expect(Appsignal::Logger::OpenTelemetryBackend).to have_received(:emit) + .with( + "group", + ::Logger::INFO, + Appsignal::Logger::AUTODETECT, + "[Request tag] [Second tag] [First message] [My other tag] Some message\n", + {} + ) + + logger.clear_tags! + logger.tagged("First message", "My other tag") { logger.info("Some message") } + expect(Appsignal::Logger::OpenTelemetryBackend).to have_received(:emit) + .with( + "group", + ::Logger::INFO, + Appsignal::Logger::AUTODETECT, + "[First message] [My other tag] Some message\n", + {} + ) + end end - it "accepts tags in #tagged as an array" do - expect(Appsignal::Extension).to receive(:log) - .with( - "group", - 3, - 3, - "[My tag] [My other tag] Some message\n", - Appsignal::Utils::Data.generate({}) - ) + describe "with tags passed as an array" do + def perform + logger.tagged(["My tag", "My other tag"]) do + logger.info("Some message") + end + end + + it "in agent mode", :agent_mode do + start_agent + expect(Appsignal::Extension).to receive(:log) + .with( + "group", + 3, + 3, + "[My tag] [My other tag] Some message\n", + Appsignal::Utils::Data.generate({}) + ) + perform + end - logger.tagged(["My tag", "My other tag"]) do - logger.info("Some message") + it "in collector mode", :collector_mode do + start_collector_agent + expect(Appsignal::Logger::OpenTelemetryBackend).to receive(:emit) + .with( + "group", + ::Logger::INFO, + Appsignal::Logger::AUTODETECT, + "[My tag] [My other tag] Some message\n", + {} + ) + perform end end @@ -136,87 +276,191 @@ # is present. if !DependencyHelper.rails_present? || DependencyHelper.rails7_present? describe "when calling #tagged without a block" do - it "returns a new logger with the tags added" do - expect(Appsignal::Extension).to receive(:log) - .with( - "group", - 3, - 3, - "[My tag] [My other tag] Some message\n", - Appsignal::Utils::Data.generate({}) - ) + describe "returns a new logger with the tags added" do + def perform + logger.tagged("My tag", "My other tag").info("Some message") + end - logger.tagged("My tag", "My other tag").info("Some message") + it "in agent mode", :agent_mode do + start_agent + expect(Appsignal::Extension).to receive(:log) + .with( + "group", + 3, + 3, + "[My tag] [My other tag] Some message\n", + Appsignal::Utils::Data.generate({}) + ) + perform + end + + it "in collector mode", :collector_mode do + start_collector_agent + expect(Appsignal::Logger::OpenTelemetryBackend).to receive(:emit) + .with( + "group", + ::Logger::INFO, + Appsignal::Logger::AUTODETECT, + "[My tag] [My other tag] Some message\n", + {} + ) + perform + end end - it "does not modify the original logger" do - expect(Appsignal::Extension).to receive(:log) - .with( - "group", - 3, - 3, - "[My tag] [My other tag] Some message\n", - Appsignal::Utils::Data.generate({}) - ) + describe "does not modify the original logger" do + it "in agent mode", :agent_mode do + start_agent + expect(Appsignal::Extension).to receive(:log) + .with( + "group", + 3, + 3, + "[My tag] [My other tag] Some message\n", + Appsignal::Utils::Data.generate({}) + ) - new_logger = logger.tagged("My tag", "My other tag") - new_logger.info("Some message") + new_logger = logger.tagged("My tag", "My other tag") + new_logger.info("Some message") - expect(Appsignal::Extension).to receive(:log) - .with( - "group", - 3, - 3, - "Some message\n", - Appsignal::Utils::Data.generate({}) - ) + expect(Appsignal::Extension).to receive(:log) + .with( + "group", + 3, + 3, + "Some message\n", + Appsignal::Utils::Data.generate({}) + ) - logger.info("Some message") + logger.info("Some message") + end + + it "in collector mode", :collector_mode do + start_collector_agent + expect(Appsignal::Logger::OpenTelemetryBackend).to receive(:emit) + .with( + "group", + ::Logger::INFO, + Appsignal::Logger::AUTODETECT, + "[My tag] [My other tag] Some message\n", + {} + ) + + new_logger = logger.tagged("My tag", "My other tag") + new_logger.info("Some message") + + expect(Appsignal::Logger::OpenTelemetryBackend).to receive(:emit) + .with( + "group", + ::Logger::INFO, + Appsignal::Logger::AUTODETECT, + "Some message\n", + {} + ) + + logger.info("Some message") + end end - it "can be chained" do - expect(Appsignal::Extension).to receive(:log) - .with( - "group", - 3, - 3, - "[My tag] [My other tag] [My third tag] Some message\n", - Appsignal::Utils::Data.generate({}) - ) + describe "can be chained" do + def perform + logger.tagged("My tag", "My other tag").tagged("My third tag").info("Some message") + end - logger.tagged("My tag", "My other tag").tagged("My third tag").info("Some message") + it "in agent mode", :agent_mode do + start_agent + expect(Appsignal::Extension).to receive(:log) + .with( + "group", + 3, + 3, + "[My tag] [My other tag] [My third tag] Some message\n", + Appsignal::Utils::Data.generate({}) + ) + perform + end + + it "in collector mode", :collector_mode do + start_collector_agent + expect(Appsignal::Logger::OpenTelemetryBackend).to receive(:emit) + .with( + "group", + ::Logger::INFO, + Appsignal::Logger::AUTODETECT, + "[My tag] [My other tag] [My third tag] Some message\n", + {} + ) + perform + end end - it "can be chained before a block invocation" do - expect(Appsignal::Extension).to receive(:log) - .with( - "group", - 3, - 3, - "[My tag] [My other tag] [My third tag] Some message\n", - Appsignal::Utils::Data.generate({}) - ) + describe "can be chained before a block invocation" do + def perform + # Use the logger passed to the block: the logger returned from + # the first #tagged invocation is a new instance. + logger.tagged("My tag", "My other tag").tagged("My third tag") do |logger| + logger.info("Some message") + end + end - # We must explicitly use the logger passed to the block, - # as the logger returned from the first #tagged invocation - # is a new instance of the logger. - logger.tagged("My tag", "My other tag").tagged("My third tag") do |logger| - logger.info("Some message") + it "in agent mode", :agent_mode do + start_agent + expect(Appsignal::Extension).to receive(:log) + .with( + "group", + 3, + 3, + "[My tag] [My other tag] [My third tag] Some message\n", + Appsignal::Utils::Data.generate({}) + ) + perform + end + + it "in collector mode", :collector_mode do + start_collector_agent + expect(Appsignal::Logger::OpenTelemetryBackend).to receive(:emit) + .with( + "group", + ::Logger::INFO, + Appsignal::Logger::AUTODETECT, + "[My tag] [My other tag] [My third tag] Some message\n", + {} + ) + perform end end - it "can be chained after a block invocation" do - expect(Appsignal::Extension).to receive(:log) - .with( - "group", - 3, - 3, - "[My tag] [My other tag] [My third tag] Some message\n", - Appsignal::Utils::Data.generate({}) - ) + describe "can be chained after a block invocation" do + def perform + logger.tagged("My tag", "My other tag") do + logger.tagged("My third tag").info("Some message") + end + end - logger.tagged("My tag", "My other tag") do - logger.tagged("My third tag").info("Some message") + it "in agent mode", :agent_mode do + start_agent + expect(Appsignal::Extension).to receive(:log) + .with( + "group", + 3, + 3, + "[My tag] [My other tag] [My third tag] Some message\n", + Appsignal::Utils::Data.generate({}) + ) + perform + end + + it "in collector mode", :collector_mode do + start_collector_agent + expect(Appsignal::Logger::OpenTelemetryBackend).to receive(:emit) + .with( + "group", + ::Logger::INFO, + Appsignal::Logger::AUTODETECT, + "[My tag] [My other tag] [My third tag] Some message\n", + {} + ) + perform end end end @@ -228,258 +472,503 @@ let(:logs) { log_contents(log_stream) } let(:logger) { Appsignal::Logger.new("group", :level => ::Logger::DEBUG) } - before do - Appsignal.internal_logger = test_logger(log_stream) - end + before do + Appsignal.internal_logger = test_logger(log_stream) + end + + it "should not create a logger with a nil group" do + expect do + Appsignal::Logger.new(nil) + end.to raise_error(TypeError) + end + + describe "format validation" do + # Constructor-only behaviour, independent of the active backend, so it + # should hold identically whether agent or collector mode booted. + describe "the documented format constants" do + it_in_both_modes do + [ + Appsignal::Logger::PLAINTEXT, + Appsignal::Logger::LOGFMT, + Appsignal::Logger::JSON, + Appsignal::Logger::AUTODETECT + ].each do |format| + expect(Appsignal.internal_logger).not_to receive(:warn) + logger = Appsignal::Logger.new("group", :format => format) + expect(logger.instance_variable_get(:@format)).to eq(format) + end + end + end + + describe "an unknown format" do + it_in_both_modes do + expect(Appsignal.internal_logger).to receive(:warn) + .with(/Unknown Appsignal::Logger format 99; falling back to AUTODETECT/) + + logger = Appsignal::Logger.new("group", :format => 99) + expect(logger.instance_variable_get(:@format)).to eq(Appsignal::Logger::AUTODETECT) + end + end + end + + describe "#add" do + describe "with a level and message" do + def perform + logger.add(::Logger::INFO, "Log message") + end + + it "in agent mode", :agent_mode do + start_agent + expect(Appsignal::Extension).to receive(:log) + .with("group", 3, 3, "Log message", instance_of(Appsignal::Extension::Data)) + perform + end + + it "in collector mode", :collector_mode do + start_collector_agent + expect(Appsignal::Logger::OpenTelemetryBackend).to receive(:emit) + .with("group", ::Logger::INFO, Appsignal::Logger::AUTODETECT, "Log message", {}) + perform + end + end + + describe "with a non-string message" do + def perform + logger.add(::Logger::INFO, 123) + logger.add(::Logger::INFO, {}) + logger.add(::Logger::INFO, []) + end + + it "in agent mode", :agent_mode do + start_agent + expect(Appsignal::Extension).to receive(:log) + .with("group", 3, 3, "123", instance_of(Appsignal::Extension::Data)) + expect(Appsignal::Extension).to receive(:log) + .with("group", 3, 3, "{}", instance_of(Appsignal::Extension::Data)) + expect(Appsignal::Extension).to receive(:log) + .with("group", 3, 3, "[]", instance_of(Appsignal::Extension::Data)) + perform + end + + it "in collector mode", :collector_mode do + start_collector_agent + expect(Appsignal::Logger::OpenTelemetryBackend).to receive(:emit) + .with("group", ::Logger::INFO, Appsignal::Logger::AUTODETECT, "123", {}) + expect(Appsignal::Logger::OpenTelemetryBackend).to receive(:emit) + .with("group", ::Logger::INFO, Appsignal::Logger::AUTODETECT, "{}", {}) + expect(Appsignal::Logger::OpenTelemetryBackend).to receive(:emit) + .with("group", ::Logger::INFO, Appsignal::Logger::AUTODETECT, "[]", {}) + perform + end + end - it "should not create a logger with a nil group" do - expect do - Appsignal::Logger.new(nil) - end.to raise_error(TypeError) - end + describe "with a block" do + def perform + logger.add(::Logger::INFO) { "Log message" } + end - describe "#add" do - it "should log with a level and message" do - expect(Appsignal::Extension).to receive(:log) - .with("group", 3, 3, "Log message", instance_of(Appsignal::Extension::Data)) - logger.add(::Logger::INFO, "Log message") - end + it "in agent mode", :agent_mode do + start_agent + expect(Appsignal::Extension).to receive(:log) + .with("group", 3, 3, "Log message", instance_of(Appsignal::Extension::Data)) + perform + end - it "calls #to_s on the message if it is not a string" do - expect(Appsignal::Extension).to receive(:log) - .with("group", 3, 3, "123", instance_of(Appsignal::Extension::Data)) - expect(Appsignal::Extension).to receive(:log) - .with("group", 3, 3, "{}", instance_of(Appsignal::Extension::Data)) - expect(Appsignal::Extension).to receive(:log) - .with("group", 3, 3, "[]", instance_of(Appsignal::Extension::Data)) - logger.add(::Logger::INFO, 123) - logger.add(::Logger::INFO, {}) - logger.add(::Logger::INFO, []) + it "in collector mode", :collector_mode do + start_collector_agent + expect(Appsignal::Logger::OpenTelemetryBackend).to receive(:emit) + .with("group", ::Logger::INFO, Appsignal::Logger::AUTODETECT, "Log message", {}) + perform + end end - it "should log with a block" do - expect(Appsignal::Extension).to receive(:log) - .with("group", 3, 3, "Log message", instance_of(Appsignal::Extension::Data)) - logger.add(::Logger::INFO) do - "Log message" + describe "with a level, message and group" do + def perform + logger.add(::Logger::INFO, "Log message", "other_group") end - end - it "should log with a level, message and group" do - expect(Appsignal::Extension).to receive(:log) - .with("other_group", 3, 3, "Log message", instance_of(Appsignal::Extension::Data)) - logger.add(::Logger::INFO, "Log message", "other_group") + it "in agent mode", :agent_mode do + start_agent + expect(Appsignal::Extension).to receive(:log) + .with("other_group", 3, 3, "Log message", instance_of(Appsignal::Extension::Data)) + perform + end + + it "in collector mode", :collector_mode do + start_collector_agent + expect(Appsignal::Logger::OpenTelemetryBackend).to receive(:emit) + .with("other_group", ::Logger::INFO, Appsignal::Logger::AUTODETECT, "Log message", {}) + perform + end end - context "with info log level" do + describe "with info log level" do let(:logger) { Appsignal::Logger.new("group", :level => ::Logger::INFO) } - it "should skip logging if the level is too low" do - expect(Appsignal::Extension).not_to receive(:log) - logger.add(::Logger::DEBUG, "Log message") + describe "when the call's level is too low" do + def perform + logger.add(::Logger::DEBUG, "Log message") + end + + it "in agent mode", :agent_mode do + start_agent + expect(Appsignal::Extension).not_to receive(:log) + perform + end + + it "in collector mode", :collector_mode do + start_collector_agent + expect(Appsignal::Logger::OpenTelemetryBackend).not_to receive(:emit) + perform + end end end - context "with the PLAINTEXT format set" do + describe "with the PLAINTEXT format set" do let(:logger) { Appsignal::Logger.new("group", :format => Appsignal::Logger::PLAINTEXT) } - it "should log and pass the format flag" do + def perform + logger.add(::Logger::INFO, "Log message") + end + + it "in agent mode", :agent_mode do + start_agent expect(Appsignal::Extension).to receive(:log) .with("group", 3, 0, "Log message", instance_of(Appsignal::Extension::Data)) - logger.add(::Logger::INFO, "Log message") + perform + end + + it "in collector mode", :collector_mode do + start_collector_agent + expect(Appsignal::Logger::OpenTelemetryBackend).to receive(:emit) + .with("group", ::Logger::INFO, Appsignal::Logger::PLAINTEXT, "Log message", {}) + perform end end - context "with the logfmt format set" do + describe "with the logfmt format set" do let(:logger) { Appsignal::Logger.new("group", :format => Appsignal::Logger::LOGFMT) } - it "should log and pass the format flag" do + def perform + logger.add(::Logger::INFO, "Log message") + end + + it "in agent mode", :agent_mode do + start_agent expect(Appsignal::Extension).to receive(:log) .with("group", 3, 1, "Log message", instance_of(Appsignal::Extension::Data)) - logger.add(::Logger::INFO, "Log message") + perform + end + + it "in collector mode", :collector_mode do + start_collector_agent + expect(Appsignal::Logger::OpenTelemetryBackend).to receive(:emit) + .with("group", ::Logger::INFO, Appsignal::Logger::LOGFMT, "Log message", {}) + perform end end - context "with the JSON format set" do + describe "with the JSON format set" do let(:logger) { Appsignal::Logger.new("group", :format => Appsignal::Logger::JSON) } - it "should log and pass the format flag" do + def perform + logger.add(::Logger::INFO, "Log message") + end + + it "in agent mode", :agent_mode do + start_agent expect(Appsignal::Extension).to receive(:log) .with("group", 3, 2, "Log message", instance_of(Appsignal::Extension::Data)) - logger.add(::Logger::INFO, "Log message") + perform + end + + it "in collector mode", :collector_mode do + start_collector_agent + expect(Appsignal::Logger::OpenTelemetryBackend).to receive(:emit) + .with("group", ::Logger::INFO, Appsignal::Logger::JSON, "Log message", {}) + perform end end - context "with a formatter set" do + describe "with a formatter set" do before do logger.formatter = proc do |_level, _timestamp, _appname, message| "formatted: '#{message}'" end end - it "should log with a level, message and group" do - expect(Appsignal::Extension).to receive(:log).with( - "other_group", - 3, - 3, - "formatted: 'Log message'", - instance_of(Appsignal::Extension::Data) - ) - logger.add(::Logger::INFO, "Log message", "other_group") - end + describe "logs with a level, message and group" do + def perform + logger.add(::Logger::INFO, "Log message", "other_group") + end - it "calls the formatter with the original message" do - expect(Appsignal::Extension).to receive(:log) - .with( - "group", + it "in agent mode", :agent_mode do + start_agent + expect(Appsignal::Extension).to receive(:log).with( + "other_group", 3, 3, - a_string_starting_with("formatted:"), + "formatted: 'Log message'", instance_of(Appsignal::Extension::Data) ) - expect(logger.formatter).to receive(:call) - .with(::Logger::INFO, instance_of(Time), "group", { :a => "b" }) - .and_call_original - logger.add(::Logger::INFO, { :a => "b" }) + perform + end + + it "in collector mode", :collector_mode do + start_collector_agent + expect(Appsignal::Logger::OpenTelemetryBackend).to receive(:emit).with( + "other_group", + ::Logger::INFO, + Appsignal::Logger::AUTODETECT, + "formatted: 'Log message'", + {} + ) + perform + end end - it "calls #to_s on the formatter output if it is not a string" do - expect(Appsignal::Extension).to receive(:log) - .with("group", 3, 3, "123", instance_of(Appsignal::Extension::Data)) - expect(logger.formatter).to receive(:call) - .with(::Logger::INFO, instance_of(Time), "group", 123) - .and_return(123) - logger.add(::Logger::INFO, 123) + describe "calls the formatter with the original message" do + def perform + logger.add(::Logger::INFO, { :a => "b" }) + end + + it "in agent mode", :agent_mode do + start_agent + expect(Appsignal::Extension).to receive(:log) + .with( + "group", + 3, + 3, + a_string_starting_with("formatted:"), + instance_of(Appsignal::Extension::Data) + ) + expect(logger.formatter).to receive(:call) + .with(::Logger::INFO, instance_of(Time), "group", { :a => "b" }) + .and_call_original + perform + end + + it "in collector mode", :collector_mode do + start_collector_agent + expect(Appsignal::Logger::OpenTelemetryBackend).to receive(:emit) + .with( + "group", + ::Logger::INFO, + Appsignal::Logger::AUTODETECT, + a_string_starting_with("formatted:"), + {} + ) + expect(logger.formatter).to receive(:call) + .with(::Logger::INFO, instance_of(Time), "group", { :a => "b" }) + .and_call_original + perform + end + end + + describe "calls #to_s on the formatter output if it is not a string" do + def perform + logger.add(::Logger::INFO, 123) + end + + it "in agent mode", :agent_mode do + start_agent + expect(Appsignal::Extension).to receive(:log) + .with("group", 3, 3, "123", instance_of(Appsignal::Extension::Data)) + expect(logger.formatter).to receive(:call) + .with(::Logger::INFO, instance_of(Time), "group", 123) + .and_return(123) + perform + end + + it "in collector mode", :collector_mode do + start_collector_agent + expect(Appsignal::Logger::OpenTelemetryBackend).to receive(:emit) + .with("group", ::Logger::INFO, Appsignal::Logger::AUTODETECT, "123", {}) + expect(logger.formatter).to receive(:call) + .with(::Logger::INFO, instance_of(Time), "group", 123) + .and_return(123) + perform + end end end end describe "#silence" do - it "calls the given block" do - num = 1 - - logger.silence do - num += 1 + describe "calls the given block" do + it_in_both_modes do + num = 1 + logger.silence { num += 1 } + expect(num).to eq(2) end - - expect(num).to eq(2) - expect(Appsignal::Extension).not_to receive(:log) end - it "silences the logger up to, but not including, the given level" do - # Expect not to receive info - expect(Appsignal::Extension).not_to receive(:log) - .with("group", 3, 3, "Log message", instance_of(Appsignal::Extension::Data)) + describe "silences the logger up to, but not including, the given level" do + def perform + logger.silence(::Logger::WARN) do + logger.info("Log message") + logger.warn("Log message") + end + end - # Expect to receive warn - expect(Appsignal::Extension).to receive(:log) - .with("group", 5, 3, "Log message", instance_of(Appsignal::Extension::Data)) + it "in agent mode", :agent_mode do + start_agent + expect(Appsignal::Extension).not_to receive(:log) + .with("group", 3, 3, "Log message", instance_of(Appsignal::Extension::Data)) + expect(Appsignal::Extension).to receive(:log) + .with("group", 5, 3, "Log message", instance_of(Appsignal::Extension::Data)) + perform + end - logger.silence(::Logger::WARN) do - logger.info("Log message") - logger.warn("Log message") + it "in collector mode", :collector_mode do + start_collector_agent + expect(Appsignal::Logger::OpenTelemetryBackend).not_to receive(:emit) + .with("group", ::Logger::INFO, Appsignal::Logger::AUTODETECT, "Log message", {}) + expect(Appsignal::Logger::OpenTelemetryBackend).to receive(:emit) + .with("group", ::Logger::WARN, Appsignal::Logger::AUTODETECT, "Log message", {}) + perform end end - it "silences the logger to error level by default" do - # Expect not to receive debug, info or warn - [2, 3, 5].each do |severity| - expect(Appsignal::Extension).not_to receive(:log) - .with("group", severity, 3, "Log message", instance_of(Appsignal::Extension::Data)) + describe "silences the logger to error level by default" do + def perform + logger.silence do + logger.debug("Log message") + logger.info("Log message") + logger.warn("Log message") + logger.error("Log message") + logger.fatal("Log message") + end end - # Expect to receive error and fatal - [6, 7].each do |severity| - expect(Appsignal::Extension).to receive(:log) - .with("group", severity, 3, "Log message", instance_of(Appsignal::Extension::Data)) + it "in agent mode", :agent_mode do + start_agent + [2, 3, 5].each do |severity| + expect(Appsignal::Extension).not_to receive(:log) + .with("group", severity, 3, "Log message", instance_of(Appsignal::Extension::Data)) + end + [6, 7].each do |severity| + expect(Appsignal::Extension).to receive(:log) + .with("group", severity, 3, "Log message", instance_of(Appsignal::Extension::Data)) + end + perform end - logger.silence do - logger.debug("Log message") - logger.info("Log message") - logger.warn("Log message") - logger.error("Log message") - logger.fatal("Log message") + it "in collector mode", :collector_mode do + start_collector_agent + [::Logger::DEBUG, ::Logger::INFO, ::Logger::WARN].each do |severity| + expect(Appsignal::Logger::OpenTelemetryBackend).not_to receive(:emit) + .with("group", severity, Appsignal::Logger::AUTODETECT, "Log message", {}) + end + [::Logger::ERROR, ::Logger::FATAL].each do |severity| + expect(Appsignal::Logger::OpenTelemetryBackend).to receive(:emit) + .with("group", severity, Appsignal::Logger::AUTODETECT, "Log message", {}) + end + perform end end end describe "#broadcast_to" do - it "broadcasts the message to the given logger" do - other_device = StringIO.new - other_logger = ::Logger.new(other_device) - - logger.broadcast_to(other_logger) + describe "broadcasts the message to the given logger" do + let(:other_device) { StringIO.new } + let(:other_logger) { ::Logger.new(other_device) } + before { logger.broadcast_to(other_logger) } - expect(Appsignal::Extension).to receive(:log) - .with("group", 3, 3, "Log message", instance_of(Appsignal::Extension::Data)) + def perform + logger.info("Log message") + expect(other_device.string).to include("INFO -- group: Log message") + end - logger.info("Log message") + it "in agent mode", :agent_mode do + start_agent + expect(Appsignal::Extension).to receive(:log) + .with("group", 3, 3, "Log message", instance_of(Appsignal::Extension::Data)) + perform + end - expect(other_device.string).to include("INFO -- group: Log message") + it "in collector mode", :collector_mode do + start_collector_agent + expect(Appsignal::Logger::OpenTelemetryBackend).to receive(:emit) + .with("group", ::Logger::INFO, Appsignal::Logger::AUTODETECT, "Log message", {}) + perform + end end - it "broadcasts the message to the given logger when it's below the log level" do - logger = Appsignal::Logger.new("group", :level => ::Logger::INFO) - - other_device = StringIO.new - other_logger = ::Logger.new(other_device) - - logger.broadcast_to(other_logger) + describe "broadcasts the message to the given logger when it's below the log level" do + let(:logger) { Appsignal::Logger.new("group", :level => ::Logger::INFO) } + let(:other_device) { StringIO.new } + let(:other_logger) { ::Logger.new(other_device) } + before { logger.broadcast_to(other_logger) } - expect(Appsignal::Extension).not_to receive(:log) + def perform + logger.debug("Log message") + expect(other_device.string).to include("DEBUG -- group: Log message") + end - logger.debug("Log message") + it "in agent mode", :agent_mode do + start_agent + expect(Appsignal::Extension).not_to receive(:log) + perform + end - expect(other_device.string).to include("DEBUG -- group: Log message") + it "in collector mode", :collector_mode do + start_collector_agent + expect(Appsignal::Logger::OpenTelemetryBackend).not_to receive(:emit) + perform + end end - it "does not broadcast the message to the given logger when silenced" do - other_device = StringIO.new - other_logger = ::Logger.new(other_device) + describe "does not broadcast the message to the given logger when silenced" do + let(:other_device) { StringIO.new } + let(:other_logger) { ::Logger.new(other_device) } + before { logger.broadcast_to(other_logger) } - logger.broadcast_to(other_logger) - - expect(Appsignal::Extension).not_to receive(:log) + def perform + logger.silence { logger.info("Log message") } + expect(other_device.string).to eq("") + end - logger.silence do - logger.info("Log message") + it "in agent mode", :agent_mode do + start_agent + expect(Appsignal::Extension).not_to receive(:log) + perform end - expect(other_device.string).to eq("") + it "in collector mode", :collector_mode do + start_collector_agent + expect(Appsignal::Logger::OpenTelemetryBackend).not_to receive(:emit) + perform + end end context "with a formatter" do - it "sets the formatter on broadcasted loggers that support it" do - other_device = StringIO.new - other_logger = ::Logger.new(other_device) + describe "sets the formatter on broadcasted loggers that support it" do + it_in_both_modes do + other_device = StringIO.new + other_logger = ::Logger.new(other_device) + logger.broadcast_to(other_logger) - logger.broadcast_to(other_logger) + formatter = proc { |_level, _timestamp, _appname, message| "custom: #{message}" } + logger.formatter = formatter - formatter = proc do |_level, _timestamp, _appname, message| - "custom: #{message}" + expect(logger.formatter).to eq(formatter) + expect(other_logger.formatter).to eq(formatter) end - - logger.formatter = formatter - - expect(logger.formatter).to eq(formatter) - expect(other_logger.formatter).to eq(formatter) end - it "does not raise an error when a broadcasted logger does not support formatter=" do - logger_without_formatter = double("logger without formatter") - allow(logger_without_formatter).to receive(:respond_to?).with(:formatter=).and_return(false) - allow(logger_without_formatter).to receive(:add) + describe "does not raise an error when a broadcasted logger does not support formatter=" do + it_in_both_modes do + logger_without_formatter = double("logger without formatter") + allow(logger_without_formatter).to receive(:respond_to?) + .with(:formatter=).and_return(false) + allow(logger_without_formatter).to receive(:add) - logger.broadcast_to(logger_without_formatter) + logger.broadcast_to(logger_without_formatter) - formatter = proc do |_level, _timestamp, _appname, message| - "custom: #{message}" + formatter = proc { |_level, _timestamp, _appname, message| "custom: #{message}" } + logger.formatter = formatter + expect(logger.formatter).to eq(formatter) end - - # Does not raise an error - logger.formatter = formatter - expect(logger.formatter).to eq(formatter) end end @@ -494,71 +983,149 @@ ActiveSupport::TaggedLogging.new(appsignal_logger) end - it "broadcasts a tagged message to the given logger" do - expect(Appsignal::Extension).to receive(:log) - .with( - "group", - 3, - 3, - "[My tag] [My other tag] Some message\n", - Appsignal::Utils::Data.generate({}) - ) + describe "broadcasts a tagged message to the given logger" do + def perform + logger.tagged("My tag", "My other tag") do + logger.info("Some message") + end + expect(other_stream.string).to eq("[My tag] [My other tag] Some message\n") + end - logger.tagged("My tag", "My other tag") do - logger.info("Some message") + it "in agent mode", :agent_mode do + start_agent + expect(Appsignal::Extension).to receive(:log) + .with( + "group", + 3, + 3, + "[My tag] [My other tag] Some message\n", + Appsignal::Utils::Data.generate({}) + ) + perform end - expect(other_stream.string) - .to eq("[My tag] [My other tag] Some message\n") + it "in collector mode", :collector_mode do + start_collector_agent + expect(Appsignal::Logger::OpenTelemetryBackend).to receive(:emit) + .with( + "group", + ::Logger::INFO, + Appsignal::Logger::AUTODETECT, + "[My tag] [My other tag] Some message\n", + {} + ) + perform + end end end end end [ - ["debug", 2, ::Logger::INFO], - ["info", 3, ::Logger::WARN], - ["warn", 5, ::Logger::ERROR], - ["error", 6, ::Logger::FATAL], - ["fatal", 7, nil] + ["debug", 2, ::Logger::DEBUG, ::Logger::INFO], + ["info", 3, ::Logger::INFO, ::Logger::WARN], + ["warn", 5, ::Logger::WARN, ::Logger::ERROR], + ["error", 6, ::Logger::ERROR, ::Logger::FATAL], + ["fatal", 7, ::Logger::FATAL, nil] ].each do |permutation| - method, extension_level, higher_level = permutation + method, extension_level, logger_level, higher_level = permutation describe "##{method}" do - it "should log with a message" do - expect(Appsignal::Utils::Data).to receive(:generate) - .with({ :attribute => "value" }) - .and_call_original - expect(Appsignal::Extension).to receive(:log) - .with("group", extension_level, 3, "Log message", instance_of(Appsignal::Extension::Data)) + describe "with a message and attributes" do + # `define_method` (rather than `def`) so the block captures the + # enclosing closure -- `method` is a block-local of the + # `.each do |permutation|` loop and isn't visible from `def`. + define_method(:perform) do + logger.send(method, "Log message", :attribute => "value") + end + + it "in agent mode", :agent_mode do + start_agent + expect(Appsignal::Utils::Data).to receive(:generate) + .with({ :attribute => "value" }) + .and_call_original + expect(Appsignal::Extension).to receive(:log) + .with( + "group", extension_level, 3, "Log message", + instance_of(Appsignal::Extension::Data) + ) + perform + end - logger.send(method, "Log message", :attribute => "value") + it "in collector mode", :collector_mode do + start_collector_agent + expect(Appsignal::Logger::OpenTelemetryBackend).to receive(:emit) + .with( + "group", + logger_level, + Appsignal::Logger::AUTODETECT, + "Log message", + { :attribute => "value" } + ) + perform + end end - it "should log with a block" do - expect(Appsignal::Utils::Data).to receive(:generate) - .with({}) - .and_call_original - expect(Appsignal::Extension).to receive(:log) - .with("group", extension_level, 3, "Log message", instance_of(Appsignal::Extension::Data)) + describe "with a block" do + define_method(:perform) do + logger.send(method) { "Log message" } + end + + it "in agent mode", :agent_mode do + start_agent + expect(Appsignal::Utils::Data).to receive(:generate) + .with({}) + .and_call_original + expect(Appsignal::Extension).to receive(:log) + .with( + "group", extension_level, 3, "Log message", + instance_of(Appsignal::Extension::Data) + ) + perform + end - logger.send(method) do - "Log message" + it "in collector mode", :collector_mode do + start_collector_agent + expect(Appsignal::Logger::OpenTelemetryBackend).to receive(:emit) + .with("group", logger_level, Appsignal::Logger::AUTODETECT, "Log message", {}) + perform end end - it "should return with a nil message" do - expect(Appsignal::Extension).not_to receive(:log) - logger.send(method) + describe "with a nil message" do + define_method(:perform) { logger.send(method) } + + it "in agent mode", :agent_mode do + start_agent + expect(Appsignal::Extension).not_to receive(:log) + perform + end + + it "in collector mode", :collector_mode do + start_collector_agent + expect(Appsignal::Logger::OpenTelemetryBackend).not_to receive(:emit) + perform + end end if higher_level context "with a lower log level" do let(:logger) { Appsignal::Logger.new("group", :level => higher_level) } - it "should skip logging if the level is too low" do - expect(Appsignal::Extension).not_to receive(:log) - logger.send(method, "Log message") + describe "skips logging when the level is too low" do + define_method(:perform) { logger.send(method, "Log message") } + + it "in agent mode", :agent_mode do + start_agent + expect(Appsignal::Extension).not_to receive(:log) + perform + end + + it "in collector mode", :collector_mode do + start_collector_agent + expect(Appsignal::Logger::OpenTelemetryBackend).not_to receive(:emit) + perform + end end end end @@ -566,108 +1133,212 @@ context "with a formatter set" do before do Timecop.freeze(Time.local(2023)) - logger.formatter = logger.formatter = proc do |_level, timestamp, _appname, message| - # This line replicates the behaviour of the Ruby default Logger::Formatter - # which expects a timestamp object as a second argument - # https://github.com/ruby/ruby/blob/master/lib/logger/formatter.rb#L15-L17 + # The Ruby default Logger::Formatter expects a timestamp object as + # the second argument (https://github.com/ruby/ruby/blob/master/lib/logger/formatter.rb#L15-L17). + logger.formatter = proc do |_level, timestamp, _appname, message| time = timestamp.strftime("%Y-%m-%dT%H:%M:%S.%6N") "formatted: #{time} '#{message}'" end end - after do - Timecop.return - end + after { Timecop.return } + + describe "logs the formatted message" do + define_method(:perform) { logger.send(method, "Log message") } + + it "in agent mode", :agent_mode do + start_agent + expect(Appsignal::Extension).to receive(:log) + .with( + "group", + extension_level, + 3, + "formatted: 2023-01-01T00:00:00.000000 'Log message'", + instance_of(Appsignal::Extension::Data) + ) + perform + end - it "should log with a level, message and group" do - expect(Appsignal::Extension).to receive(:log) - .with( - "group", - extension_level, - 3, - "formatted: 2023-01-01T00:00:00.000000 'Log message'", - instance_of(Appsignal::Extension::Data) - ) - logger.send(method, "Log message") + it "in collector mode", :collector_mode do + start_collector_agent + expect(Appsignal::Logger::OpenTelemetryBackend).to receive(:emit) + .with( + "group", + logger_level, + Appsignal::Logger::AUTODETECT, + "formatted: 2023-01-01T00:00:00.000000 'Log message'", + {} + ) + perform + end end end end end describe "a logger with default attributes" do - it "adds the attributes when a message is logged" do - logger = Appsignal::Logger.new("group", :attributes => { :some_key => "some_value" }) + let(:logger) { Appsignal::Logger.new("group", :attributes => { :some_key => "some_value" }) } - expect(Appsignal::Extension).to receive(:log).with("group", 6, 3, "Some message", - Appsignal::Utils::Data.generate({ :other_key => "other_value", :some_key => "some_value" })) - logger.error("Some message", { :other_key => "other_value" }) + describe "adds the attributes when a message is logged" do + def perform + logger.error("Some message", { :other_key => "other_value" }) + end + + it "in agent mode", :agent_mode do + start_agent + expect(Appsignal::Extension).to receive(:log).with( + "group", 6, 3, "Some message", + Appsignal::Utils::Data.generate( + { :other_key => "other_value", :some_key => "some_value" } + ) + ) + perform + end + + it "in collector mode", :collector_mode do + start_collector_agent + expect(Appsignal::Logger::OpenTelemetryBackend).to receive(:emit).with( + "group", + ::Logger::ERROR, + Appsignal::Logger::AUTODETECT, + "Some message", + { :other_key => "other_value", :some_key => "some_value" } + ) + perform + end end - it "does not modify the original attribute hashes passed" do - default_attributes = { :some_key => "some_value" } - logger = Appsignal::Logger.new("group", :attributes => default_attributes) + describe "does not modify the original attribute hashes passed" do + it_in_both_modes do + default_attributes = { :some_key => "some_value" } + logger = Appsignal::Logger.new("group", :attributes => default_attributes) - line_attributes = { :other_key => "other_value" } - logger.error("Some message", line_attributes) + line_attributes = { :other_key => "other_value" } + logger.error("Some message", line_attributes) - expect(default_attributes).to eq({ :some_key => "some_value" }) - expect(line_attributes).to eq({ :other_key => "other_value" }) + expect(default_attributes).to eq({ :some_key => "some_value" }) + expect(line_attributes).to eq({ :other_key => "other_value" }) + end end - it "prioritises line attributes over default attributes" do - logger = Appsignal::Logger.new("group", :attributes => { :some_key => "some_value" }) + describe "prioritises line attributes over default attributes" do + def perform + logger.error("Some message", { :some_key => "other_value" }) + end - expect(Appsignal::Extension).to receive(:log).with("group", 6, 3, "Some message", - Appsignal::Utils::Data.generate({ :some_key => "other_value" })) + it "in agent mode", :agent_mode do + start_agent + expect(Appsignal::Extension).to receive(:log).with( + "group", 6, 3, "Some message", + Appsignal::Utils::Data.generate({ :some_key => "other_value" }) + ) + perform + end - logger.error("Some message", { :some_key => "other_value" }) + it "in collector mode", :collector_mode do + start_collector_agent + expect(Appsignal::Logger::OpenTelemetryBackend).to receive(:emit).with( + "group", + ::Logger::ERROR, + Appsignal::Logger::AUTODETECT, + "Some message", + { :some_key => "other_value" } + ) + perform + end end - it "adds the default attributes when #add is called" do - logger = Appsignal::Logger.new("group", :attributes => { :some_key => "some_value" }) + describe "adds the default attributes when #add is called" do + def perform + logger.add(::Logger::INFO, "Log message") + end + + it "in agent mode", :agent_mode do + start_agent + expect(Appsignal::Extension).to receive(:log).with( + "group", 3, 3, "Log message", + Appsignal::Utils::Data.generate({ :some_key => "some_value" }) + ) + perform + end - expect(Appsignal::Extension).to receive(:log).with("group", 3, 3, "Log message", - Appsignal::Utils::Data.generate({ :some_key => "some_value" })) - logger.add(::Logger::INFO, "Log message") + it "in collector mode", :collector_mode do + start_collector_agent + expect(Appsignal::Logger::OpenTelemetryBackend).to receive(:emit).with( + "group", + ::Logger::INFO, + Appsignal::Logger::AUTODETECT, + "Log message", + { :some_key => "some_value" } + ) + perform + end end end describe "#error with exception object" do - it "logs the exception class and its message" do - error = - begin - raise ExampleStandardError, "oh no!" - rescue => e - # This makes the exception include a backtrace, so we can assert its - # first line is included - e - end - expect(Appsignal::Extension).to receive(:log) - .with( - "group", - 6, - 3, - a_string_matching(/ExampleStandardError: oh no! \(.*logger_spec.rb.*\)/), - instance_of(Appsignal::Extension::Data) - ) - logger.error(error) + describe "logs the exception class and its message" do + let(:error) do + raise ExampleStandardError, "oh no!" + rescue => e + # Re-raise capture so the exception carries a backtrace, letting + # us assert that its first line is part of the logged string. + e + end + + def perform + logger.error(error) + end + + it "in agent mode", :agent_mode do + start_agent + expect(Appsignal::Extension).to receive(:log) + .with( + "group", + 6, + 3, + a_string_matching(/ExampleStandardError: oh no! \(.*logger_spec.rb.*\)/), + instance_of(Appsignal::Extension::Data) + ) + perform + end + + it "in collector mode", :collector_mode do + start_collector_agent + expect(Appsignal::Logger::OpenTelemetryBackend).to receive(:emit) + .with( + "group", + ::Logger::ERROR, + Appsignal::Logger::AUTODETECT, + a_string_matching(/ExampleStandardError: oh no! \(.*logger_spec.rb.*\)/), + {} + ) + perform + end end end describe "#<<" do - it "writes an info message and returns the number of characters written" do - expect(Appsignal::Extension).to receive(:log) - .with( - "group", - 3, - 3, - "hello there", - instance_of(Appsignal::Extension::Data) - ) + describe "writes an info message and returns the number of characters written" do + def perform + message = "hello there" + result = logger << message + expect(result).to eq(message.length) + end - message = "hello there" - result = logger << message - expect(result).to eq(message.length) + it "in agent mode", :agent_mode do + start_agent + expect(Appsignal::Extension).to receive(:log) + .with("group", 3, 3, "hello there", instance_of(Appsignal::Extension::Data)) + perform + end + + it "in collector mode", :collector_mode do + start_collector_agent + expect(Appsignal::Logger::OpenTelemetryBackend).to receive(:emit) + .with("group", ::Logger::INFO, Appsignal::Logger::AUTODETECT, "hello there", {}) + perform + end end context "with a formatter set" do @@ -677,18 +1348,29 @@ end end - # This documents how the logger currently behaves in this scenario. - # Normally a Ruby logger would ignore the logger. - # We would recommend not setting a logger on the AppSignal logger. - it "logs a formatted message" do - expect(Appsignal::Extension).to receive(:log).with( - "group", - 3, - 3, - "formatted: 'Log message'", - instance_of(Appsignal::Extension::Data) - ) - logger << "Log message" + # Documents how the logger currently behaves: a Ruby logger would + # normally bypass the formatter for `<<`. We recommend against setting + # a formatter on the AppSignal logger. + describe "logs a formatted message" do + def perform + logger << "Log message" + end + + it "in agent mode", :agent_mode do + start_agent + expect(Appsignal::Extension).to receive(:log).with( + "group", 3, 3, "formatted: 'Log message'", instance_of(Appsignal::Extension::Data) + ) + perform + end + + it "in collector mode", :collector_mode do + start_collector_agent + expect(Appsignal::Logger::OpenTelemetryBackend).to receive(:emit).with( + "group", ::Logger::INFO, Appsignal::Logger::AUTODETECT, "formatted: 'Log message'", {} + ) + perform + end end end end diff --git a/spec/lib/appsignal/metrics/opentelemetry_backend_spec.rb b/spec/lib/appsignal/metrics/opentelemetry_backend_spec.rb new file mode 100644 index 000000000..984ede8a3 --- /dev/null +++ b/spec/lib/appsignal/metrics/opentelemetry_backend_spec.rb @@ -0,0 +1,151 @@ +# frozen_string_literal: true + +require "opentelemetry/sdk" if DependencyHelper.opentelemetry_present? +require "opentelemetry-metrics-sdk" if DependencyHelper.opentelemetry_present? + +describe Appsignal::Metrics::OpenTelemetryBackend, :if => DependencyHelper.opentelemetry_present? do + let(:exporter) { ::OpenTelemetry::SDK::Metrics::Export::InMemoryMetricPullExporter.new } + let(:meter_provider) do + provider = ::OpenTelemetry::SDK::Metrics::MeterProvider.new + provider.add_metric_reader(exporter) + provider + end + + before do + ::OpenTelemetry.meter_provider = meter_provider + described_class.reset! + end + + after { described_class.reset! } + + def collect_snapshots + exporter.pull + snapshots = exporter.metric_snapshots.dup + exporter.reset + snapshots + end + + def snapshot_for(name) + collect_snapshots.find { |snapshot| snapshot.name == name } + end + + describe ".set_gauge" do + it "emits a Gauge snapshot with the recorded value and attributes" do + described_class.set_gauge("my_gauge", 42.5, { :host => "node-1" }) + + snapshot = snapshot_for("my_gauge") + expect(snapshot).not_to be_nil + expect(snapshot.instrument_kind).to eq(:gauge) + expect(snapshot.data_points.first.value).to eq(42.5) + expect(snapshot.data_points.first.attributes).to eq("host" => "node-1") + end + + it "coerces integer values to float" do + described_class.set_gauge("my_gauge", 10, {}) + + snapshot = snapshot_for("my_gauge") + expect(snapshot.data_points.first.value).to eq(10.0) + end + + it "coerces a symbol metric name to a string" do + described_class.set_gauge(:my_gauge, 1.0, {}) + + # snapshot_for matches on the string name, so this only finds the + # snapshot if the symbol name was coerced. + expect(snapshot_for("my_gauge")).not_to be_nil + end + end + + describe ".increment_counter" do + it "emits an UpDownCounter snapshot whose sum tracks repeated calls" do + described_class.increment_counter("my_counter", 1, { :endpoint => "/" }) + described_class.increment_counter("my_counter", 3, { :endpoint => "/" }) + + snapshot = snapshot_for("my_counter") + expect(snapshot).not_to be_nil + expect(snapshot.instrument_kind).to eq(:up_down_counter) + expect(snapshot.data_points.first.value).to eq(4.0) + expect(snapshot.data_points.first.attributes).to eq("endpoint" => "/") + end + + it "accepts negative increments" do + described_class.increment_counter("my_counter", -5, {}) + + snapshot = snapshot_for("my_counter") + expect(snapshot.data_points.first.value).to eq(-5.0) + end + end + + describe ".add_distribution_value" do + it "emits a Histogram snapshot capturing the recorded values" do + described_class.add_distribution_value("my_distribution", 0.1, { :route => "/login" }) + described_class.add_distribution_value("my_distribution", 0.2, { :route => "/login" }) + + snapshot = snapshot_for("my_distribution") + expect(snapshot).not_to be_nil + expect(snapshot.instrument_kind).to eq(:histogram) + data_point = snapshot.data_points.first + expect(data_point.count).to eq(2) + expect(data_point.sum).to be_within(0.0001).of(0.3) + expect(data_point.attributes).to eq("route" => "/login") + end + end + + describe "attribute coercion" do + it "stringifies symbol keys and symbol values, preserves primitives" do + described_class.set_gauge( + "my_gauge", + 1.0, + { + :string => "value", + "symbol" => :sym, + :integer => 42, + :float => 1.5, + :truthy => true, + :falsy => false + } + ) + + attrs = snapshot_for("my_gauge").data_points.first.attributes + expect(attrs).to eq( + "string" => "value", + "symbol" => "sym", + "integer" => 42, + "float" => 1.5, + "truthy" => true, + "falsy" => false + ) + end + + it "coerces other tag value types via to_s" do + described_class.set_gauge("my_gauge", 1.0, { :time => Time.utc(2026, 1, 2, 3, 4, 5) }) + + attrs = snapshot_for("my_gauge").data_points.first.attributes + expect(attrs["time"]).to eq("2026-01-02 03:04:05 UTC") + end + + it "treats an empty tags hash as no attributes" do + described_class.increment_counter("my_counter", 1, {}) + + attrs = snapshot_for("my_counter").data_points.first.attributes + expect(attrs).to eq({}) + end + end + + describe "instrument caching" do + it "reuses the same instrument across calls for a given metric name" do + meter = ::OpenTelemetry.meter_provider.meter("appsignal-helpers") + expect(meter).to receive(:create_up_down_counter).once.and_call_original + + described_class.increment_counter("cached_counter", 1, {}) + described_class.increment_counter("cached_counter", 1, {}) + end + + it "uses the 'appsignal-helpers' meter scope name" do + described_class.set_gauge("scoped_gauge", 1.0, {}) + + snapshot = snapshot_for("scoped_gauge") + expect(snapshot.instrumentation_scope.name).to eq("appsignal-helpers") + end + end +end diff --git a/spec/lib/appsignal/opentelemetry/error_type_spec.rb b/spec/lib/appsignal/opentelemetry/error_type_spec.rb new file mode 100644 index 000000000..2bd5d7b0b --- /dev/null +++ b/spec/lib/appsignal/opentelemetry/error_type_spec.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +describe Appsignal::OpenTelemetry::ErrorType do + describe ".attributes_for" do + it "uses an exception class name as it is" do + expect(described_class.attributes_for("RuntimeError")).to eq( + "error.type" => "RuntimeError" + ) + end + + it "uses a datastore's error code as it is" do + expect(described_class.attributes_for("NamespaceNotFound")).to eq( + "error.type" => "NamespaceNotFound" + ) + end + + # An anonymous exception class has no name, and a datastore does not always + # report a code, so there has to be something to fall back to. + it "reports a failure without a name as _OTHER" do + expect(described_class.attributes_for(nil)).to eq("error.type" => "_OTHER") + expect(described_class.attributes_for("")).to eq("error.type" => "_OTHER") + end + + it "reports an anonymous exception class as _OTHER" do + error_class = Class.new(StandardError) + + expect(described_class.attributes_for(error_class.name)).to eq( + "error.type" => "_OTHER" + ) + end + end +end diff --git a/spec/lib/appsignal/opentelemetry/http_client_request_spec.rb b/spec/lib/appsignal/opentelemetry/http_client_request_spec.rb new file mode 100644 index 000000000..a900776c0 --- /dev/null +++ b/spec/lib/appsignal/opentelemetry/http_client_request_spec.rb @@ -0,0 +1,124 @@ +# frozen_string_literal: true + +describe Appsignal::OpenTelemetry::HttpClientRequest do + describe ".attributes_for" do + def attributes_for(**args) + described_class.attributes_for(**args) + end + + it "describes the method, the host and port, and the URL" do + expect( + attributes_for( + :method => "GET", + :scheme => "https", + :host => "example.com", + :port => 443, + :path => "/users/1" + ) + ).to eq( + "http.request.method" => "GET", + "server.address" => "example.com", + "server.port" => 443, + "url.full" => "https://example.com/users/1" + ) + end + + # A URL is normally written without the port its scheme implies, but the + # conventions ask for the port either way. + it "leaves a scheme's own port out of the URL" do + expect( + attributes_for(:method => "GET", :scheme => "http", :host => "example.com", :port => 80) + ).to include( + "server.port" => 80, + "url.full" => "http://example.com/" + ) + end + + it "keeps a port the scheme does not imply in the URL" do + expect( + attributes_for( + :method => "GET", :scheme => "http", :host => "example.com", :port => 8080, + :path => "/path" + ) + ).to include( + "server.port" => 8080, + "url.full" => "http://example.com:8080/path" + ) + end + + # Some clients only name a port when the URL did, so the scheme's own port + # has to stand in. The conventions ask for the port on every client span. + it "falls back to the scheme's own port" do + expect( + attributes_for(:method => "GET", :scheme => "https", :host => "example.com") + ).to include( + "server.port" => 443, + "url.full" => "https://example.com/" + ) + end + + it "accepts a port given as a String" do + expect( + attributes_for(:method => "GET", :scheme => "http", :host => "example.com", :port => "8080") + ).to include("server.port" => 8080) + end + + # Some clients hand us a request target rather than a path, which can carry + # the query string along with it. + it "cuts the query string off the path" do + expect( + attributes_for( + :method => "GET", :scheme => "https", :host => "example.com", + :path => "/search?q=secret" + ) + ).to include("url.full" => "https://example.com/search") + end + + # A URL can carry a username and password, which must not be sent. Building + # the URL from the parts means there is nowhere for them to come from. + it "cannot include credentials" do + expect( + attributes_for( + :method => "GET", :scheme => "https", :host => "example.com", :path => "/path" + )["url.full"] + ).to eq("https://example.com/path") + end + + it "leaves out the URL when there is no host to build it from" do + attributes = attributes_for(:method => "GET", :scheme => "https") + + expect(attributes).to_not have_key("url.full") + expect(attributes).to_not have_key("server.address") + expect(attributes["http.request.method"]).to eq("GET") + end + + it "leaves out the URL when there is no scheme to build it from" do + attributes = attributes_for(:method => "GET", :host => "example.com") + + expect(attributes).to_not have_key("url.full") + expect(attributes).to_not have_key("server.port") + expect(attributes["server.address"]).to eq("example.com") + end + + it "normalizes the request method" do + expect( + attributes_for(:method => :get, :scheme => "https", :host => "example.com") + ).to include( + "http.request.method" => "GET", + "http.request.method_original" => "get" + ) + end + + # Some clients report the path of a request to the root of a host as empty, + # and others as "/". The request goes to "/" either way. + it "describes a request without a path as one to the root" do + expect( + attributes_for(:method => "GET", :scheme => "https", :host => "example.com", :path => "") + ).to include("url.full" => "https://example.com/") + end + + it "returns no attributes when there is nothing to describe" do + expect(attributes_for(:method => nil)).to eq({}) + end + end +end diff --git a/spec/lib/appsignal/opentelemetry/http_method_spec.rb b/spec/lib/appsignal/opentelemetry/http_method_spec.rb new file mode 100644 index 000000000..d436d917a --- /dev/null +++ b/spec/lib/appsignal/opentelemetry/http_method_spec.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +describe Appsignal::OpenTelemetry::HttpMethod do + describe ".attributes_for" do + it "passes a known method through, with nothing to preserve" do + expect(described_class.attributes_for("GET")).to eq( + "http.request.method" => "GET" + ) + end + + it "recognizes every method the semantic conventions define" do + %w[CONNECT DELETE GET HEAD OPTIONS PATCH POST PUT TRACE].each do |method| + expect(described_class.attributes_for(method)).to eq( + "http.request.method" => method + ) + end + end + + # Method names are case sensitive, so a lowercase method is not the known + # method. It is replaced by the canonical form, keeping what we were given. + it "upcases a known method given in another case, keeping the original" do + expect(described_class.attributes_for("get")).to eq( + "http.request.method" => "GET", + "http.request.method_original" => "get" + ) + end + + it "accepts a Symbol, as the HTTP client integrations pass" do + expect(described_class.attributes_for(:post)).to eq( + "http.request.method" => "POST", + "http.request.method_original" => "post" + ) + end + + # The conventions treat the method as a closed set, so anything outside it + # has to be reported as `_OTHER` rather than passed through. + it "reports an unknown method as _OTHER, keeping the original" do + expect(described_class.attributes_for("PROPFIND")).to eq( + "http.request.method" => "_OTHER", + "http.request.method_original" => "PROPFIND" + ) + end + + it "keeps the original of an unknown method exactly as given" do + expect(described_class.attributes_for("PropFind")).to eq( + "http.request.method" => "_OTHER", + "http.request.method_original" => "PropFind" + ) + end + + # Callers that could not read a method pass the result on without checking, + # and an empty Hash adds no attributes. + it "returns no attributes when there is no method" do + expect(described_class.attributes_for(nil)).to eq({}) + expect(described_class.attributes_for("")).to eq({}) + end + end +end diff --git a/spec/lib/appsignal/opentelemetry/http_response_spec.rb b/spec/lib/appsignal/opentelemetry/http_response_spec.rb new file mode 100644 index 000000000..245199c0c --- /dev/null +++ b/spec/lib/appsignal/opentelemetry/http_response_spec.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +describe Appsignal::OpenTelemetry::HttpResponse do + describe ".attributes_for" do + it "describes the status code" do + expect(described_class.attributes_for(200)).to eq( + "http.response.status_code" => 200 + ) + end + + it "describes a status code given as a String" do + expect(described_class.attributes_for("404")).to eq( + "http.response.status_code" => 404 + ) + end + + # Callers whose request never produced a response pass the result on without + # checking. + it "returns no attributes when there is no status" do + expect(described_class.attributes_for(nil)).to eq({}) + end + + it "returns no attributes for a status that is not a number" do + expect(described_class.attributes_for("nonsense")).to eq({}) + expect(described_class.attributes_for("")).to eq({}) + end + end +end diff --git a/spec/lib/appsignal/opentelemetry/http_server_request_spec.rb b/spec/lib/appsignal/opentelemetry/http_server_request_spec.rb new file mode 100644 index 000000000..e8be141ca --- /dev/null +++ b/spec/lib/appsignal/opentelemetry/http_server_request_spec.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +describe Appsignal::OpenTelemetry::HttpServerRequest do + describe ".attributes_for" do + it "describes the method, path, scheme and query string" do + expect( + described_class.attributes_for( + :method => "GET", + :path => "/users/1", + :scheme => "https", + :query => "page=2&query=lorem" + ) + ).to eq( + "http.request.method" => "GET", + "url.path" => "/users/1", + "url.scheme" => "https", + "url.query" => "page=2&query=lorem" + ) + end + + # A request without a query string gets no query attribute, rather than an + # empty one. Rack reports a missing query string as an empty String. + it "leaves out an empty query string" do + expect( + described_class.attributes_for(:method => "GET", :path => "/", :query => "") + ).to eq( + "http.request.method" => "GET", + "url.path" => "/" + ) + end + + # The method goes through the same normalization as anywhere else, so an + # unknown method is reported as `_OTHER` with the original kept. + it "normalizes the request method" do + expect( + described_class.attributes_for(:method => "get", :path => "/", :scheme => "http") + ).to eq( + "http.request.method" => "GET", + "http.request.method_original" => "get", + "url.path" => "/", + "url.scheme" => "http" + ) + end + + # Reading any of these from the request can fail, and the callers pass on + # whatever they got without checking. + it "leaves out a value it was not given" do + expect(described_class.attributes_for(:method => "GET")).to eq( + "http.request.method" => "GET" + ) + end + + it "leaves out an empty value" do + expect( + described_class.attributes_for( + :method => "GET", :path => "", :scheme => "", :query => "" + ) + ).to eq("http.request.method" => "GET") + end + + it "returns no attributes when there is nothing to describe" do + expect(described_class.attributes_for(:method => nil)).to eq({}) + end + end +end diff --git a/spec/lib/appsignal/opentelemetry/messaging_spec.rb b/spec/lib/appsignal/opentelemetry/messaging_spec.rb new file mode 100644 index 000000000..068837c5e --- /dev/null +++ b/spec/lib/appsignal/opentelemetry/messaging_spec.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +describe Appsignal::OpenTelemetry::Messaging do + describe ".enqueue_attributes" do + # Enqueuing a job is one of the five kinds of operation the conventions + # define: it sends a message. + it "describes a job being enqueued" do + expect(described_class.enqueue_attributes("sidekiq")).to eq( + "messaging.system" => "sidekiq", + "messaging.operation.name" => "enqueue", + "messaging.operation.type" => "send" + ) + end + + it "names the queue the job is being put on" do + expect( + described_class.enqueue_attributes("sidekiq", :destination => "mailers") + ).to include("messaging.destination.name" => "mailers") + end + end + + describe ".perform_attributes" do + # Performing a job processes a message, which is another of the five. + it "describes a job being performed" do + expect(described_class.perform_attributes("resque")).to eq( + "messaging.system" => "resque", + "messaging.operation.name" => "perform", + "messaging.operation.type" => "process" + ) + end + + it "names the queue the job came off" do + expect( + described_class.perform_attributes("resque", :destination => "mailers") + ).to include("messaging.destination.name" => "mailers") + end + end + + describe "a span that covers a batch" do + it "says how many messages the batch holds" do + expect( + described_class.perform_attributes("aws_sqs", :batch_size => 3) + ).to include("messaging.batch.message_count" => 3) + end + + # The conventions say a span describing a single message must not carry a + # count, so only a batch gets one. + it "leaves out the count when the span is not a batch" do + expect(described_class.perform_attributes("aws_sqs")).to_not have_key( + "messaging.batch.message_count" + ) + end + + it "leaves out a count of nothing" do + expect( + described_class.perform_attributes("aws_sqs", :batch_size => 0) + ).to_not have_key("messaging.batch.message_count") + end + end + + # Not every job library records a queue, and not every job is on one, so the + # attribute is left out rather than sent empty. + it "leaves out a queue it was not given" do + expect(described_class.perform_attributes("resque")).to_not have_key( + "messaging.destination.name" + ) + expect( + described_class.perform_attributes("resque", :destination => "") + ).to_not have_key("messaging.destination.name") + end +end diff --git a/spec/lib/appsignal/opentelemetry/sql_db_system_spec.rb b/spec/lib/appsignal/opentelemetry/sql_db_system_spec.rb new file mode 100644 index 000000000..0fc983a2b --- /dev/null +++ b/spec/lib/appsignal/opentelemetry/sql_db_system_spec.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +describe Appsignal::OpenTelemetry::SqlDbSystem do + describe ".name_for_active_record" do + it "maps ActiveRecord's ADAPTER_NAME to the semantic conventions value" do + expect(described_class.name_for_active_record("PostgreSQL")).to eq("postgresql") + expect(described_class.name_for_active_record("Mysql2")).to eq("mysql") + expect(described_class.name_for_active_record("Trilogy")).to eq("mysql") + expect(described_class.name_for_active_record("SQLite")).to eq("sqlite") + end + + # activerecord-sqlserver-adapter and activerecord-oracle_enhanced-adapter + # are separate gems, but declare ADAPTER_NAME the same way the adapters + # Rails bundles do. + it "maps activerecord-sqlserver-adapter's ADAPTER_NAME to the older semconv value" do + expect(described_class.name_for_active_record("SQLServer")).to eq("mssql") + end + + it "maps activerecord-oracle_enhanced-adapter's ADAPTER_NAME to the older semconv value" do + expect(described_class.name_for_active_record("OracleEnhanced")).to eq("oracle") + end + + it "returns nil for an adapter it does not recognise" do + expect(described_class.name_for_active_record("DB2")).to be_nil + expect(described_class.name_for_active_record(nil)).to be_nil + end + + it "does not recognise Sequel's or DataMapper's vocabulary" do + expect(described_class.name_for_active_record(:postgres)).to be_nil + expect(described_class.name_for_active_record("DataObjects::Postgres::Connection")).to be_nil + end + end + + describe ".name_for_sequel" do + it "maps Sequel's database_type to the semantic conventions value" do + expect(described_class.name_for_sequel(:postgres)).to eq("postgresql") + expect(described_class.name_for_sequel(:mysql)).to eq("mysql") + expect(described_class.name_for_sequel(:sqlite)).to eq("sqlite") + end + + # SQL Server and Oracle map to the older semantic conventions values, + # `mssql` and `oracle`, rather than the current registry's + # `microsoft.sql_server` and `oracle.db`, because those are what the + # AppSignal collector's sanitizer recognizes today. + it "maps SQL Server and Oracle to the older semantic conventions values" do + expect(described_class.name_for_sequel(:mssql)).to eq("mssql") + expect(described_class.name_for_sequel(:oracle)).to eq("oracle") + end + + it "returns nil for a database_type it does not recognise" do + expect(described_class.name_for_sequel(:db2)).to be_nil + expect(described_class.name_for_sequel(nil)).to be_nil + end + + it "does not recognise ActiveRecord's or DataMapper's vocabulary" do + expect(described_class.name_for_sequel("PostgreSQL")).to be_nil + expect(described_class.name_for_sequel("DataObjects::Postgres::Connection")).to be_nil + end + end + + describe ".name_for_data_mapper" do + it "maps DataMapper's DataObjects connection classes to the semantic conventions value" do + expect(described_class.name_for_data_mapper("DataObjects::Postgres::Connection")) + .to eq("postgresql") + expect(described_class.name_for_data_mapper("DataObjects::Mysql::Connection")) + .to eq("mysql") + expect(described_class.name_for_data_mapper("DataObjects::Sqlite3::Connection")) + .to eq("sqlite") + end + + # SQL Server maps to the older semantic conventions value, `mssql`, + # rather than the current registry's `microsoft.sql_server`, because + # that is what the AppSignal collector's sanitizer recognizes today. + it "maps SQL Server to the older semantic conventions value" do + expect(described_class.name_for_data_mapper("DataObjects::SqlServer::Connection")) + .to eq("mssql") + end + + it "returns nil for a connection class it does not recognise" do + expect(described_class.name_for_data_mapper("DataObjects::Oracle::Connection")).to be_nil + expect(described_class.name_for_data_mapper(nil)).to be_nil + end + + it "does not recognise ActiveRecord's or Sequel's vocabulary" do + expect(described_class.name_for_data_mapper("PostgreSQL")).to be_nil + expect(described_class.name_for_data_mapper(:postgres)).to be_nil + end + end +end diff --git a/spec/lib/appsignal/opentelemetry_spec.rb b/spec/lib/appsignal/opentelemetry_spec.rb new file mode 100644 index 000000000..d61d2466d --- /dev/null +++ b/spec/lib/appsignal/opentelemetry_spec.rb @@ -0,0 +1,485 @@ +# frozen_string_literal: true + +# The configure/shutdown/started behavior is gated on Ruby 3.1+ (the OTel +# SDK ships fork hooks via Process._fork). On older Rubies these unit +# specs are skipped; the config-level gate is covered in `config_spec`. +if DependencyHelper.opentelemetry_present? + require "opentelemetry/sdk" + require "opentelemetry-metrics-sdk" + require "opentelemetry-logs-sdk" + + describe Appsignal::OpenTelemetry do + let(:config) do + build_config( + :options => { + :name => "collector-mode-spec", + :push_api_key => "abc", + :collector_endpoint => "http://127.0.0.1:9090" + } + ) + end + + before { described_class.reset! } + after { described_class.reset! } + + # Attach a root span to the current OTel context, run the block, and detach + # it afterwards. Stands in for Bug A leaving a previous request's root span + # attached to the fiber, so the extraction specs can assert that a request + # with no incoming trace context does not inherit it. + def with_leaked_ambient_context + tracer = ::OpenTelemetry.tracer_provider.tracer("leak-spec") + leaked = tracer.start_root_span("leaked-from-previous-request") + token = ::OpenTelemetry::Context.attach( + ::OpenTelemetry::Trace.context_with_span(leaked) + ) + yield leaked + ensure + ::OpenTelemetry::Context.detach(token) + end + + describe ".configure" do + context "on success" do + it "sets started? to true" do + described_class.configure(config) + + expect(described_class.started?).to be(true) + end + + it "installs meter and logger providers on the global ::OpenTelemetry" do + described_class.configure(config) + + expect(::OpenTelemetry.meter_provider) + .to be_a(::OpenTelemetry::SDK::Metrics::MeterProvider) + expect(::OpenTelemetry.logger_provider) + .to be_a(::OpenTelemetry::SDK::Logs::LoggerProvider) + end + + it "uses the same merged resource (AppSignal + SDK defaults) for all providers" do + described_class.configure(config) + + tracer_attrs = resource_attrs(::OpenTelemetry.tracer_provider.resource) + meter_attrs = resource_attrs(::OpenTelemetry.meter_provider.resource) + # LoggerProvider doesn't expose a public `resource` accessor; read + # the instance variable directly. Switch to a public method if/when + # the OTel logs SDK exposes one. + logger_attrs = resource_attrs( + ::OpenTelemetry.logger_provider.instance_variable_get(:@resource) + ) + + expect(tracer_attrs).to eq(meter_attrs) + expect(tracer_attrs).to eq(logger_attrs) + + # AppSignal attrs are present. + expect(meter_attrs["appsignal.config.name"]).to eq("collector-mode-spec") + # SDK default attrs survived the merge. + expect(meter_attrs["telemetry.sdk.name"]).to eq("opentelemetry") + expect(meter_attrs["telemetry.sdk.language"]).to eq("ruby") + end + end + + context "when an SDK gem can't be loaded" do + let(:err_stream) { std_stream } + + it "logs the error, doesn't raise, and leaves started? false" do + allow(described_class).to receive(:require) + .with("opentelemetry/sdk") + .and_raise(LoadError, "fake load failure") + + logs = + capture_logs do + capture_std_streams(std_stream, err_stream) do + expect { described_class.configure(config) }.not_to raise_error + end + end + + expect(described_class.started?).to be(false) + expect(logs).to include("Cannot configure OpenTelemetry SDK") + expect(logs).to include("fake load failure") + expect(err_stream.read).to include("appsignal ERROR") + end + end + + context "when SDK setup raises a non-LoadError" do + let(:err_stream) { std_stream } + + it "logs the error, doesn't raise, and leaves started? false" do + allow(::OpenTelemetry::SDK).to receive(:configure) + .and_raise(RuntimeError, "boom") + + logs = + capture_logs do + capture_std_streams(std_stream, err_stream) do + expect { described_class.configure(config) }.not_to raise_error + end + end + + expect(described_class.started?).to be(false) + expect(logs).to include("Error configuring OpenTelemetry SDK") + expect(logs).to include("boom") + expect(err_stream.read).to include("appsignal ERROR") + end + end + + describe "OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE" do + before { ENV.delete("OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE") } + after { ENV.delete("OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE") } + + it "defaults to 'delta' when unset" do + described_class.configure(config) + + expect(ENV.fetch("OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE")) + .to eq("delta") + end + + it "preserves a user-set value" do + ENV["OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE"] = "cumulative" + + described_class.configure(config) + + expect(ENV.fetch("OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE")) + .to eq("cumulative") + end + end + + describe "endpoint normalization" do + it "strips trailing slashes before appending the OTLP path" do + trailing = build_config( + :options => { + :name => "collector-mode-spec", + :push_api_key => "abc", + :collector_endpoint => "http://127.0.0.1:9090//" + } + ) + # Capture the endpoint each OTLP exporter is constructed with so we + # can prove the slashes were stripped before "/v1/" was + # appended. The SDK may construct exporters of its own without + # passing :endpoint (it falls back to env vars in that case), so we + # only assert on the endpoints we explicitly pass through. + endpoints = [] + [ + ::OpenTelemetry::Exporter::OTLP::Exporter, + ::OpenTelemetry::Exporter::OTLP::Metrics::MetricsExporter, + ::OpenTelemetry::Exporter::OTLP::Logs::LogsExporter + ].each do |klass| + allow(klass).to receive(:new).and_wrap_original do |original, **kwargs| + endpoints << kwargs[:endpoint] if kwargs[:endpoint] + original.call(**kwargs) + end + end + + described_class.configure(trailing) + + expect(endpoints).to contain_exactly( + "http://127.0.0.1:9090/v1/traces", + "http://127.0.0.1:9090/v1/metrics", + "http://127.0.0.1:9090/v1/logs" + ) + end + end + end + + describe ".started?" do + it "is false before configure has been called" do + expect(described_class.started?).to be(false) + end + + it "is true after a successful configure" do + described_class.configure(config) + + expect(described_class.started?).to be(true) + end + + it "is reset! back to false on demand" do + described_class.configure(config) + described_class.reset! + + expect(described_class.started?).to be(false) + end + end + + describe ".shutdown" do + it "is a no-op when not started" do + # No SDK is wired up; the API-gem proxy providers raise on shutdown. + # The guard in shutdown should short-circuit before touching them. + expect { described_class.shutdown }.not_to raise_error + end + + it "calls shutdown on all three providers when started" do + described_class.configure(config) + + expect(::OpenTelemetry.tracer_provider).to receive(:shutdown) + expect(::OpenTelemetry.meter_provider).to receive(:shutdown) + expect(::OpenTelemetry.logger_provider).to receive(:shutdown) + + described_class.shutdown + end + + it "logs and swallows errors raised by a provider's shutdown" do + described_class.configure(config) + + allow(::OpenTelemetry.meter_provider).to receive(:shutdown) + .and_raise(RuntimeError, "meter shutdown failed") + + logs = capture_logs { expect { described_class.shutdown }.not_to raise_error } + + expect(logs).to include("Error shutting down OpenTelemetry SDK") + expect(logs).to include("meter shutdown failed") + end + end + + describe ".extract_rack_context" do + let(:env) do + { "HTTP_TRACEPARENT" => "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01" } + end + + it "returns nil when the SDK has not booted" do + expect(described_class.started?).to be(false) + expect(described_class.extract_rack_context(env)).to be_nil + end + + it "extracts from the env with the Rack getter, onto an empty context, when started" do + require "opentelemetry-common" + allow(described_class).to receive(:started?).and_return(true) + + received = {} + allow(::OpenTelemetry.propagation).to receive(:extract) do |carrier, **kwargs| + received = kwargs.merge(:carrier => carrier) + ::OpenTelemetry::Context.empty + end + + described_class.extract_rack_context(env) + + expect(received[:carrier]).to eq(env) + expect(received[:getter]) + .to eq(::OpenTelemetry::Common::Propagation.rack_env_getter) + # The base context must carry no ambient span, so a request with no + # `traceparent` in the carrier does not inherit whatever span happens + # to be current on the fiber. + expect(::OpenTelemetry::Trace.current_span(received[:context])) + .to eq(::OpenTelemetry::Trace::Span::INVALID) + end + + # Regression for issue #7. With a span already attached to the fiber's + # context, extraction must reflect only the carrier. Otherwise a request + # with no `traceparent` inherits the ambient span and its trace merges + # into the leaked one. + context "with a span already attached to the current context", :collector_mode do + before { start_collector_agent } + + it "does not inherit the ambient span when the env has no trace context" do + with_leaked_ambient_context do |leaked| + context = described_class.extract_rack_context({}) + extracted = ::OpenTelemetry::Trace.current_span(context).context + expect(extracted).to_not be_valid + expect(extracted.hex_trace_id).to_not eq(leaked.context.hex_trace_id) + end + end + + it "still continues a real incoming traceparent" do + with_leaked_ambient_context do + context = described_class.extract_rack_context(env) + extracted = ::OpenTelemetry::Trace.current_span(context).context + expect(extracted.hex_trace_id).to eq("0af7651916cd43dd8448eb211c80319c") + end + end + end + end + + describe ".extract_job_context" do + let(:carrier) do + { "traceparent" => "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01" } + end + + it "returns nil when the SDK has not booted" do + expect(described_class.started?).to be(false) + expect(described_class.extract_job_context({})).to be_nil + end + + # Regression for issue #7, mirroring the Rack case for the job carrier. + context "with a span already attached to the current context", :collector_mode do + before { start_collector_agent } + + it "does not inherit the ambient span when the job has no trace context" do + with_leaked_ambient_context do |leaked| + context = described_class.extract_job_context({}) + extracted = ::OpenTelemetry::Trace.current_span(context).context + expect(extracted).to_not be_valid + expect(extracted.hex_trace_id).to_not eq(leaked.context.hex_trace_id) + end + end + + it "still reads a real incoming traceparent" do + with_leaked_ambient_context do + context = described_class.extract_job_context(carrier) + extracted = ::OpenTelemetry::Trace.current_span(context).context + expect(extracted.hex_trace_id).to eq("0af7651916cd43dd8448eb211c80319c") + end + end + end + end + + describe ".if_started" do + it "does not run the block and returns nil when the SDK has not booted" do + expect(described_class.started?).to be(false) + + ran = false + result = described_class.if_started { ran = true } + + expect(ran).to be(false) + expect(result).to be_nil + end + + it "runs the block and returns its result when started" do + allow(described_class).to receive(:started?).and_return(true) + + expect(described_class.if_started { :value }).to eq(:value) + end + end + + describe ".build_resource" do + it "maps AppSignal config attributes onto the resource" do + resource = described_class.build_resource( + build_config( + :options => { + :name => "my-app", + :push_api_key => "abc", + :revision => "deadbeef", + :hostname => "host-1", + :service_name => "my-service", + :filter_attributes => ["password"], + :ignore_actions => ["IgnoredController#action"] + } + ) + ) + attrs = resource_attrs(resource) + + expect(attrs["appsignal.config.name"]).to eq("my-app") + expect(attrs["appsignal.config.push_api_key"]).to eq("abc") + expect(attrs["appsignal.config.revision"]).to eq("deadbeef") + expect(attrs["appsignal.config.language_integration"]).to eq("ruby") + expect(attrs["service.name"]).to eq("my-service") + expect(attrs["host.name"]).to eq("host-1") + expect(attrs["appsignal.config.filter_attributes"]).to eq(["password"]) + expect(attrs["appsignal.config.ignore_actions"]) + .to eq(["IgnoredController#action"]) + end + + it "falls back to 'unknown' for empty revision, service_name, and hostname" do + # Other specs in the suite set `ENV["APP_REVISION"]` without clearing + # it (the spec_helper before-block only resets APPSIGNAL_* and + # _APPSIGNAL_* prefixed vars). Clear it locally so this test is + # robust to spec ordering. + ENV.delete("APP_REVISION") + + resource = described_class.build_resource( + build_config( + :options => { + :name => "my-app", + :push_api_key => "abc", + :revision => nil, + :service_name => nil, + :hostname => nil + } + ) + ) + attrs = resource_attrs(resource) + + expect(attrs["appsignal.config.revision"]).to eq("unknown") + expect(attrs["service.name"]).to eq("unknown") + expect(attrs["host.name"]).to eq("unknown") + end + + it "omits attributes whose underlying option is nil or empty" do + resource = described_class.build_resource( + build_config( + :options => { + :name => "my-app", + :push_api_key => "abc" + } + ) + ) + attrs = resource_attrs(resource) + + # These all default to nil or [] and should be dropped so the + # collector can apply its own defaults. + %w[ + appsignal.config.filter_function_parameters + appsignal.config.filter_request_query_parameters + appsignal.config.ignore_errors + appsignal.config.response_headers + appsignal.config.send_function_parameters + appsignal.config.send_request_query_parameters + appsignal.config.send_request_payload + ].each do |key| + expect(attrs).not_to have_key(key) + end + end + end + + # Pull the attributes out of an OTel Resource as a plain hash so specs + # can assert on them without touching the SDK's internals. + def resource_attrs(resource) + resource.attribute_enumerator.to_h + end + end +end + +# The version gate compares installed gem versions against `REQUIRED_GEMS`. +# These specs stub `Gem.loaded_specs`, so they need no OpenTelemetry gems +# installed and run outside the `opentelemetry_present?` guard above. +describe Appsignal::OpenTelemetry, "collector-mode gem version gate" do + before { described_class.reset! } + after { described_class.reset! } + + # Stand in for the installed gems using a `{ name => version }` map. Any gem + # not in the map is treated as not installed. + def stub_loaded_specs(versions) + specs = versions.transform_values do |version| + instance_double(Gem::Specification, :version => Gem::Version.new(version)) + end + allow(Gem).to receive(:loaded_specs).and_return(specs) + end + + # Capture the message passed to the warning logger by the gate. + def captured_warning + message = nil + allow(Appsignal::Utils::StdoutAndLoggerMessage) + .to receive(:warning) { |msg| message = msg } + expect(described_class.send(:required_gem_versions_met?)).to be(false) + message + end + + context "when every required gem is missing" do + before { stub_loaded_specs({}) } + + it "warns with only the appsignal-opentelemetry recommendation" do + message = captured_warning + + expect(message).to include("Add the `appsignal-opentelemetry` gem") + expect(message).to_not include("not compatible") + expect(message).to_not include("- opentelemetry") + end + end + + context "when an installed gem is older than the supported version" do + before { stub_loaded_specs("opentelemetry-common" => "0.19.0") } + + it "warns with the recommendation and a line for that gem" do + message = captured_warning + + expect(message).to include("Add the `appsignal-opentelemetry` gem") + expect(message).to include("not compatible") + expect(message).to include("- opentelemetry-common 0.19.0 (requires ~> 0.23)") + end + end + + context "when an installed gem is newer than the supported version" do + before { stub_loaded_specs("opentelemetry-common" => "1.0.0") } + + it "flags the too-new gem as incompatible" do + message = captured_warning + + expect(message).to include("- opentelemetry-common 1.0.0 (requires ~> 0.23)") + end + end +end diff --git a/spec/lib/appsignal/probes/gvl_spec.rb b/spec/lib/appsignal/probes/gvl_spec.rb index 8a9341525..690fba4c3 100644 --- a/spec/lib/appsignal/probes/gvl_spec.rb +++ b/spec/lib/appsignal/probes/gvl_spec.rb @@ -21,51 +21,122 @@ def gauges_for(metric) end end - after { FakeGVLTools.reset } - - it "gauges the global timer delta" do - FakeGVLTools::GlobalTimer.monotonic_time = 100_000_000 - probe.call - - expect(gauges_for("gvl_global_timer")).to be_empty + # A probe wired to the real Appsignal so `set_gauge` routes through the OTel + # metrics backend (collector mode) instead of the in-memory AppsignalMock. + def collector_probe + described_class.new(:appsignal => Appsignal, :gvl_tools => FakeGVLTools) + end - FakeGVLTools::GlobalTimer.monotonic_time = 300_000_000 - probe.call + # Assert the collector-mode counterpart of the agent-mode two-entry gauge: the + # probe emits each metric twice, once tagged with the process and once with + # only the hostname. With the real Appsignal the hostname is the host's own, + # so it is only checked for presence. + def expect_dual_gauge_points(name, value, process_name:) + snapshot = metric_snapshot(name) + expect(snapshot).not_to be_nil + expect(snapshot.instrument_kind).to eq(:gauge) + expect(snapshot.data_points.size).to eq(2) + expect(snapshot.data_points.map(&:value)).to all(eq(value)) + expect_process_tag_split(snapshot, process_name) + end - expect(gauges_for("gvl_global_timer")).to eq [ - [200, { - :hostname => hostname, - :process_name => "rspec", - :process_id => Process.pid - }], - [200, { :hostname => hostname }] - ] + def expect_process_tag_split(snapshot, process_name) + with_process = snapshot.data_points.find { |point| point.attributes.key?("process_name") } + expect(with_process).not_to be_nil + expect(with_process.attributes).to include( + "process_name" => process_name, + "process_id" => Process.pid, + "hostname" => kind_of(String) + ) + + without_process = snapshot.data_points.find { |point| !point.attributes.key?("process_name") } + expect(without_process).not_to be_nil + expect(without_process.attributes.keys).to eq(["hostname"]) end - context "when the delta is negative" do - it "does not gauge the global timer delta" do + after { FakeGVLTools.reset } + + describe "the global timer delta gauge" do + def perform(probe) + FakeGVLTools::GlobalTimer.monotonic_time = 100_000_000 + probe.call FakeGVLTools::GlobalTimer.monotonic_time = 300_000_000 probe.call + end - expect(gauges_for("gvl_global_timer")).to be_empty - - FakeGVLTools::GlobalTimer.monotonic_time = 0 - probe.call + it "in agent mode", :agent_mode do + start_agent + # The two-entry match also proves the first call emits nothing: a gauge + # on the first call would add a third entry. + perform(probe) - expect(gauges_for("gvl_global_timer")).to be_empty + expect(gauges_for("gvl_global_timer")).to eq [ + [200, { + :hostname => hostname, + :process_name => "rspec", + :process_id => Process.pid + }], + [200, { :hostname => hostname }] + ] end - end - context "when the delta is zero" do - it "does not gauge the global timer delta" do - FakeGVLTools::GlobalTimer.monotonic_time = 300_000_000 - probe.call + it "in collector mode", :collector_mode do + start_collector_agent + perform(collector_probe) - expect(gauges_for("gvl_global_timer")).to be_empty + # The probe emits the gauge twice: once tagged with the process, once + # with only the hostname. Asserting exactly two points also proves the + # first call emitted nothing. + expect_dual_gauge_points("gvl_global_timer", 200, :process_name => "rspec") + end + end - probe.call + context "when the delta is negative" do + describe "does not gauge the global timer delta" do + def perform(probe) + FakeGVLTools::GlobalTimer.monotonic_time = 300_000_000 + probe.call + FakeGVLTools::GlobalTimer.monotonic_time = 0 + probe.call + end + + it "in agent mode", :agent_mode do + start_agent + perform(probe) + + expect(gauges_for("gvl_global_timer")).to be_empty + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform(collector_probe) + + expect(metric_snapshot("gvl_global_timer")).to be_nil + end + end + end - expect(gauges_for("gvl_global_timer")).to be_empty + context "when the delta is zero" do + describe "does not gauge the global timer delta" do + def perform(probe) + FakeGVLTools::GlobalTimer.monotonic_time = 300_000_000 + probe.call + probe.call + end + + it "in agent mode", :agent_mode do + start_agent + perform(probe) + + expect(gauges_for("gvl_global_timer")).to be_empty + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform(collector_probe) + + expect(metric_snapshot("gvl_global_timer")).to be_nil + end end end @@ -74,18 +145,32 @@ def gauges_for(metric) FakeGVLTools::WaitingThreads.enabled = true end - it "gauges the waiting threads count" do - FakeGVLTools::WaitingThreads.count = 3 - probe.call - - expect(gauges_for("gvl_waiting_threads")).to eq [ - [3, { - :hostname => hostname, - :process_name => "rspec", - :process_id => Process.pid - }], - [3, { :hostname => hostname }] - ] + describe "the waiting threads count gauge" do + def perform(probe) + FakeGVLTools::WaitingThreads.count = 3 + probe.call + end + + it "in agent mode", :agent_mode do + start_agent + perform(probe) + + expect(gauges_for("gvl_waiting_threads")).to eq [ + [3, { + :hostname => hostname, + :process_name => "rspec", + :process_id => Process.pid + }], + [3, { :hostname => hostname }] + ] + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform(collector_probe) + + expect_dual_gauge_points("gvl_waiting_threads", 3, :process_name => "rspec") + end end end @@ -94,71 +179,130 @@ def gauges_for(metric) FakeGVLTools::WaitingThreads.enabled = false end - it "does not gauge the waiting threads count" do - FakeGVLTools::WaitingThreads.count = 3 - probe.call + describe "does not gauge the waiting threads count" do + def perform(probe) + FakeGVLTools::WaitingThreads.count = 3 + probe.call + end - expect(gauges_for("gvl_waiting_threads")).to be_empty + it "in agent mode", :agent_mode do + start_agent + perform(probe) + + expect(gauges_for("gvl_waiting_threads")).to be_empty + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform(collector_probe) + + expect(metric_snapshot("gvl_waiting_threads")).to be_nil + end end end context "when the process name is a custom value" do before do FakeGVLTools::WaitingThreads.enabled = true - end - - it "uses only the first word as the process name" do + # Set before the probe is built: the probe reads the process name at + # initialization, and the lazy `probe`/`collector_probe` is created in the + # example body after this hook runs. $PROGRAM_NAME = "sidekiq 7.1.6 app [0 of 5 busy]" - probe.call + end - expect(gauges_for("gvl_waiting_threads")).to eq [ - [0, { - :hostname => hostname, - :process_name => "sidekiq", - :process_id => Process.pid - }], - [0, { :hostname => hostname }] - ] + describe "uses only the first word as the process name" do + def perform(probe) + probe.call + end + + it "in agent mode", :agent_mode do + start_agent + perform(probe) + + expect(gauges_for("gvl_waiting_threads")).to eq [ + [0, { + :hostname => hostname, + :process_name => "sidekiq", + :process_id => Process.pid + }], + [0, { :hostname => hostname }] + ] + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform(collector_probe) + + expect_dual_gauge_points("gvl_waiting_threads", 0, :process_name => "sidekiq") + end end end context "when the process name is a path" do before do FakeGVLTools::WaitingThreads.enabled = true - end - - it "uses only the binary name as the process name" do $PROGRAM_NAME = "/foo/folder with spaces/bin/rails" - probe.call + end - expect(gauges_for("gvl_waiting_threads")).to eq [ - [0, { - :hostname => hostname, - :process_name => "rails", - :process_id => Process.pid - }], - [0, { :hostname => hostname }] - ] + describe "uses only the binary name as the process name" do + def perform(probe) + probe.call + end + + it "in agent mode", :agent_mode do + start_agent + perform(probe) + + expect(gauges_for("gvl_waiting_threads")).to eq [ + [0, { + :hostname => hostname, + :process_name => "rails", + :process_id => Process.pid + }], + [0, { :hostname => hostname }] + ] + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform(collector_probe) + + expect_dual_gauge_points("gvl_waiting_threads", 0, :process_name => "rails") + end end end context "when the process name is an empty string" do before do FakeGVLTools::WaitingThreads.enabled = true - end - - it "uses [unknown process] as the process name" do $PROGRAM_NAME = "" - probe.call + end - expect(gauges_for("gvl_waiting_threads")).to eq [ - [0, { - :hostname => hostname, - :process_name => "[unknown process]", - :process_id => Process.pid - }], - [0, { :hostname => hostname }] - ] + describe "uses [unknown process] as the process name" do + def perform(probe) + probe.call + end + + it "in agent mode", :agent_mode do + start_agent + perform(probe) + + expect(gauges_for("gvl_waiting_threads")).to eq [ + [0, { + :hostname => hostname, + :process_name => "[unknown process]", + :process_id => Process.pid + }], + [0, { :hostname => hostname }] + ] + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform(collector_probe) + + expect_dual_gauge_points("gvl_waiting_threads", 0, :process_name => "[unknown process]") + end end end end diff --git a/spec/lib/appsignal/probes/mri_spec.rb b/spec/lib/appsignal/probes/mri_spec.rb index 90d584aaa..121b4341a 100644 --- a/spec/lib/appsignal/probes/mri_spec.rb +++ b/spec/lib/appsignal/probes/mri_spec.rb @@ -25,116 +25,300 @@ allow(GC::Profiler).to receive(:enabled?).and_return(true) end - it "should track vm cache metrics" do - probe.call + # The two metric tags depend on the Ruby version. + def vm_cache_metrics if DependencyHelper.ruby_3_2_or_newer? - expect_gauge_value("ruby_vm", :tags => { :metric => :constant_cache_invalidations }) - expect_gauge_value("ruby_vm", :tags => { :metric => :constant_cache_misses }) + [:constant_cache_invalidations, :constant_cache_misses] else - expect_gauge_value("ruby_vm", :tags => { :metric => :class_serial }) - expect_gauge_value("ruby_vm", :tags => { :metric => :global_constant_state }) + [:class_serial, :global_constant_state] end end - it "tracks thread counts" do - probe.call - expect_gauge_value("thread_count") + describe "the vm cache gauges" do + def perform(probe) + probe.call + end + + it "in agent mode", :agent_mode do + start_agent + perform(probe) + vm_cache_metrics.each do |metric| + expect_gauge_value("ruby_vm", :tags => { :metric => metric }) + end + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform(collector_probe) + + snapshots = metric_snapshots + vm_cache_metrics.each do |metric| + point = find_gauge_point(snapshots, "ruby_vm", :metric => metric) + expect(point.value).to be_a(Numeric) + end + end + end + + describe "the thread count gauge" do + def perform(probe) + probe.call + end + + it "in agent mode", :agent_mode do + start_agent + perform(probe) + expect_gauge_value("thread_count") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform(collector_probe) + + snapshot = metric_snapshot("thread_count") + expect(snapshot).not_to be_nil + expect(snapshot.instrument_kind).to eq(:gauge) + data_point = snapshot.data_points.first + expect(data_point.value).to be_a(Numeric) + expect(data_point.attributes).to include("hostname" => kind_of(String)) + end end - it "tracks GC time between measurements" do - expect(gc_profiler_mock).to receive(:total_time).and_return(10, 15) - probe.call - probe.call - expect_gauge_value("gc_time", 5) + describe "the gc time gauge" do + # The gauge reports the delta between measurements, so call twice. + def perform(probe) + expect(gc_profiler_mock).to receive(:total_time).and_return(10, 15) + probe.call + probe.call + end + + it "in agent mode", :agent_mode do + start_agent + perform(probe) + expect_gauge_value("gc_time", 5) + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform(collector_probe) + expect(find_gauge_point(metric_snapshots, "gc_time").value).to eq(5) + end end context "when GC total time overflows" do - it "skips one report" do - expect(gc_profiler_mock).to receive(:total_time).and_return(10, 15, 0, 10) - probe.call # Normal call, create a cache - probe.call # Report delta value based on cached value - probe.call # The value overflows and reports no value. Then stores 0 in the cache - probe.call # Report new value based on cache of 0 - expect_gauges([["gc_time", 5], ["gc_time", 10]]) + describe "skips one report" do + def perform(probe) + expect(gc_profiler_mock).to receive(:total_time).and_return(10, 15, 0, 10) + probe.call # Normal call, create a cache + probe.call # Report delta value based on cached value + probe.call # The value overflows and reports no value. Then stores 0 in the cache + probe.call # Report new value based on cache of 0 + end + + it "in agent mode", :agent_mode do + start_agent + perform(probe) + expect_gauges([["gc_time", 5], ["gc_time", 10]]) + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform(collector_probe) + # An OTel gauge keeps only its last value, so assert the final + # post-overflow value (10) rather than the agent's [5, 10] sequence. + # This still confirms the metric is emitted through the overflow. + expect(find_gauge_point(metric_snapshots, "gc_time").value).to eq(10) + end end end context "when GC profiling is disabled" do - it "does not report a gc_time metric" do - allow(GC::Profiler).to receive(:enabled?).and_return(false) - expect(gc_profiler_mock).to_not receive(:total_time) - probe.call # Normal call, create a cache - probe.call # Report delta value based on cached value - metrics = appsignal_mock.gauges.map { |(key)| key } - expect(metrics).to_not include("gc_time") - end - - it "does not report a gc_time metric while temporarily disabled" do - # While enabled - allow(GC::Profiler).to receive(:enabled?).and_return(true) - expect(gc_profiler_mock).to receive(:total_time).and_return(10, 15) - probe.call # Normal call, create a cache - probe.call # Report delta value based on cached value - expect_gauges([["gc_time", 5]]) + describe "the gc time gauge" do + def perform(probe) + allow(GC::Profiler).to receive(:enabled?).and_return(false) + expect(gc_profiler_mock).to_not receive(:total_time) + probe.call # Normal call, create a cache + probe.call # Report delta value based on cached value + end - # While disabled - allow(GC::Profiler).to receive(:enabled?).and_return(false) - probe.call # Call twice to make sure any caches resets wouldn't mess up the assertion - probe.call - # Does not include any newly reported metrics - expect_gauges([["gc_time", 5]]) + it "does not report a gc_time metric in agent mode", :agent_mode do + start_agent + perform(probe) + metrics = appsignal_mock.gauges.map { |(key)| key } + expect(metrics).to_not include("gc_time") + end - # When enabled after being disabled for a while, it only reports the - # newly reported time since it was renabled - allow(GC::Profiler).to receive(:enabled?).and_return(true) - expect(gc_profiler_mock).to receive(:total_time).and_return(25) - probe.call - expect_gauges([["gc_time", 5], ["gc_time", 10]]) + it "does not report a gc_time metric in collector mode", :collector_mode do + start_collector_agent + perform(collector_probe) + expect(metric_snapshots.map(&:name)).to_not include("gc_time") + end + end + + describe "does not report a gc_time metric while temporarily disabled" do + def perform(probe) + # While enabled + allow(GC::Profiler).to receive(:enabled?).and_return(true) + expect(gc_profiler_mock).to receive(:total_time).and_return(10, 15) + probe.call # Normal call, create a cache + probe.call # Report delta value based on cached value + + # While disabled + allow(GC::Profiler).to receive(:enabled?).and_return(false) + probe.call # Call twice to make sure any cache resets wouldn't mess up the assertion + probe.call + + # When enabled after being disabled for a while, it only reports the + # newly reported time since it was renabled + allow(GC::Profiler).to receive(:enabled?).and_return(true) + expect(gc_profiler_mock).to receive(:total_time).and_return(25) + probe.call + end + + it "in agent mode", :agent_mode do + start_agent + perform(probe) + # Exactly two emissions: the disabled phase reported nothing. + expect_gauges([["gc_time", 5], ["gc_time", 10]]) + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform(collector_probe) + # The gauge keeps its last value; assert the post-re-enable value + # (10), confirming the disable/re-enable cache logic emits correctly. + expect(find_gauge_point(metric_snapshots, "gc_time").value).to eq(10) + end end end - it "tracks GC run count" do - expect(GC).to receive(:count).and_return(10, 15) - expect(GC).to receive(:stat).and_return( - { :minor_gc_count => 10, :major_gc_count => 10 }, - :minor_gc_count => 16, :major_gc_count => 17 - ) - probe.call - probe.call - expect_gauge_value("gc_count", 5, :tags => { :metric => :gc_count }) - expect_gauge_value("gc_count", 6, :tags => { :metric => :minor_gc_count }) - expect_gauge_value("gc_count", 7, :tags => { :metric => :major_gc_count }) + describe "the gc run count gauge" do + # The gauges report deltas between measurements, so call twice. + def perform(probe) + expect(GC).to receive(:count).and_return(10, 15) + expect(GC).to receive(:stat).and_return( + { :minor_gc_count => 10, :major_gc_count => 10 }, + :minor_gc_count => 16, :major_gc_count => 17 + ) + probe.call + probe.call + end + + it "in agent mode", :agent_mode do + start_agent + perform(probe) + expect_gauge_value("gc_count", 5, :tags => { :metric => :gc_count }) + expect_gauge_value("gc_count", 6, :tags => { :metric => :minor_gc_count }) + expect_gauge_value("gc_count", 7, :tags => { :metric => :major_gc_count }) + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform(collector_probe) + snapshots = metric_snapshots + expect(find_gauge_point(snapshots, "gc_count", :metric => :gc_count).value).to eq(5) + expect(find_gauge_point(snapshots, "gc_count", :metric => :minor_gc_count).value).to eq(6) + expect(find_gauge_point(snapshots, "gc_count", :metric => :major_gc_count).value).to eq(7) + end end - it "tracks object allocation" do - expect(GC).to receive(:stat).and_return( - { :total_allocated_objects => 10 }, - :total_allocated_objects => 15 - ) - # Only tracks delta value so the needs to be called twice - probe.call - probe.call - expect_gauge_value("allocated_objects", 5) + describe "the allocated objects gauge" do + # Only tracks the delta value, so it needs to be called twice. + def perform(probe) + expect(GC).to receive(:stat).and_return( + { :total_allocated_objects => 10 }, + :total_allocated_objects => 15 + ) + probe.call + probe.call + end + + it "in agent mode", :agent_mode do + start_agent + perform(probe) + expect_gauge_value("allocated_objects", 5) + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform(collector_probe) + expect(find_gauge_point(metric_snapshots, "allocated_objects").value).to eq(5) + end end - it "tracks heap slots" do - probe.call - expect_gauge_value("heap_slots", :tags => { :metric => :heap_live }) - expect_gauge_value("heap_slots", :tags => { :metric => :heap_free }) + describe "the heap slots gauges" do + def perform(probe) + probe.call + end + + it "in agent mode", :agent_mode do + start_agent + perform(probe) + expect_gauge_value("heap_slots", :tags => { :metric => :heap_live }) + expect_gauge_value("heap_slots", :tags => { :metric => :heap_free }) + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform(collector_probe) + snapshots = metric_snapshots + expect(find_gauge_point(snapshots, "heap_slots", :metric => :heap_live).value) + .to be_a(Numeric) + expect(find_gauge_point(snapshots, "heap_slots", :metric => :heap_free).value) + .to be_a(Numeric) + end end context "with custom hostname" do let(:hostname) { "my hostname" } + # Collector mode reads the hostname from the real Appsignal config; agent + # mode reads it from the AppsignalMock, which carries it directly. + let(:start_agent_args) { { :options => { :hostname => hostname } } } - it "reports custom hostname tag value" do - probe.call - expect_gauge_value("heap_slots", - :tags => { :metric => :heap_live, :hostname => hostname }) + describe "reports custom hostname tag value" do + def perform(probe) + probe.call + end + + it "in agent mode", :agent_mode do + start_agent + perform(probe) + expect_gauge_value("heap_slots", + :tags => { :metric => :heap_live, :hostname => hostname }) + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform(collector_probe) + point = find_gauge_point(metric_snapshots, "heap_slots", + :metric => :heap_live, :hostname => hostname) + expect(point.attributes["hostname"]).to eq(hostname) + end end end end end + # A probe wired to the real Appsignal so `set_gauge` routes through the OTel + # metrics backend (collector mode) instead of the in-memory AppsignalMock. + def collector_probe + described_class.new(:appsignal => Appsignal, :gc_profiler => gc_profiler_mock) + end + + # Find a single gauge data point in the pulled snapshots by metric name and + # (stringified) tags, asserting the snapshot exists, is a gauge, and carries a + # hostname. Tag values are compared as strings since OTel stringifies them. + def find_gauge_point(snapshots, name, tags = {}) + snapshot = snapshots.find { |s| s.name == name } + expect(snapshot).not_to(be_nil, "expected a #{name} snapshot") + expect(snapshot.instrument_kind).to eq(:gauge) + point = snapshot.data_points.find do |p| + tags.all? { |key, value| p.attributes[key.to_s] == value.to_s } + end + expect(point).not_to(be_nil, "expected #{name} point with tags #{tags}") + expect(point.attributes).to include("hostname" => kind_of(String)) + point + end + def expect_gauge_value(expected_key, expected_value = nil, tags: {}) expected_tags = { :hostname => Socket.gethostname }.merge(tags) expect(appsignal_mock.gauges).to satisfy do |gauges| diff --git a/spec/lib/appsignal/probes/sidekiq_spec.rb b/spec/lib/appsignal/probes/sidekiq_spec.rb index 712fb31b3..a278cc701 100644 --- a/spec/lib/appsignal/probes/sidekiq_spec.rb +++ b/spec/lib/appsignal/probes/sidekiq_spec.rb @@ -5,9 +5,10 @@ let(:probe) { described_class.new } let(:redis_hostname) { "localhost" } let(:expected_default_tags) { { :hostname => "localhost" } } + # `start_agent` is supplied by the `:agent_mode`/`:collector_mode` contexts + # on each example, not here -- a hardcoded `start_agent` would boot the agent + # in agent mode and clobber collector mode's collector-endpoint setup. before do - start_agent - # The probe will `require "sidekiq/api"` on initialize, which # as of 8.0.8 expects the `Sidekiq` module to provide a `loader` # method that responds to `run_load_hooks`. @@ -204,7 +205,8 @@ def with_sidekiq6! end end - it "loads Sidekiq::API" do + it "loads Sidekiq::API", :agent_mode do + start_agent with_sidekiq! # Hide the Sidekiq constant if it was already loaded. It will be # redefined by loading "sidekiq/api" in the probe. @@ -215,7 +217,8 @@ def with_sidekiq6! expect(defined?(Sidekiq::Stats)).to be_truthy end - it "logs config on initialize" do + it "logs config on initialize", :agent_mode do + start_agent with_sidekiq! log = capture_logs { probe } expect(log).to contains_log(:debug, "Initializing Sidekiq probe\n") @@ -224,7 +227,8 @@ def with_sidekiq6! context "with Sidekiq 7" do before { with_sidekiq7! } - it "logs used hostname on call once" do + it "logs used hostname on call once", :agent_mode do + start_agent log = capture_logs { probe.call } expect(log).to contains_log( :debug, @@ -235,37 +239,52 @@ def with_sidekiq6! expect(log).to_not contains_log(:debug, %(Sidekiq probe: )) end - it "collects custom metrics" do - expect_gauge("worker_count", 24).twice - expect_gauge("process_count", 25).twice - expect_gauge("connection_count", 2).twice - expect_gauge("memory_usage", 1024).twice - expect_gauge("memory_usage_rss", 512).twice - expect_gauge("job_count", 5, :status => :processed) # Gauge delta - expect_gauge("job_count", 3, :status => :failed) # Gauge delta - expect_gauge("job_count", 12, :status => :retry_queue).twice - expect_gauge("job_count", 2, :status => :died) # Gauge delta - expect_gauge("job_count", 14, :status => :scheduled).twice - expect_gauge("job_count", 15, :status => :enqueued).twice - expect_gauge("queue_length", 10, :queue => "default").twice - expect_gauge("queue_latency", 12_000, :queue => "default").twice - expect_gauge("queue_length", 1, :queue => "critical").twice - expect_gauge("queue_latency", 2_000, :queue => "critical").twice - # Call probe twice so we can calculate the delta for some gauge values - probe.call - probe.call + describe "collecting custom metrics" do + # Call the probe twice so the delta-based gauges report a value. + def perform + probe.call + probe.call + end + + it "in agent mode", :agent_mode do + start_agent + expect_all_custom_gauges + perform + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + expect_all_custom_gauge_snapshots + end end context "when redis info doesn't contain requested keys" do before { Sidekiq7Mock.redis_info_data = {} } - it "doesn't create metrics for nil values" do - expect_gauge("connection_count").never - expect_gauge("memory_usage").never - expect_gauge("memory_usage_rss").never - # Call probe twice so we can calculate the delta for some gauge values - probe.call - probe.call + describe "the redis info gauges" do + # Call probe twice so we can calculate the delta for some gauge values. + def perform + probe.call + probe.call + end + + it "doesn't create metrics for nil values in agent mode", :agent_mode do + start_agent + expect_gauge("connection_count").never + expect_gauge("memory_usage").never + expect_gauge("memory_usage_rss").never + perform + end + + it "doesn't create metrics for nil values in collector mode", :collector_mode do + start_collector_agent + perform + names = metric_snapshots.map(&:name) + expect(names).not_to include("sidekiq_connection_count") + expect(names).not_to include("sidekiq_memory_usage") + expect(names).not_to include("sidekiq_memory_usage_rss") + end end end end @@ -273,7 +292,8 @@ def with_sidekiq6! context "with Sidekiq 6" do before { with_sidekiq6! } - it "logs used hostname on call once" do + it "logs used hostname on call once", :agent_mode do + start_agent log = capture_logs { probe.call } expect(log).to contains_log( :debug, @@ -284,25 +304,24 @@ def with_sidekiq6! expect(log).to_not contains_log(:debug, %(Sidekiq probe: )) end - it "collects custom metrics" do - expect_gauge("worker_count", 24).twice - expect_gauge("process_count", 25).twice - expect_gauge("connection_count", 2).twice - expect_gauge("memory_usage", 1024).twice - expect_gauge("memory_usage_rss", 512).twice - expect_gauge("job_count", 5, :status => :processed) # Gauge delta - expect_gauge("job_count", 3, :status => :failed) # Gauge delta - expect_gauge("job_count", 12, :status => :retry_queue).twice - expect_gauge("job_count", 2, :status => :died) # Gauge delta - expect_gauge("job_count", 14, :status => :scheduled).twice - expect_gauge("job_count", 15, :status => :enqueued).twice - expect_gauge("queue_length", 10, :queue => "default").twice - expect_gauge("queue_latency", 12_000, :queue => "default").twice - expect_gauge("queue_length", 1, :queue => "critical").twice - expect_gauge("queue_latency", 2_000, :queue => "critical").twice - # Call probe twice so we can calculate the delta for some gauge values - probe.call - probe.call + describe "collecting custom metrics" do + # Call the probe twice so the delta-based gauges report a value. + def perform + probe.call + probe.call + end + + it "in agent mode", :agent_mode do + start_agent + expect_all_custom_gauges + perform + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + expect_all_custom_gauge_snapshots + end end context "when Sidekiq `redis_info` is not defined" do @@ -310,11 +329,23 @@ def with_sidekiq6! allow(Sidekiq).to receive(:respond_to?).with(:redis_info).and_return(false) end - it "does not collect redis metrics" do - expect_gauge("connection_count", 2).never - expect_gauge("memory_usage", 1024).never - expect_gauge("memory_usage_rss", 512).never - probe.call + describe "the redis info gauges" do + it "does not collect redis metrics in agent mode", :agent_mode do + start_agent + expect_gauge("connection_count", 2).never + expect_gauge("memory_usage", 1024).never + expect_gauge("memory_usage_rss", 512).never + probe.call + end + + it "does not collect redis metrics in collector mode", :collector_mode do + start_collector_agent + probe.call + names = metric_snapshots.map(&:name) + expect(names).not_to include("sidekiq_connection_count") + expect(names).not_to include("sidekiq_memory_usage") + expect(names).not_to include("sidekiq_memory_usage_rss") + end end end end @@ -323,7 +354,8 @@ def with_sidekiq6! let(:redis_hostname) { "my_redis_server" } let(:probe) { described_class.new(:hostname => redis_hostname) } - it "uses the redis hostname for the hostname tag" do + it "uses the redis hostname for the hostname tag", :agent_mode do + start_agent with_sidekiq! allow(Appsignal).to receive(:set_gauge).and_call_original @@ -340,6 +372,17 @@ def with_sidekiq6! expect(Appsignal).to have_received(:set_gauge) .with(anything, anything, :hostname => redis_hostname).at_least(:once) end + + it "tags the emitted gauges with the configured hostname", :collector_mode do + start_collector_agent + with_sidekiq! + + probe.call + + snapshot = metric_snapshot("sidekiq_worker_count") + expect(snapshot).not_to be_nil + expect(snapshot.data_points.first.attributes).to eq("hostname" => redis_hostname) + end end def expect_gauge(key, value = anything, tags = {}) @@ -347,5 +390,67 @@ def expect_gauge(key, value = anything, tags = {}) .with("sidekiq_#{key}", value, expected_default_tags.merge(tags)) .and_call_original end + + # The full set of gauges the probe emits over two `#call`s, asserted in + # agent mode via `set_gauge` message expectations. Delta-based gauges + # (processed/failed/died job counts) only report on the second call, so + # they are expected once; every other gauge is expected on both calls. + def expect_all_custom_gauges + expect_gauge("worker_count", 24).twice + expect_gauge("process_count", 25).twice + expect_gauge("connection_count", 2).twice + expect_gauge("memory_usage", 1024).twice + expect_gauge("memory_usage_rss", 512).twice + expect_gauge("job_count", 5, :status => :processed) # Gauge delta + expect_gauge("job_count", 3, :status => :failed) # Gauge delta + expect_gauge("job_count", 12, :status => :retry_queue).twice + expect_gauge("job_count", 2, :status => :died) # Gauge delta + expect_gauge("job_count", 14, :status => :scheduled).twice + expect_gauge("job_count", 15, :status => :enqueued).twice + expect_gauge("queue_length", 10, :queue => "default").twice + expect_gauge("queue_latency", 12_000, :queue => "default").twice + expect_gauge("queue_length", 1, :queue => "critical").twice + expect_gauge("queue_latency", 2_000, :queue => "critical").twice + end + + # The collector-mode counterpart of `expect_all_custom_gauges`: the agent + # has no in-memory readout, so here we read the same gauges back off the + # OpenTelemetry exporter and assert each value AND its attributes. A gauge + # holds its last recorded value, so the values match the agent-mode deltas. + # Each row is [metric short name, extra attributes, expected value]. A gauge + # holds its last recorded value, so the delta-based job counts + # (processed/failed/died) match the agent-mode deltas. + EXPECTED_CUSTOM_GAUGES = [ + ["worker_count", {}, 24], + ["process_count", {}, 25], + ["connection_count", {}, 2], + ["memory_usage", {}, 1024], + ["memory_usage_rss", {}, 512], + ["job_count", { "status" => "processed" }, 5], + ["job_count", { "status" => "failed" }, 3], + ["job_count", { "status" => "retry_queue" }, 12], + ["job_count", { "status" => "died" }, 2], + ["job_count", { "status" => "scheduled" }, 14], + ["job_count", { "status" => "enqueued" }, 15], + ["queue_length", { "queue" => "default" }, 10], + ["queue_latency", { "queue" => "default" }, 12_000], + ["queue_length", { "queue" => "critical" }, 1], + ["queue_latency", { "queue" => "critical" }, 2_000] + ].freeze + + def expect_all_custom_gauge_snapshots + # `metric_snapshots` resets the reader on each call, so pull once. + snapshots = metric_snapshots + + EXPECTED_CUSTOM_GAUGES.each do |name, extra_attributes, value| + snapshot = snapshots.find { |s| s.name == "sidekiq_#{name}" } + expect(snapshot).not_to(be_nil, "expected a sidekiq_#{name} snapshot") + expect(snapshot.instrument_kind).to eq(:gauge) + expected_attributes = { "hostname" => "localhost" }.merge(extra_attributes) + point = snapshot.data_points.find { |p| p.attributes == expected_attributes } + expect(point).not_to(be_nil, "expected sidekiq_#{name} point with #{expected_attributes}") + expect(point.value).to eq(value) + end + end end end diff --git a/spec/lib/appsignal/rack/abstract_middleware_spec.rb b/spec/lib/appsignal/rack/abstract_middleware_spec.rb index e51bb02dc..cf94905a6 100644 --- a/spec/lib/appsignal/rack/abstract_middleware_spec.rb +++ b/spec/lib/appsignal/rack/abstract_middleware_spec.rb @@ -4,6 +4,7 @@ Rack::MockRequest.env_for( "/some/path", "REQUEST_METHOD" => "GET", + "HTTP_ACCEPT" => "application/json", :params => { "page" => 2, "query" => "lorem" }, "rack.session" => { "session" => "data", "user_id" => 123 } ) @@ -12,8 +13,8 @@ let(:appsignal_env) { :default } let(:options) { {} } - before { start_agent(:env => appsignal_env) } - around { |example| keep_transactions { example.run } } + # Pass the example's AppSignal env through to the mode contexts' `start_agent`. + let(:start_agent_args) { { :env => appsignal_env } } def make_request middleware.call(env) @@ -27,56 +28,173 @@ def make_request_with_error(error_class, error_message) context "when not active" do let(:appsignal_env) { :inactive_env } - it "does not instrument the request" do + it_in_both_modes "does not instrument the request" do expect { make_request }.to_not(change { created_transactions.count }) end - it "calls the next middleware in the stack" do + it_in_both_modes "calls the next middleware in the stack" do make_request expect(app).to be_called end end context "when appsignal is active" do - it "creates a transaction for the request" do - expect { make_request }.to(change { created_transactions.count }.by(1)) + describe "creates a transaction for the request" do + def perform + make_request + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + expect { perform }.to(change { created_transactions.count }.by(1)) + + expect(last_transaction).to have_namespace(Appsignal::Transaction::HTTP_REQUEST) + end + + it "in collector mode", :collector_mode do + start_collector_agent + expect { perform }.to(change { created_transactions.count }.by(1)) + + expect(root_span.attributes["appsignal.namespace"]) + .to eq("web") + expect(root_span.kind).to eq(:server) + end + end + + describe "incoming trace context" do + let(:trace_id_hex) { "0af7651916cd43dd8448eb211c80319c" } + let(:span_id_hex) { "b7ad6b7169203331" } + + it "continues the upstream trace when a traceparent is present", :collector_mode do + env["HTTP_TRACEPARENT"] = "00-#{trace_id_hex}-#{span_id_hex}-01" + start_collector_agent + make_request + + expect(root_span.hex_trace_id).to eq(trace_id_hex) + expect(root_span.parent_span_id.unpack1("H*")).to eq(span_id_hex) + end - expect(last_transaction).to have_namespace(Appsignal::Transaction::HTTP_REQUEST) + it "starts a fresh root trace when no traceparent is present", :collector_mode do + start_collector_agent + make_request + + expect(root_span.parent_span_id).to eq(::OpenTelemetry::Trace::INVALID_SPAN_ID) + end end - it "wraps the response body in a BodyWrapper subclass" do + it_in_both_modes "wraps the response body in a BodyWrapper subclass" do _status, _headers, body = make_request expect(body).to be_kind_of(Appsignal::Rack::BodyWrapper) end context "without an error" do - before { make_request } - - it "calls the next middleware in the stack" do + it_in_both_modes "calls the next middleware in the stack" do + make_request expect(app).to be_called end - it "does not record an error" do - expect(last_transaction).to_not have_error + describe "does not record an error" do + def perform + make_request + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + + expect(last_transaction).to_not have_error + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + + expect(exception_events).to be_empty + end + end + + context "with a nested instrumentation middleware" do + let(:options) { { :instrument_event_name => "outer_event.category" } } + let(:app) do + described_class.new( + DummyApp.new, + :instrument_event_name => "inner_event.category" + ) + end + + # Only the middleware that created the transaction describes the + # response. The nested one finishes while the outer one's event is + # still open, so it would describe that event rather than the + # transaction. + it "describes the response on the transaction span only", :collector_mode do + start_collector_agent + make_request + + expect(root_span.attributes["http.response.status_code"]).to eq(200) + expect(event_spans.map(&:name)) + .to include("outer_event.category", "inner_event.category") + event_spans.each do |span| + expect(span.attributes).to_not have_key("http.response.status_code") + end + end end context "without :instrument_event_name option set" do let(:options) { {} } - it "does not record an instrumentation event" do - expect(last_transaction).to_not include_event + describe "does not record an instrumentation event" do + def perform + make_request + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + + expect(last_transaction).to_not include_event + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + + expect(event_spans).to be_empty + end end end context "with :instrument_event_name option set" do let(:options) { { :instrument_event_name => "event_name.category" } } - it "records an instrumentation event" do - expect(last_transaction).to include_event(:name => "event_name.category") + describe "records an instrumentation event" do + def perform + make_request + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + + expect(last_transaction).to include_event(:name => "event_name.category") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + + expect(event_spans.map(&:name)).to include("event_name.category") + span = event_spans.find { |s| s.name == "event_name.category" } + expect(span).not_to be_nil + expect(span.parent_span_id).to eq(root_span.span_id) + end end end - it "completes the transaction" do + # `be_completed` reads `backend._completed?` and `Appsignal::Transaction + # .current` is the thread-local, so both assertions are backend-agnostic. + it_in_both_modes "completes the transaction" do + make_request + expect(last_transaction).to be_completed expect(Appsignal::Transaction.current) .to be_kind_of(Appsignal::Transaction::NilTransaction) @@ -85,8 +203,24 @@ def make_request_with_error(error_class, error_message) context "when instrument_event_name option is nil" do let(:options) { { :instrument_event_name => nil } } - it "does not record an instrumentation event" do - expect(last_transaction).to_not include_events + describe "does not record an instrumentation event" do + def perform + make_request + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + + expect(last_transaction).to_not include_events + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + + expect(event_spans).to be_empty + end end end end @@ -95,117 +229,311 @@ def make_request_with_error(error_class, error_message) let(:error) { ExampleException.new("error message") } let(:app) { lambda { |_env| raise ExampleException, "error message" } } - it "create a transaction for the request" do - expect { make_request_with_error(ExampleException, "error message") } - .to(change { created_transactions.count }.by(1)) + describe "create a transaction for the request" do + def perform + make_request_with_error(ExampleException, "error message") + end - expect(last_transaction).to have_namespace(Appsignal::Transaction::HTTP_REQUEST) + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + expect { perform }.to(change { created_transactions.count }.by(1)) + + expect(last_transaction).to have_namespace(Appsignal::Transaction::HTTP_REQUEST) + end + + it "in collector mode", :collector_mode do + start_collector_agent + expect { perform }.to(change { created_transactions.count }.by(1)) + + expect(root_span.attributes["appsignal.namespace"]) + .to eq("web") + end end describe "error" do - before do - make_request_with_error(ExampleException, "error message") - end + describe "records the error" do + def perform + make_request_with_error(ExampleException, "error message") + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + + expect(last_transaction).to have_error("ExampleException", "error message") + end - it "records the error" do - expect(last_transaction).to have_error("ExampleException", "error message") + it "in collector mode", :collector_mode do + start_collector_agent + perform + + event = root_span.events.find { |e| e.name == "exception" } + expect(event).not_to be_nil + expect(event.attributes["exception.type"]).to eq("ExampleException") + expect(event.attributes["exception.message"]).to eq("error message") + expect(event.attributes["exception.stacktrace"]).to be_a(String) + expect(event.attributes["appsignal.alert_this_error"]).to eq(true) + expect(root_span.status.code).to eq(::OpenTelemetry::Trace::Status::ERROR) + end end - it "completes the transaction" do + it_in_both_modes "completes the transaction" do + make_request_with_error(ExampleException, "error message") + expect(last_transaction).to be_completed expect(Appsignal::Transaction.current) .to be_kind_of(Appsignal::Transaction::NilTransaction) end context "with :report_errors set to false" do - let(:app) { lambda { |_env| raise ExampleException, "error message" } } let(:options) { { :report_errors => false } } - it "does not record the exception on the transaction" do - expect(last_transaction).to_not have_error + describe "does not record the exception on the transaction" do + def perform + make_request_with_error(ExampleException, "error message") + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + + expect(last_transaction).to_not have_error + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + + expect(exception_events).to be_empty + end end end context "with :report_errors set to true" do - let(:app) { lambda { |_env| raise ExampleException, "error message" } } let(:options) { { :report_errors => true } } - it "records the exception on the transaction" do - expect(last_transaction).to have_error("ExampleException", "error message") + describe "records the exception on the transaction" do + def perform + make_request_with_error(ExampleException, "error message") + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + + expect(last_transaction).to have_error("ExampleException", "error message") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + + event = root_span.events.find { |e| e.name == "exception" } + expect(event).not_to be_nil + expect(event.attributes["exception.type"]).to eq("ExampleException") + expect(event.attributes["exception.message"]).to eq("error message") + expect(event.attributes["exception.stacktrace"]).to be_a(String) + expect(event.attributes["appsignal.alert_this_error"]).to eq(true) + expect(root_span.status.code).to eq(::OpenTelemetry::Trace::Status::ERROR) + end end end context "with :report_errors set to a lambda that returns false" do - let(:app) { lambda { |_env| raise ExampleException, "error message" } } let(:options) { { :report_errors => lambda { |_env| false } } } - it "does not record the exception on the transaction" do - expect(last_transaction).to_not have_error + describe "does not record the exception on the transaction" do + def perform + make_request_with_error(ExampleException, "error message") + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + + expect(last_transaction).to_not have_error + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + + expect(exception_events).to be_empty + end end end context "with :report_errors set to a lambda that returns true" do - let(:app) { lambda { |_env| raise ExampleException, "error message" } } let(:options) { { :report_errors => lambda { |_env| true } } } - it "records the exception on the transaction" do - expect(last_transaction).to have_error("ExampleException", "error message") + describe "records the exception on the transaction" do + def perform + make_request_with_error(ExampleException, "error message") + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + + expect(last_transaction).to have_error("ExampleException", "error message") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + + event = root_span.events.find { |e| e.name == "exception" } + expect(event).not_to be_nil + expect(event.attributes["exception.type"]).to eq("ExampleException") + expect(event.attributes["exception.message"]).to eq("error message") + expect(event.attributes["exception.stacktrace"]).to be_a(String) + expect(event.attributes["appsignal.alert_this_error"]).to eq(true) + expect(root_span.status.code).to eq(::OpenTelemetry::Trace::Status::ERROR) + end end end end end context "without action name metadata" do - it "reports no action name" do - make_request + describe "reports no action name" do + def perform + make_request + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform - expect(last_transaction).to_not have_action + expect(last_transaction).to_not have_action + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + + expect(root_span.attributes).to_not have_key("appsignal.action_name") + # With no action to group by, mirror agent mode (which does not + # report an actionless transaction) by flagging the subtrace so the + # collector drops it, instead of surfacing it under the placeholder + # span name. + expect(root_span.attributes["appsignal.ignore_subtrace"]).to be(true) + end end end # Partial duplicate tests from Appsignal::Rack::ApplyRackRequest that # ensure the request metadata is set on via the AbstractMiddleware. describe "request metadata" do - it "sets request metadata" do - env.merge!("PATH_INFO" => "/some/path", "REQUEST_METHOD" => "GET") - make_request + describe "sets request metadata" do + def perform + env.merge!("PATH_INFO" => "/some/path", "REQUEST_METHOD" => "GET") + make_request + end - expect(last_transaction).to include_metadata( - "request_method" => "GET", - "method" => "GET", - "request_path" => "/some/path", - "path" => "/some/path" - ) - expect(last_transaction).to include_environment( - "REQUEST_METHOD" => "GET", - "PATH_INFO" => "/some/path" - # and more, but we don't need to test Rack mock defaults - ) + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + + expect(last_transaction).to include_metadata( + "request_method" => "GET", + "method" => "GET", + "request_path" => "/some/path", + "path" => "/some/path" + ) + expect(last_transaction).to include_environment( + "REQUEST_METHOD" => "GET", + "PATH_INFO" => "/some/path" + # and more, but we don't need to test Rack mock defaults + ) + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + + # Metadata is emitted as `appsignal.tag.*` attributes. + expect(root_span.attributes["appsignal.tag.request_method"]).to eq("GET") + expect(root_span.attributes["appsignal.tag.method"]).to eq("GET") + expect(root_span.attributes["appsignal.tag.request_path"]).to eq("/some/path") + expect(root_span.attributes["appsignal.tag.path"]).to eq("/some/path") + # Only true HTTP headers map to the `http.request.header.*` + # convention; the non-header CGI vars (REQUEST_METHOD, PATH_INFO) + # are intentionally dropped. + expect(root_span.attributes["http.request.header.accept"]).to eq("application/json") + expect(root_span.attributes.keys).to_not include("http.request.header.request-method") + end end - it "sets request parameters" do - make_request + describe "sets request parameters" do + def perform + make_request + end - expect(last_transaction).to include_params( - "page" => "2", - "query" => "lorem" - ) + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + + expect(last_transaction).to include_params( + "page" => "2", + "query" => "lorem" + ) + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + + params = JSON.parse(root_span.attributes["appsignal.request.payload"]) + expect(params).to include("page" => "2", "query" => "lorem") + end end - it "sets session data" do - make_request + describe "sets session data" do + def perform + make_request + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform - expect(last_transaction).to include_session_data("session" => "data", "user_id" => 123) + expect(last_transaction).to include_session_data("session" => "data", "user_id" => 123) + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + + session = JSON.parse(root_span.attributes["appsignal.request.session_data"]) + expect(session).to include("session" => "data", "user_id" => 123) + end end context "with queue start header" do let(:queue_start_time) { fixed_time * 1_000 } - it "sets the queue start" do - env["HTTP_X_REQUEST_START"] = "t=#{queue_start_time.to_i}" # in milliseconds - make_request + describe "sets the queue start" do + def perform + env["HTTP_X_REQUEST_START"] = "t=#{queue_start_time.to_i}" # in milliseconds + make_request + end - expect(last_transaction).to have_queue_start(queue_start_time) + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + + expect(last_transaction).to have_queue_start(queue_start_time) + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + + queue_event = Array(root_span.events).find { |e| e.name == "appsignal.queue_start" } + expect(queue_event.timestamp) + .to eq((Time.at(queue_start_time.to_i / 1000.0).to_r * 1_000_000_000).to_i) + end end end @@ -238,53 +566,73 @@ def session { :request_class => SomeFilteredRequest, :params_method => :filtered_params } end - it "uses the overridden request class and params method to fetch params" do - make_request + describe "uses the overridden request class and params method to fetch params" do + def perform + make_request + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + + expect(last_transaction).to include_params("abc" => "123") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform - expect(last_transaction).to include_params("abc" => "123") + params = JSON.parse(root_span.attributes["appsignal.request.payload"]) + expect(params).to include("abc" => "123") + end end - it "uses the overridden request class to fetch session data" do - make_request + describe "uses the overridden request class to fetch session data" do + def perform + make_request + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + + expect(last_transaction).to include_session_data("data" => "value") + end - expect(last_transaction).to include_session_data("data" => "value") + it "in collector mode", :collector_mode do + start_collector_agent + perform + + session = JSON.parse(root_span.attributes["appsignal.request.session_data"]) + expect(session).to include("data" => "value") + end end end end context "with parent instrumentation" do let(:transaction) { http_request_transaction } - before do + + # The parent transaction's backend is mode-specific, so it must be built + # after the agent starts -- called from each example body, not a `before`. + def setup_parent_transaction env[Appsignal::Rack::APPSIGNAL_TRANSACTION] = transaction set_current_transaction(transaction) end - it "uses the existing transaction" do + it_in_both_modes "uses the existing transaction" do + setup_parent_transaction make_request expect { make_request }.to_not(change { created_transactions.count }) end - it "wraps the response body in a BodyWrapper subclass" do - _status, _headers, body = make_request - expect(body).to be_kind_of(Appsignal::Rack::BodyWrapper) - - body.to_ary - response_events = - last_transaction.to_h["events"].count do |event| - event["name"] == "process_response_body.rack" - end - expect(response_events).to eq(1) - end - - context "when the response body is already instrumented" do - let(:body) { Appsignal::Rack::BodyWrapper.wrap(["hello!"], transaction) } - let(:app) { DummyApp.new { [200, {}, body] } } - - it "doesn't wrap the body again" do - env[Appsignal::Rack::APPSIGNAL_RESPONSE_INSTRUMENTED] = true + describe "wraps the response body in a BodyWrapper subclass" do + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + setup_parent_transaction _status, _headers, body = make_request - expect(body).to eq(body) + expect(body).to be_kind_of(Appsignal::Rack::BodyWrapper) body.to_ary response_events = @@ -293,31 +641,118 @@ def session end expect(response_events).to eq(1) end + + it "in collector mode", :collector_mode do + start_collector_agent + setup_parent_transaction + _status, _headers, body = make_request + expect(body).to be_kind_of(Appsignal::Rack::BodyWrapper) + + body.to_ary + response_events = + event_spans.count do |span| + event_category(span) == "process_response_body.rack" + end + expect(response_events).to eq(1) + end + end + + context "when the response body is already instrumented" do + let(:body) { Appsignal::Rack::BodyWrapper.wrap(["hello!"], transaction) } + let(:app) { DummyApp.new { [200, {}, body] } } + + describe "doesn't wrap the body again" do + def perform + setup_parent_transaction + env[Appsignal::Rack::APPSIGNAL_RESPONSE_INSTRUMENTED] = true + _status, _headers, body = make_request + expect(body).to eq(body) + body.to_ary + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + + response_events = + last_transaction.to_h["events"].count do |event| + event["name"] == "process_response_body.rack" + end + expect(response_events).to eq(1) + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + + response_events = + event_spans.count do |span| + event_category(span) == "process_response_body.rack" + end + expect(response_events).to eq(1) + end + end end context "with error" do let(:app) { lambda { |_env| raise ExampleException, "error message" } } - it "doesn't record the error on the transaction" do - make_request_with_error(ExampleException, "error message") + describe "doesn't record the error on the transaction" do + def perform + setup_parent_transaction + make_request_with_error(ExampleException, "error message") + end - expect(last_transaction).to_not have_error + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + + expect(last_transaction).to_not have_error + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + # The middleware leaves the parent open; finish it so its span + # exports and we can confirm no exception event was recorded. + Appsignal::Transaction.complete_current! + + expect(exception_events).to be_empty + end end end - it "doesn't complete the existing transaction" do + it_in_both_modes "doesn't complete the existing transaction" do + setup_parent_transaction make_request expect(env[Appsignal::Rack::APPSIGNAL_TRANSACTION]).to_not be_completed end context "with custom set action name" do - it "does not overwrite the action name" do - env[Appsignal::Rack::APPSIGNAL_TRANSACTION].set_action("My custom action") - env["appsignal.action"] = "POST /my-action" - make_request + describe "does not overwrite the action name" do + def perform + setup_parent_transaction + env[Appsignal::Rack::APPSIGNAL_TRANSACTION].set_action("My custom action") + env["appsignal.action"] = "POST /my-action" + make_request + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform - expect(last_transaction).to have_action("My custom action") + expect(last_transaction).to have_action("My custom action") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + Appsignal::Transaction.complete_current! + + expect(root_span.name).to eq("My custom action") + expect(root_span.attributes["appsignal.action_name"]).to eq("My custom action") + end end end @@ -325,10 +760,26 @@ def session let(:app) { lambda { |_env| raise ExampleException, "error message" } } let(:options) { { :report_errors => false } } - it "does not record the error on the transaction" do - make_request_with_error(ExampleException, "error message") + describe "does not record the error on the transaction" do + def perform + setup_parent_transaction + make_request_with_error(ExampleException, "error message") + end - expect(last_transaction).to_not have_error + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + + expect(last_transaction).to_not have_error + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + Appsignal::Transaction.complete_current! + + expect(exception_events).to be_empty + end end end @@ -336,10 +787,32 @@ def session let(:app) { lambda { |_env| raise ExampleException, "error message" } } let(:options) { { :report_errors => true } } - it "records the error on the transaction" do - make_request_with_error(ExampleException, "error message") + describe "records the error on the transaction" do + def perform + setup_parent_transaction + make_request_with_error(ExampleException, "error message") + end - expect(last_transaction).to have_error("ExampleException", "error message") + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + + expect(last_transaction).to have_error("ExampleException", "error message") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + Appsignal::Transaction.complete_current! + + event = root_span.events.find { |e| e.name == "exception" } + expect(event).not_to be_nil + expect(event.attributes["exception.type"]).to eq("ExampleException") + expect(event.attributes["exception.message"]).to eq("error message") + expect(event.attributes["exception.stacktrace"]).to be_a(String) + expect(event.attributes["appsignal.alert_this_error"]).to eq(true) + expect(root_span.status.code).to eq(::OpenTelemetry::Trace::Status::ERROR) + end end end @@ -347,10 +820,26 @@ def session let(:app) { lambda { |_env| raise ExampleException, "error message" } } let(:options) { { :report_errors => lambda { |_env| false } } } - it "does not record the exception on the transaction" do - make_request_with_error(ExampleException, "error message") + describe "does not record the exception on the transaction" do + def perform + setup_parent_transaction + make_request_with_error(ExampleException, "error message") + end - expect(last_transaction).to_not have_error + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + + expect(last_transaction).to_not have_error + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + Appsignal::Transaction.complete_current! + + expect(exception_events).to be_empty + end end end @@ -358,10 +847,32 @@ def session let(:app) { lambda { |_env| raise ExampleException, "error message" } } let(:options) { { :report_errors => lambda { |_env| true } } } - it "records the error on the transaction" do - make_request_with_error(ExampleException, "error message") + describe "records the error on the transaction" do + def perform + setup_parent_transaction + make_request_with_error(ExampleException, "error message") + end - expect(last_transaction).to have_error("ExampleException", "error message") + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + + expect(last_transaction).to have_error("ExampleException", "error message") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + Appsignal::Transaction.complete_current! + + event = root_span.events.find { |e| e.name == "exception" } + expect(event).not_to be_nil + expect(event.attributes["exception.type"]).to eq("ExampleException") + expect(event.attributes["exception.message"]).to eq("error message") + expect(event.attributes["exception.stacktrace"]).to be_a(String) + expect(event.attributes["appsignal.alert_this_error"]).to eq(true) + expect(root_span.status.code).to eq(::OpenTelemetry::Trace::Status::ERROR) + end end end end diff --git a/spec/lib/appsignal/rack/body_wrapper_spec.rb b/spec/lib/appsignal/rack/body_wrapper_spec.rb index 0a425cb3e..56a38f36a 100644 --- a/spec/lib/appsignal/rack/body_wrapper_spec.rb +++ b/spec/lib/appsignal/rack/body_wrapper_spec.rb @@ -1,11 +1,47 @@ describe Appsignal::Rack::BodyWrapper do - let(:transaction) { http_request_transaction } - before do - start_agent - set_current_transaction(transaction) + # Create the transaction inside the example (lazily, on first reference) and + # set it as the current transaction. In collector mode it must be created + # after the mode context's `before` has enabled collector mode, so it gets + # the OpenTelemetry backend. + let(:transaction) do + http_request_transaction.tap { |t| set_current_transaction(t) } end - it "forwards method calls to the body if the method doesn't exist" do + # Collector-mode assertion helpers. The wrapper does not complete the + # transaction, so finish it here to export its spans, then assert on the + # recorded exception events / child event spans. + def expect_collector_error(type, message) + transaction.complete + event = root_span.events.find { |e| e.name == "exception" } + expect(event).not_to be_nil + expect(event.attributes["exception.type"]).to eq(type) + expect(event.attributes["exception.message"]).to eq(message) + expect(event.attributes["exception.stacktrace"]).to be_a(String) + expect(event.attributes["appsignal.alert_this_error"]).to eq(true) + expect(root_span.status.code).to eq(::OpenTelemetry::Trace::Status::ERROR) + end + + def expect_collector_no_error + transaction.complete + expect(exception_events).to be_empty + end + + def expect_collector_event(name, title = nil) + transaction.complete + # The event name leads the span name. When there is a human-readable + # title it follows in parentheses, otherwise the name stands alone. + span = event_span_for(name) + expect(span).not_to be_nil + expect(span.parent_span_id).to eq(root_span.span_id) + expect(span.name).to eq("#{name} (#{title})") if title + end + + def expect_collector_no_event(name) + transaction.complete + expect(event_spans_for(name)).to be_empty + end + + it_in_both_modes "forwards method calls to the body if the method doesn't exist" do fake_body = double( :body => ["some body"], :some_method => :some_value @@ -19,7 +55,7 @@ expect(wrapped.some_method).to eq(:some_value) end - it "doesn't respond to methods the Rack::BodyProxy doesn't respond to" do + it_in_both_modes "doesn't respond to methods the Rack::BodyProxy doesn't respond to" do body = Rack::BodyProxy.new(["body"]) wrapped = described_class.wrap(body, transaction) @@ -31,7 +67,7 @@ end describe "with a body only supporting each()" do - it "wraps with appropriate class" do + it_in_both_modes "wraps with appropriate class" do fake_body = double(:each => nil) wrapped = described_class.wrap(fake_body, transaction) @@ -41,183 +77,418 @@ expect(wrapped).to respond_to(:close) end - it "reads out the body in full using each" do - fake_body = double - expect(fake_body).to receive(:each).once.and_yield("a").and_yield("b").and_yield("c") + describe "reads out the body in full using each" do + def perform + fake_body = double + expect(fake_body).to receive(:each).once.and_yield("a").and_yield("b").and_yield("c") - wrapped = described_class.wrap(fake_body, transaction) - expect { |b| wrapped.each(&b) }.to yield_successive_args("a", "b", "c") + wrapped = described_class.wrap(fake_body, transaction) + expect { |b| wrapped.each(&b) }.to yield_successive_args("a", "b", "c") + end - expect(transaction).to include_event( - "name" => "process_response_body.rack", - "title" => "Process Rack response body (#each)" - ) - end + it "in agent mode", :agent_mode do + start_agent - it "returns an Enumerator if each() gets called without a block" do - fake_body = double - expect(fake_body).to receive(:each).once.and_yield("a").and_yield("b").and_yield("c") + perform - wrapped = described_class.wrap(fake_body, transaction) - enum = wrapped.each - expect(enum).to be_kind_of(Enumerator) - expect { |b| enum.each(&b) }.to yield_successive_args("a", "b", "c") + expect(transaction).to include_event( + "name" => "process_response_body.rack", + "title" => "Process Rack response body (#each)" + ) + end - expect(transaction).to_not include_event("name" => "process_response_body.rack") + it "in collector mode", :collector_mode do + start_collector_agent + + perform + + expect_collector_event( + "process_response_body.rack", + "Process Rack response body (#each)" + ) + end end - it "sets the exception raised inside each() on the transaction" do - fake_body = double - expect(fake_body).to receive(:each).once.and_raise(ExampleException, "error message") + describe "returns an Enumerator if each() gets called without a block" do + def perform + fake_body = double + expect(fake_body).to receive(:each).once.and_yield("a").and_yield("b").and_yield("c") - wrapped = described_class.wrap(fake_body, transaction) - expect do - expect { |b| wrapped.each(&b) }.to yield_control - end.to raise_error(ExampleException, "error message") + wrapped = described_class.wrap(fake_body, transaction) + enum = wrapped.each + expect(enum).to be_kind_of(Enumerator) + expect { |b| enum.each(&b) }.to yield_successive_args("a", "b", "c") + end + + # Iterating the returned Enumerator reads the body through the same + # instrumented `each`, so it is recorded exactly as passing a block is. + it "in agent mode", :agent_mode do + start_agent - expect(transaction).to have_error("ExampleException", "error message") + perform + + expect(transaction).to include_event( + "name" => "process_response_body.rack", + "title" => "Process Rack response body (#each)" + ) + end + + it "in collector mode", :collector_mode do + start_collector_agent + + perform + + expect_collector_event( + "process_response_body.rack", + "Process Rack response body (#each)" + ) + end end - it "doesn't report EPIPE error" do - fake_body = double - expect(fake_body).to receive(:each).once.and_raise(Errno::EPIPE) + describe "sets the exception raised inside each() on the transaction" do + def perform + fake_body = double + expect(fake_body).to receive(:each).once.and_raise(ExampleException, "error message") - wrapped = described_class.wrap(fake_body, transaction) - expect do - expect { |b| wrapped.each(&b) }.to yield_control - end.to raise_error(Errno::EPIPE) + wrapped = described_class.wrap(fake_body, transaction) + expect do + expect { |b| wrapped.each(&b) }.to yield_control + end.to raise_error(ExampleException, "error message") + end + + it "in agent mode", :agent_mode do + start_agent + + perform + + expect(transaction).to have_error("ExampleException", "error message") + end + + it "in collector mode", :collector_mode do + start_collector_agent - expect(transaction).to_not have_error + perform + + expect_collector_error("ExampleException", "error message") + end end - it "doesn't report ECONNRESET error" do - fake_body = double - expect(fake_body).to receive(:each).once.and_raise(Errno::ECONNRESET) + describe "doesn't report EPIPE error" do + def perform + fake_body = double + expect(fake_body).to receive(:each).once.and_raise(Errno::EPIPE) - wrapped = described_class.wrap(fake_body, transaction) - expect do - expect { |b| wrapped.each(&b) }.to yield_control - end.to raise_error(Errno::ECONNRESET) + wrapped = described_class.wrap(fake_body, transaction) + expect do + expect { |b| wrapped.each(&b) }.to yield_control + end.to raise_error(Errno::EPIPE) + end + + it "in agent mode", :agent_mode do + start_agent - expect(transaction).to_not have_error + perform + expect(transaction).to_not have_error + end + + it "in collector mode", :collector_mode do + start_collector_agent + + perform + expect_collector_no_error + end end - it "does not report EPIPE error when it's the error cause" do - error = error_with_cause(StandardError, "error message", Errno::EPIPE) - fake_body = double - expect(fake_body).to receive(:each).once.and_raise(error) + describe "doesn't report ECONNRESET error" do + def perform + fake_body = double + expect(fake_body).to receive(:each).once.and_raise(Errno::ECONNRESET) - wrapped = described_class.wrap(fake_body, transaction) - expect do - expect { |b| wrapped.each(&b) }.to yield_control - end.to raise_error(StandardError, "error message") + wrapped = described_class.wrap(fake_body, transaction) + expect do + expect { |b| wrapped.each(&b) }.to yield_control + end.to raise_error(Errno::ECONNRESET) + end + + it "in agent mode", :agent_mode do + start_agent + + perform + expect(transaction).to_not have_error + end + + it "in collector mode", :collector_mode do + start_collector_agent - expect(transaction).to_not have_error + perform + expect_collector_no_error + end end - it "does not report EPIPE error when it's the nested error cause" do - error = error_with_nested_cause(StandardError, "error message", Errno::EPIPE) - fake_body = double - expect(fake_body).to receive(:each).once.and_raise(error) + describe "does not report EPIPE error when it's the error cause" do + def perform + error = error_with_cause(StandardError, "error message", Errno::EPIPE) + fake_body = double + expect(fake_body).to receive(:each).once.and_raise(error) - wrapped = described_class.wrap(fake_body, transaction) - expect do - expect { |b| wrapped.each(&b) }.to yield_control - end.to raise_error(StandardError, "error message") + wrapped = described_class.wrap(fake_body, transaction) + expect do + expect { |b| wrapped.each(&b) }.to yield_control + end.to raise_error(StandardError, "error message") + end + + it "in agent mode", :agent_mode do + start_agent + + perform + expect(transaction).to_not have_error + end - expect(transaction).to_not have_error + it "in collector mode", :collector_mode do + start_collector_agent + + perform + expect_collector_no_error + end end - it "does not report ECONNRESET error when it's the error cause" do - error = error_with_cause(StandardError, "error message", Errno::ECONNRESET) - fake_body = double - expect(fake_body).to receive(:each).once.and_raise(error) + describe "does not report EPIPE error when it's the nested error cause" do + def perform + error = error_with_nested_cause(StandardError, "error message", Errno::EPIPE) + fake_body = double + expect(fake_body).to receive(:each).once.and_raise(error) - wrapped = described_class.wrap(fake_body, transaction) - expect do - expect { |b| wrapped.each(&b) }.to yield_control - end.to raise_error(StandardError, "error message") + wrapped = described_class.wrap(fake_body, transaction) + expect do + expect { |b| wrapped.each(&b) }.to yield_control + end.to raise_error(StandardError, "error message") + end + + it "in agent mode", :agent_mode do + start_agent - expect(transaction).to_not have_error + perform + expect(transaction).to_not have_error + end + + it "in collector mode", :collector_mode do + start_collector_agent + + perform + expect_collector_no_error + end end - it "does not report ECONNRESET error when it's the nested error cause" do - error = error_with_nested_cause(StandardError, "error message", Errno::ECONNRESET) - fake_body = double - expect(fake_body).to receive(:each).once.and_raise(error) + describe "does not report ECONNRESET error when it's the error cause" do + def perform + error = error_with_cause(StandardError, "error message", Errno::ECONNRESET) + fake_body = double + expect(fake_body).to receive(:each).once.and_raise(error) - wrapped = described_class.wrap(fake_body, transaction) - expect do - expect { |b| wrapped.each(&b) }.to yield_control - end.to raise_error(StandardError, "error message") + wrapped = described_class.wrap(fake_body, transaction) + expect do + expect { |b| wrapped.each(&b) }.to yield_control + end.to raise_error(StandardError, "error message") + end + + it "in agent mode", :agent_mode do + start_agent + + perform + expect(transaction).to_not have_error + end + + it "in collector mode", :collector_mode do + start_collector_agent - expect(transaction).to_not have_error + perform + expect_collector_no_error + end end - it "closes the body and tracks an instrumentation event when it gets closed" do - fake_body = double(:close => nil) - expect(fake_body).to receive(:each).once.and_yield("a").and_yield("b").and_yield("c") + describe "does not report ECONNRESET error when it's the nested error cause" do + def perform + error = error_with_nested_cause(StandardError, "error message", Errno::ECONNRESET) + fake_body = double + expect(fake_body).to receive(:each).once.and_raise(error) - wrapped = described_class.wrap(fake_body, transaction) - expect { |b| wrapped.each(&b) }.to yield_successive_args("a", "b", "c") - wrapped.close + wrapped = described_class.wrap(fake_body, transaction) + expect do + expect { |b| wrapped.each(&b) }.to yield_control + end.to raise_error(StandardError, "error message") + end + + it "in agent mode", :agent_mode do + start_agent + + perform + expect(transaction).to_not have_error + end - expect(transaction).to include_event("name" => "close_response_body.rack") + it "in collector mode", :collector_mode do + start_collector_agent + + perform + expect_collector_no_error + end end - it "reports an error if an error occurs on close" do - fake_body = double - expect(fake_body).to receive(:close).and_raise(ExampleException, "error message") + describe "closes the body and tracks an instrumentation event when it gets closed" do + def perform + fake_body = double(:close => nil) + expect(fake_body).to receive(:each).once.and_yield("a").and_yield("b").and_yield("c") - wrapped = described_class.wrap(fake_body, transaction) - expect do + wrapped = described_class.wrap(fake_body, transaction) + expect { |b| wrapped.each(&b) }.to yield_successive_args("a", "b", "c") wrapped.close - end.to raise_error(ExampleException, "error message") + end + + it "in agent mode", :agent_mode do + start_agent + + perform + + expect(transaction).to include_event("name" => "close_response_body.rack") + end + + it "in collector mode", :collector_mode do + start_collector_agent + + perform - expect(transaction).to have_error("ExampleException", "error message") + expect_collector_event("close_response_body.rack") + end end - it "doesn't report EPIPE error on close" do - fake_body = double - expect(fake_body).to receive(:close).and_raise(Errno::EPIPE) + describe "reports an error if an error occurs on close" do + def perform + fake_body = double + expect(fake_body).to receive(:close).and_raise(ExampleException, "error message") - wrapped = described_class.wrap(fake_body, transaction) - expect { wrapped.close }.to raise_error(Errno::EPIPE) - expect(transaction).to_not have_error + wrapped = described_class.wrap(fake_body, transaction) + expect do + wrapped.close + end.to raise_error(ExampleException, "error message") + end + + it "in agent mode", :agent_mode do + start_agent + + perform + + expect(transaction).to have_error("ExampleException", "error message") + end + + it "in collector mode", :collector_mode do + start_collector_agent + + perform + + expect_collector_error("ExampleException", "error message") + end end - it "doesn't report ECONNRESET error on close" do - fake_body = double - expect(fake_body).to receive(:close).and_raise(Errno::ECONNRESET) + describe "doesn't report EPIPE error on close" do + def perform + fake_body = double + expect(fake_body).to receive(:close).and_raise(Errno::EPIPE) - wrapped = described_class.wrap(fake_body, transaction) - expect { wrapped.close }.to raise_error(Errno::ECONNRESET) - expect(transaction).to_not have_error + wrapped = described_class.wrap(fake_body, transaction) + expect { wrapped.close }.to raise_error(Errno::EPIPE) + end + + it "in agent mode", :agent_mode do + start_agent + + perform + expect(transaction).to_not have_error + end + + it "in collector mode", :collector_mode do + start_collector_agent + + perform + expect_collector_no_error + end end - it "does not report EPIPE error when it's the error cause on close" do - error = error_with_cause(StandardError, "error message", Errno::EPIPE) - fake_body = double - expect(fake_body).to receive(:close).and_raise(error) + describe "doesn't report ECONNRESET error on close" do + def perform + fake_body = double + expect(fake_body).to receive(:close).and_raise(Errno::ECONNRESET) - wrapped = described_class.wrap(fake_body, transaction) - expect { wrapped.close }.to raise_error(StandardError, "error message") - expect(transaction).to_not have_error + wrapped = described_class.wrap(fake_body, transaction) + expect { wrapped.close }.to raise_error(Errno::ECONNRESET) + end + + it "in agent mode", :agent_mode do + start_agent + + perform + expect(transaction).to_not have_error + end + + it "in collector mode", :collector_mode do + start_collector_agent + + perform + expect_collector_no_error + end end - it "does not report ECONNRESET error when it's the error cause on close" do - error = error_with_cause(StandardError, "error message", Errno::ECONNRESET) - fake_body = double - expect(fake_body).to receive(:close).and_raise(error) + describe "does not report EPIPE error when it's the error cause on close" do + def perform + error = error_with_cause(StandardError, "error message", Errno::EPIPE) + fake_body = double + expect(fake_body).to receive(:close).and_raise(error) - wrapped = described_class.wrap(fake_body, transaction) - expect { wrapped.close }.to raise_error(StandardError, "error message") - expect(transaction).to_not have_error + wrapped = described_class.wrap(fake_body, transaction) + expect { wrapped.close }.to raise_error(StandardError, "error message") + end + + it "in agent mode", :agent_mode do + start_agent + + perform + expect(transaction).to_not have_error + end + + it "in collector mode", :collector_mode do + start_collector_agent + + perform + expect_collector_no_error + end + end + + describe "does not report ECONNRESET error when it's the error cause on close" do + def perform + error = error_with_cause(StandardError, "error message", Errno::ECONNRESET) + fake_body = double + expect(fake_body).to receive(:close).and_raise(error) + + wrapped = described_class.wrap(fake_body, transaction) + expect { wrapped.close }.to raise_error(StandardError, "error message") + end + + it "in agent mode", :agent_mode do + start_agent + + perform + expect(transaction).to_not have_error + end + + it "in collector mode", :collector_mode do + start_collector_agent + + perform + expect_collector_no_error + end end end describe "with a body supporting both each() and call" do - it "wraps with the wrapper that exposes each" do + it_in_both_modes "wraps with the wrapper that exposes each" do fake_body = double( :each => true, :call => "original call" @@ -236,7 +507,7 @@ describe "with a body supporting both to_ary and each" do let(:fake_body) { double(:each => nil, :to_ary => []) } - it "wraps with appropriate class" do + it_in_both_modes "wraps with appropriate class" do wrapped = described_class.wrap(fake_body, transaction) expect(wrapped).to respond_to(:each) expect(wrapped).to respond_to(:to_ary) @@ -245,138 +516,292 @@ expect(wrapped).to respond_to(:close) end - it "reads out the body in full using each" do - expect(fake_body).to receive(:each).once.and_yield("a").and_yield("b").and_yield("c") + describe "reads out the body in full using each" do + def perform + expect(fake_body).to receive(:each).once.and_yield("a").and_yield("b").and_yield("c") - wrapped = described_class.wrap(fake_body, transaction) - expect { |b| wrapped.each(&b) }.to yield_successive_args("a", "b", "c") + wrapped = described_class.wrap(fake_body, transaction) + expect { |b| wrapped.each(&b) }.to yield_successive_args("a", "b", "c") + end - expect(transaction).to include_event( - "name" => "process_response_body.rack", - "title" => "Process Rack response body (#each)" - ) + it "in agent mode", :agent_mode do + start_agent + + perform + + expect(transaction).to include_event( + "name" => "process_response_body.rack", + "title" => "Process Rack response body (#each)" + ) + end + + it "in collector mode", :collector_mode do + start_collector_agent + + perform + + expect_collector_event( + "process_response_body.rack", + "Process Rack response body (#each)" + ) + end end - it "sets the exception raised inside each() into the Appsignal transaction" do - expect(fake_body).to receive(:each).once.and_raise(ExampleException, "error message") + describe "sets the exception raised inside each() into the Appsignal transaction" do + def perform + expect(fake_body).to receive(:each).once.and_raise(ExampleException, "error message") - wrapped = described_class.wrap(fake_body, transaction) - expect do - expect { |b| wrapped.each(&b) }.to yield_control - end.to raise_error(ExampleException, "error message") + wrapped = described_class.wrap(fake_body, transaction) + expect do + expect { |b| wrapped.each(&b) }.to yield_control + end.to raise_error(ExampleException, "error message") + end + + it "in agent mode", :agent_mode do + start_agent + + perform + + expect(transaction).to have_error("ExampleException", "error message") + end + + it "in collector mode", :collector_mode do + start_collector_agent + + perform - expect(transaction).to have_error("ExampleException", "error message") + expect_collector_error("ExampleException", "error message") + end end - it "doesn't report EPIPE error" do - expect(fake_body).to receive(:each).once.and_raise(Errno::EPIPE) + describe "doesn't report EPIPE error" do + def perform + expect(fake_body).to receive(:each).once.and_raise(Errno::EPIPE) - wrapped = described_class.wrap(fake_body, transaction) - expect do - expect { |b| wrapped.each(&b) }.to yield_control - end.to raise_error(Errno::EPIPE) + wrapped = described_class.wrap(fake_body, transaction) + expect do + expect { |b| wrapped.each(&b) }.to yield_control + end.to raise_error(Errno::EPIPE) + end + + it "in agent mode", :agent_mode do + start_agent + + perform + expect(transaction).to_not have_error + end - expect(transaction).to_not have_error + it "in collector mode", :collector_mode do + start_collector_agent + + perform + expect_collector_no_error + end end - it "doesn't report ECONNRESET error" do - expect(fake_body).to receive(:each).once.and_raise(Errno::ECONNRESET) + describe "doesn't report ECONNRESET error" do + def perform + expect(fake_body).to receive(:each).once.and_raise(Errno::ECONNRESET) - wrapped = described_class.wrap(fake_body, transaction) - expect do - expect { |b| wrapped.each(&b) }.to yield_control - end.to raise_error(Errno::ECONNRESET) + wrapped = described_class.wrap(fake_body, transaction) + expect do + expect { |b| wrapped.each(&b) }.to yield_control + end.to raise_error(Errno::ECONNRESET) + end + + it "in agent mode", :agent_mode do + start_agent - expect(transaction).to_not have_error + perform + expect(transaction).to_not have_error + end + + it "in collector mode", :collector_mode do + start_collector_agent + + perform + expect_collector_no_error + end end - it "does not report EPIPE error when it's the error cause" do - error = error_with_cause(StandardError, "error message", Errno::EPIPE) - fake_body = double - expect(fake_body).to receive(:each).once.and_raise(error) + describe "does not report EPIPE error when it's the error cause (each)" do + def perform + error = error_with_cause(StandardError, "error message", Errno::EPIPE) + fake_body = double + expect(fake_body).to receive(:each).once.and_raise(error) - wrapped = described_class.wrap(fake_body, transaction) - expect do - expect { |b| wrapped.each(&b) }.to yield_control - end.to raise_error(StandardError, "error message") + wrapped = described_class.wrap(fake_body, transaction) + expect do + expect { |b| wrapped.each(&b) }.to yield_control + end.to raise_error(StandardError, "error message") + end + + it "in agent mode", :agent_mode do + start_agent + + perform + expect(transaction).to_not have_error + end + + it "in collector mode", :collector_mode do + start_collector_agent - expect(transaction).to_not have_error + perform + expect_collector_no_error + end end - it "does not report ECONNRESET error when it's the error cause" do - error = error_with_cause(StandardError, "error message", Errno::ECONNRESET) - fake_body = double - expect(fake_body).to receive(:each).once.and_raise(error) + describe "does not report ECONNRESET error when it's the error cause (each)" do + def perform + error = error_with_cause(StandardError, "error message", Errno::ECONNRESET) + fake_body = double + expect(fake_body).to receive(:each).once.and_raise(error) - wrapped = described_class.wrap(fake_body, transaction) - expect do - expect { |b| wrapped.each(&b) }.to yield_control - end.to raise_error(StandardError, "error message") + wrapped = described_class.wrap(fake_body, transaction) + expect do + expect { |b| wrapped.each(&b) }.to yield_control + end.to raise_error(StandardError, "error message") + end + + it "in agent mode", :agent_mode do + start_agent + + perform + expect(transaction).to_not have_error + end - expect(transaction).to_not have_error + it "in collector mode", :collector_mode do + start_collector_agent + + perform + expect_collector_no_error + end end - it "reads out the body in full using to_ary" do - expect(fake_body).to receive(:to_ary).and_return(["one", "two", "three"]) + describe "reads out the body in full using to_ary" do + def perform + expect(fake_body).to receive(:to_ary).and_return(["one", "two", "three"]) - wrapped = described_class.wrap(fake_body, transaction) - expect(wrapped.to_ary).to eq(["one", "two", "three"]) + wrapped = described_class.wrap(fake_body, transaction) + expect(wrapped.to_ary).to eq(["one", "two", "three"]) + end - expect(transaction).to include_event( - "name" => "process_response_body.rack", - "title" => "Process Rack response body (#to_ary)" - ) + it "in agent mode", :agent_mode do + start_agent + + perform + + expect(transaction).to include_event( + "name" => "process_response_body.rack", + "title" => "Process Rack response body (#to_ary)" + ) + end + + it "in collector mode", :collector_mode do + start_collector_agent + + perform + + expect_collector_event( + "process_response_body.rack", + "Process Rack response body (#to_ary)" + ) + end end - it "sends the exception raised inside to_ary() into the Appsignal and closes transaction" do - fake_body = double - allow(fake_body).to receive(:each) - expect(fake_body).to receive(:to_ary).once.and_raise(ExampleException, "error message") - expect(fake_body).to_not receive(:close) # Per spec we expect the body has closed itself + describe "sends the exception raised inside to_ary() to AppSignal and closes" do + def perform + fake_body = double + allow(fake_body).to receive(:each) + expect(fake_body).to receive(:to_ary).once.and_raise(ExampleException, "error message") + expect(fake_body).to_not receive(:close) # Per spec we expect the body has closed itself + + wrapped = described_class.wrap(fake_body, transaction) + expect do + wrapped.to_ary + end.to raise_error(ExampleException, "error message") + end - wrapped = described_class.wrap(fake_body, transaction) - expect do - wrapped.to_ary - end.to raise_error(ExampleException, "error message") + it "in agent mode", :agent_mode do + start_agent - expect(transaction).to have_error("ExampleException", "error message") + perform + + expect(transaction).to have_error("ExampleException", "error message") + end + + it "in collector mode", :collector_mode do + start_collector_agent + + perform + + expect_collector_error("ExampleException", "error message") + end end - it "does not report EPIPE error when it's the error cause" do - error = error_with_cause(StandardError, "error message", Errno::EPIPE) - fake_body = double - allow(fake_body).to receive(:each) - expect(fake_body).to receive(:to_ary).once.and_raise(error) - expect(fake_body).to_not receive(:close) # Per spec we expect the body has closed itself + describe "does not report EPIPE error when it's the error cause (to_ary)" do + def perform + error = error_with_cause(StandardError, "error message", Errno::EPIPE) + fake_body = double + allow(fake_body).to receive(:each) + expect(fake_body).to receive(:to_ary).once.and_raise(error) + expect(fake_body).to_not receive(:close) # Per spec we expect the body has closed itself + + wrapped = described_class.wrap(fake_body, transaction) + expect do + wrapped.to_ary + end.to raise_error(StandardError, "error message") + end - wrapped = described_class.wrap(fake_body, transaction) - expect do - wrapped.to_ary - end.to raise_error(StandardError, "error message") + it "in agent mode", :agent_mode do + start_agent - expect(transaction).to_not have_error + perform + expect(transaction).to_not have_error + end + + it "in collector mode", :collector_mode do + start_collector_agent + + perform + expect_collector_no_error + end end - it "does not report ECONNRESET error when it's the error cause" do - error = error_with_cause(StandardError, "error message", Errno::ECONNRESET) - fake_body = double - allow(fake_body).to receive(:each) - expect(fake_body).to receive(:to_ary).once.and_raise(error) - expect(fake_body).to_not receive(:close) # Per spec we expect the body has closed itself + describe "does not report ECONNRESET error when it's the error cause (to_ary)" do + def perform + error = error_with_cause(StandardError, "error message", Errno::ECONNRESET) + fake_body = double + allow(fake_body).to receive(:each) + expect(fake_body).to receive(:to_ary).once.and_raise(error) + expect(fake_body).to_not receive(:close) # Per spec we expect the body has closed itself + + wrapped = described_class.wrap(fake_body, transaction) + expect do + wrapped.to_ary + end.to raise_error(StandardError, "error message") + end - wrapped = described_class.wrap(fake_body, transaction) - expect do - wrapped.to_ary - end.to raise_error(StandardError, "error message") + it "in agent mode", :agent_mode do + start_agent + + perform + expect(transaction).to_not have_error + end - expect(transaction).to_not have_error + it "in collector mode", :collector_mode do + start_collector_agent + + perform + expect_collector_no_error + end end end describe "with a body supporting both to_path and each" do let(:fake_body) { double(:each => nil, :to_path => nil) } - it "wraps with appropriate class" do + it_in_both_modes "wraps with appropriate class" do wrapped = described_class.wrap(fake_body, transaction) expect(wrapped).to respond_to(:each) expect(wrapped).to_not respond_to(:to_ary) @@ -385,127 +810,281 @@ expect(wrapped).to respond_to(:close) end - it "reads out the body in full using each()" do - expect(fake_body).to receive(:each).once.and_yield("a").and_yield("b").and_yield("c") + describe "reads out the body in full using each()" do + def perform + expect(fake_body).to receive(:each).once.and_yield("a").and_yield("b").and_yield("c") - wrapped = described_class.wrap(fake_body, transaction) - expect { |b| wrapped.each(&b) }.to yield_successive_args("a", "b", "c") + wrapped = described_class.wrap(fake_body, transaction) + expect { |b| wrapped.each(&b) }.to yield_successive_args("a", "b", "c") + end - expect(transaction).to include_event( - "name" => "process_response_body.rack", - "title" => "Process Rack response body (#each)" - ) + it "in agent mode", :agent_mode do + start_agent + + perform + + expect(transaction).to include_event( + "name" => "process_response_body.rack", + "title" => "Process Rack response body (#each)" + ) + end + + it "in collector mode", :collector_mode do + start_collector_agent + + perform + + expect_collector_event( + "process_response_body.rack", + "Process Rack response body (#each)" + ) + end end - it "sets the exception raised inside each() into the Appsignal transaction" do - expect(fake_body).to receive(:each).once.and_raise(ExampleException, "error message") + describe "sets the exception raised inside each() into the Appsignal transaction" do + def perform + expect(fake_body).to receive(:each).once.and_raise(ExampleException, "error message") - wrapped = described_class.wrap(fake_body, transaction) - expect do - expect { |b| wrapped.each(&b) }.to yield_control - end.to raise_error(ExampleException, "error message") + wrapped = described_class.wrap(fake_body, transaction) + expect do + expect { |b| wrapped.each(&b) }.to yield_control + end.to raise_error(ExampleException, "error message") + end + + it "in agent mode", :agent_mode do + start_agent + + perform + + expect(transaction).to have_error("ExampleException", "error message") + end - expect(transaction).to have_error("ExampleException", "error message") + it "in collector mode", :collector_mode do + start_collector_agent + + perform + + expect_collector_error("ExampleException", "error message") + end end - it "sets the exception raised inside to_path() into the Appsignal transaction" do - allow(fake_body).to receive(:to_path).once.and_raise(ExampleException, "error message") + describe "sets the exception raised inside to_path() into the Appsignal transaction" do + def perform + allow(fake_body).to receive(:to_path).once.and_raise(ExampleException, "error message") - wrapped = described_class.wrap(fake_body, transaction) - expect do - wrapped.to_path - end.to raise_error(ExampleException, "error message") + wrapped = described_class.wrap(fake_body, transaction) + expect do + wrapped.to_path + end.to raise_error(ExampleException, "error message") + end + + it "in agent mode", :agent_mode do + start_agent + + perform + + expect(transaction).to have_error("ExampleException", "error message") + end + + it "in collector mode", :collector_mode do + start_collector_agent - expect(transaction).to have_error("ExampleException", "error message") + perform + + expect_collector_error("ExampleException", "error message") + end end - it "doesn't report EPIPE error" do - expect(fake_body).to receive(:to_path).once.and_raise(Errno::EPIPE) + describe "doesn't report EPIPE error" do + def perform + expect(fake_body).to receive(:to_path).once.and_raise(Errno::EPIPE) - wrapped = described_class.wrap(fake_body, transaction) - expect do - wrapped.to_path - end.to raise_error(Errno::EPIPE) + wrapped = described_class.wrap(fake_body, transaction) + expect do + wrapped.to_path + end.to raise_error(Errno::EPIPE) + end + + it "in agent mode", :agent_mode do + start_agent - expect(transaction).to_not have_error + perform + expect(transaction).to_not have_error + end + + it "in collector mode", :collector_mode do + start_collector_agent + + perform + expect_collector_no_error + end end - it "doesn't report ECONNRESET error" do - expect(fake_body).to receive(:to_path).once.and_raise(Errno::ECONNRESET) + describe "doesn't report ECONNRESET error" do + def perform + expect(fake_body).to receive(:to_path).once.and_raise(Errno::ECONNRESET) - wrapped = described_class.wrap(fake_body, transaction) - expect do - wrapped.to_path - end.to raise_error(Errno::ECONNRESET) + wrapped = described_class.wrap(fake_body, transaction) + expect do + wrapped.to_path + end.to raise_error(Errno::ECONNRESET) + end - expect(transaction).to_not have_error + it "in agent mode", :agent_mode do + start_agent + + perform + expect(transaction).to_not have_error + end + + it "in collector mode", :collector_mode do + start_collector_agent + + perform + expect_collector_no_error + end end - it "does not report EPIPE error from #each when it's the error cause" do - error = error_with_cause(StandardError, "error message", Errno::EPIPE) - expect(fake_body).to receive(:each).once.and_raise(error) + describe "does not report EPIPE error from #each when it's the error cause" do + def perform + error = error_with_cause(StandardError, "error message", Errno::EPIPE) + expect(fake_body).to receive(:each).once.and_raise(error) - wrapped = described_class.wrap(fake_body, transaction) - expect do - expect { |b| wrapped.each(&b) }.to yield_control - end.to raise_error(StandardError, "error message") + wrapped = described_class.wrap(fake_body, transaction) + expect do + expect { |b| wrapped.each(&b) }.to yield_control + end.to raise_error(StandardError, "error message") + end + + it "in agent mode", :agent_mode do + start_agent + + perform + expect(transaction).to_not have_error + end - expect(transaction).to_not have_error + it "in collector mode", :collector_mode do + start_collector_agent + + perform + expect_collector_no_error + end end - it "does not report ECONNRESET error from #each when it's the error cause" do - error = error_with_cause(StandardError, "error message", Errno::ECONNRESET) - expect(fake_body).to receive(:each).once.and_raise(error) + describe "does not report ECONNRESET error from #each when it's the error cause" do + def perform + error = error_with_cause(StandardError, "error message", Errno::ECONNRESET) + expect(fake_body).to receive(:each).once.and_raise(error) - wrapped = described_class.wrap(fake_body, transaction) - expect do - expect { |b| wrapped.each(&b) }.to yield_control - end.to raise_error(StandardError, "error message") + wrapped = described_class.wrap(fake_body, transaction) + expect do + expect { |b| wrapped.each(&b) }.to yield_control + end.to raise_error(StandardError, "error message") + end + + it "in agent mode", :agent_mode do + start_agent - expect(transaction).to_not have_error + perform + expect(transaction).to_not have_error + end + + it "in collector mode", :collector_mode do + start_collector_agent + + perform + expect_collector_no_error + end end - it "does not report EPIPE error from #to_path when it's the error cause" do - error = error_with_cause(StandardError, "error message", Errno::EPIPE) - allow(fake_body).to receive(:to_path).once.and_raise(error) + describe "does not report EPIPE error from #to_path when it's the error cause" do + def perform + error = error_with_cause(StandardError, "error message", Errno::EPIPE) + allow(fake_body).to receive(:to_path).once.and_raise(error) - wrapped = described_class.wrap(fake_body, transaction) - expect do - wrapped.to_path - end.to raise_error(StandardError, "error message") + wrapped = described_class.wrap(fake_body, transaction) + expect do + wrapped.to_path + end.to raise_error(StandardError, "error message") + end - expect(transaction).to_not have_error + it "in agent mode", :agent_mode do + start_agent + + perform + expect(transaction).to_not have_error + end + + it "in collector mode", :collector_mode do + start_collector_agent + + perform + expect_collector_no_error + end end - it "does not report ECONNRESET error from #to_path when it's the error cause" do - error = error_with_cause(StandardError, "error message", Errno::ECONNRESET) - allow(fake_body).to receive(:to_path).once.and_raise(error) + describe "does not report ECONNRESET error from #to_path when it's the error cause" do + def perform + error = error_with_cause(StandardError, "error message", Errno::ECONNRESET) + allow(fake_body).to receive(:to_path).once.and_raise(error) - wrapped = described_class.wrap(fake_body, transaction) - expect do - wrapped.to_path - end.to raise_error(StandardError, "error message") + wrapped = described_class.wrap(fake_body, transaction) + expect do + wrapped.to_path + end.to raise_error(StandardError, "error message") + end + + it "in agent mode", :agent_mode do + start_agent + + perform + expect(transaction).to_not have_error + end - expect(transaction).to_not have_error + it "in collector mode", :collector_mode do + start_collector_agent + + perform + expect_collector_no_error + end end - it "exposes to_path to the sender" do - allow(fake_body).to receive(:to_path).and_return("/tmp/file.bin") + describe "exposes to_path to the sender" do + def perform + allow(fake_body).to receive(:to_path).and_return("/tmp/file.bin") - wrapped = described_class.wrap(fake_body, transaction) - expect(wrapped.to_path).to eq("/tmp/file.bin") + wrapped = described_class.wrap(fake_body, transaction) + expect(wrapped.to_path).to eq("/tmp/file.bin") + end - expect(transaction).to include_event( - "name" => "process_response_body.rack", - "title" => "Process Rack response body (#to_path)" - ) + it "in agent mode", :agent_mode do + start_agent + + perform + + expect(transaction).to include_event( + "name" => "process_response_body.rack", + "title" => "Process Rack response body (#to_path)" + ) + end + + it "in collector mode", :collector_mode do + start_collector_agent + + perform + + expect_collector_event( + "process_response_body.rack", + "Process Rack response body (#to_path)" + ) + end end end describe "with a body only supporting call()" do let(:fake_body) { double(:call => nil) } - it "wraps with appropriate class" do + it_in_both_modes "wraps with appropriate class" do wrapped = described_class.wrap(fake_body, transaction) expect(wrapped).to_not respond_to(:each) expect(wrapped).to_not respond_to(:to_ary) @@ -514,92 +1093,183 @@ expect(wrapped).to respond_to(:close) end - it "passes the stream into the call() of the body" do - fake_rack_stream = double("stream") - expect(fake_body).to receive(:call).with(fake_rack_stream) + describe "passes the stream into the call() of the body" do + def perform + fake_rack_stream = double("stream") + expect(fake_body).to receive(:call).with(fake_rack_stream) - wrapped = described_class.wrap(fake_body, transaction) - wrapped.call(fake_rack_stream) + wrapped = described_class.wrap(fake_body, transaction) + wrapped.call(fake_rack_stream) + end - expect(transaction).to include_event( - "name" => "process_response_body.rack", - "title" => "Process Rack response body (#call)" - ) + it "in agent mode", :agent_mode do + start_agent + + perform + + expect(transaction).to include_event( + "name" => "process_response_body.rack", + "title" => "Process Rack response body (#call)" + ) + end + + it "in collector mode", :collector_mode do + start_collector_agent + + perform + + expect_collector_event( + "process_response_body.rack", + "Process Rack response body (#call)" + ) + end end - it "sets the exception raised inside call() into the Appsignal transaction" do - fake_rack_stream = double - allow(fake_body).to receive(:call) - .with(fake_rack_stream) - .and_raise(ExampleException, "error message") + describe "sets the exception raised inside call() into the Appsignal transaction" do + def perform + fake_rack_stream = double + allow(fake_body).to receive(:call) + .with(fake_rack_stream) + .and_raise(ExampleException, "error message") - wrapped = described_class.wrap(fake_body, transaction) + wrapped = described_class.wrap(fake_body, transaction) - expect do - wrapped.call(fake_rack_stream) - end.to raise_error(ExampleException, "error message") + expect do + wrapped.call(fake_rack_stream) + end.to raise_error(ExampleException, "error message") + end + + it "in agent mode", :agent_mode do + start_agent + + perform + + expect(transaction).to have_error("ExampleException", "error message") + end + + it "in collector mode", :collector_mode do + start_collector_agent + + perform - expect(transaction).to have_error("ExampleException", "error message") + expect_collector_error("ExampleException", "error message") + end end - it "doesn't report EPIPE error" do - fake_rack_stream = double - expect(fake_body).to receive(:call) - .with(fake_rack_stream) - .and_raise(Errno::EPIPE) + describe "doesn't report EPIPE error" do + def perform + fake_rack_stream = double + expect(fake_body).to receive(:call) + .with(fake_rack_stream) + .and_raise(Errno::EPIPE) + + wrapped = described_class.wrap(fake_body, transaction) + expect do + wrapped.call(fake_rack_stream) + end.to raise_error(Errno::EPIPE) + end - wrapped = described_class.wrap(fake_body, transaction) - expect do - wrapped.call(fake_rack_stream) - end.to raise_error(Errno::EPIPE) + it "in agent mode", :agent_mode do + start_agent + + perform + expect(transaction).to_not have_error + end + + it "in collector mode", :collector_mode do + start_collector_agent - expect(transaction).to_not have_error + perform + expect_collector_no_error + end end - it "doesn't report ECONNRESET error" do - fake_rack_stream = double - expect(fake_body).to receive(:call) - .with(fake_rack_stream) - .and_raise(Errno::ECONNRESET) + describe "doesn't report ECONNRESET error" do + def perform + fake_rack_stream = double + expect(fake_body).to receive(:call) + .with(fake_rack_stream) + .and_raise(Errno::ECONNRESET) + + wrapped = described_class.wrap(fake_body, transaction) + expect do + wrapped.call(fake_rack_stream) + end.to raise_error(Errno::ECONNRESET) + end - wrapped = described_class.wrap(fake_body, transaction) - expect do - wrapped.call(fake_rack_stream) - end.to raise_error(Errno::ECONNRESET) + it "in agent mode", :agent_mode do + start_agent + + perform + expect(transaction).to_not have_error + end - expect(transaction).to_not have_error + it "in collector mode", :collector_mode do + start_collector_agent + + perform + expect_collector_no_error + end end - it "does not report EPIPE error from #call when it's the error cause" do - error = error_with_cause(StandardError, "error message", Errno::EPIPE) - fake_rack_stream = double - allow(fake_body).to receive(:call) - .with(fake_rack_stream) - .and_raise(error) + describe "does not report EPIPE error from #call when it's the error cause" do + def perform + error = error_with_cause(StandardError, "error message", Errno::EPIPE) + fake_rack_stream = double + allow(fake_body).to receive(:call) + .with(fake_rack_stream) + .and_raise(error) - wrapped = described_class.wrap(fake_body, transaction) + wrapped = described_class.wrap(fake_body, transaction) - expect do - wrapped.call(fake_rack_stream) - end.to raise_error(StandardError, "error message") + expect do + wrapped.call(fake_rack_stream) + end.to raise_error(StandardError, "error message") + end + + it "in agent mode", :agent_mode do + start_agent + + perform + expect(transaction).to_not have_error + end - expect(transaction).to_not have_error + it "in collector mode", :collector_mode do + start_collector_agent + + perform + expect_collector_no_error + end end - it "does not report ECONNRESET error from #call when it's the error cause" do - error = error_with_cause(StandardError, "error message", Errno::ECONNRESET) - fake_rack_stream = double - allow(fake_body).to receive(:call) - .with(fake_rack_stream) - .and_raise(error) + describe "does not report ECONNRESET error from #call when it's the error cause" do + def perform + error = error_with_cause(StandardError, "error message", Errno::ECONNRESET) + fake_rack_stream = double + allow(fake_body).to receive(:call) + .with(fake_rack_stream) + .and_raise(error) - wrapped = described_class.wrap(fake_body, transaction) + wrapped = described_class.wrap(fake_body, transaction) - expect do - wrapped.call(fake_rack_stream) - end.to raise_error(StandardError, "error message") + expect do + wrapped.call(fake_rack_stream) + end.to raise_error(StandardError, "error message") + end - expect(transaction).to_not have_error + it "in agent mode", :agent_mode do + start_agent + + perform + expect(transaction).to_not have_error + end + + it "in collector mode", :collector_mode do + start_collector_agent + + perform + expect_collector_no_error + end end end diff --git a/spec/lib/appsignal/rack/event_handler_spec.rb b/spec/lib/appsignal/rack/event_handler_spec.rb index 65308e8eb..c494fa555 100644 --- a/spec/lib/appsignal/rack/event_handler_spec.rb +++ b/spec/lib/appsignal/rack/event_handler_spec.rb @@ -5,6 +5,7 @@ "HTTP_X_REQUEST_START" => "t=#{queue_start_time.to_i}", # in milliseconds "REQUEST_METHOD" => "POST", "PATH_INFO" => "/path", + "HTTP_ACCEPT" => "application/json", "QUERY_STRING" => "query_param1=value1&query_param2=value2", "rack.session" => { "session1" => "value1", "session2" => "value2" }, "rack.input" => StringIO.new("post_param1=value1&post_param2=value2") @@ -24,11 +25,14 @@ end let(:rack_app) { lambda { |_env| [200, {}, ["Hello world!"]] } } let(:appsignal_env) { :default } - before do - start_agent(:env => appsignal_env) + # Pass the example's AppSignal env through to the mode contexts' `start_agent`. + let(:start_agent_args) { { :env => appsignal_env } } + + # `start_agent` resets the internal logger, so the test logger has to be + # installed from the example body, after the mode context starts the agent. + def use_test_logger Appsignal.internal_logger = test_logger(log_stream) end - around { |example| keep_transactions { example.run } } def on_start event_handler_instance.on_start(request, response) @@ -43,7 +47,8 @@ def on_error(error) # `Rack::Events` middleware. let(:event_handler_instance) { described_class.new } - it "emits a warning about using it with Rack::Events" do + it_in_both_modes "emits a warning about using it with Rack::Events" do + use_test_logger events = ::Rack::Events.new(rack_app, [event_handler_instance]) logs = capture_logs { events.call({}) } @@ -55,7 +60,8 @@ def on_error(error) end context "When used via ::Appsignal::Rack::EventMiddleware" do - it "does not emit a warning about using it with Rack::Events" do + it_in_both_modes "does not emit a warning about using it with Rack::Events" do + use_test_logger expect(described_class).to receive(:new).and_call_original event_middleware = Appsignal::Rack::EventMiddleware.new(rack_app) @@ -69,61 +75,171 @@ def on_error(error) end describe "#on_start" do - it "creates a new transaction" do - expect { on_start }.to change { created_transactions.length }.by(1) + describe "creates a new transaction" do + def perform + on_start + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + use_test_logger + expect { perform }.to change { created_transactions.length }.by(1) + + transaction = last_transaction + expect(transaction).to have_id + expect(transaction).to have_namespace(Appsignal::Transaction::HTTP_REQUEST) + + expect(Appsignal::Transaction.current).to eq(transaction) + end + + it "in collector mode", :collector_mode do + start_collector_agent + use_test_logger + expect { perform }.to change { created_transactions.length }.by(1) + + transaction = last_transaction + expect(Appsignal::Transaction.current).to eq(transaction) + # Finish the still-open root span so we can read the namespace it was + # opened with. + Appsignal::Transaction.complete_current! + expect(root_span.attributes["appsignal.namespace"]) + .to eq("web") + expect(root_span.kind).to eq(:server) + expect(scope_of(root_span)).to eq(["appsignal-ruby/rack", Appsignal::VERSION]) + end + end + + describe "incoming trace context" do + let(:trace_id_hex) { "0af7651916cd43dd8448eb211c80319c" } + let(:span_id_hex) { "b7ad6b7169203331" } + + it "continues the upstream trace when a traceparent is present", :collector_mode do + env["HTTP_TRACEPARENT"] = "00-#{trace_id_hex}-#{span_id_hex}-01" + start_collector_agent + on_start + Appsignal::Transaction.complete_current! + + expect(root_span.hex_trace_id).to eq(trace_id_hex) + expect(root_span.parent_span_id.unpack1("H*")).to eq(span_id_hex) + end + + it "starts a fresh root trace when no traceparent is present", :collector_mode do + start_collector_agent + on_start + Appsignal::Transaction.complete_current! + + expect(root_span.parent_span_id).to eq(::OpenTelemetry::Trace::INVALID_SPAN_ID) + end + end - transaction = last_transaction - expect(transaction).to have_id - expect(transaction).to have_namespace(Appsignal::Transaction::HTTP_REQUEST) + describe "marking the transaction as an incoming HTTP request" do + # The `process_request.rack` event opens together with the transaction and + # stays open until the response finishes. An attribute set anywhere later + # in the request would land on that event span instead of the transaction + # span, which is why these are set before the event starts. + it "sets the request attributes on the transaction span only", :collector_mode do + start_collector_agent + on_start + Appsignal::Transaction.complete_current! + + expect(root_span.attributes["http.request.method"]).to eq("POST") + expect(root_span.attributes["url.path"]).to eq("/path") + # Sent whole and unfiltered. The collector filters it with the + # `filter_request_query_parameters` option. + expect(root_span.attributes["url.query"]) + .to eq("query_param1=value1&query_param2=value2") + # This environment carries no scheme, so there is nothing to report and + # the attribute is left out rather than sent empty. + expect(root_span.attributes).to_not have_key("url.scheme") + expect(event_spans).to_not be_empty + event_spans.each do |span| + expect(span.attributes).to_not have_key("http.request.method") + expect(span.attributes).to_not have_key("url.path") + expect(span.attributes).to_not have_key("url.scheme") + expect(span.attributes).to_not have_key("url.query") + end + end + + it "reads the scheme off the request", :collector_mode do + env["rack.url_scheme"] = "https" + start_collector_agent + on_start + Appsignal::Transaction.complete_current! - expect(Appsignal::Transaction.current).to eq(transaction) + expect(root_span.attributes["url.scheme"]).to eq("https") + end end context "when not active" do let(:appsignal_env) { :inactive_env } - it "does not create a new transaction" do + it_in_both_modes "does not create a new transaction" do + use_test_logger expect { on_start }.to_not(change { created_transactions.length }) end end context "when the handler is nested in another EventHandler" do - it "does not create a new transaction in the nested EventHandler" do + it_in_both_modes "does not create a new transaction in the nested EventHandler" do + use_test_logger on_start expect { described_class.new.on_start(request, response) } .to_not(change { created_transactions.length }) end end - it "registers transaction on the request environment" do + it_in_both_modes "registers transaction on the request environment" do + use_test_logger on_start expect(request.env[Appsignal::Rack::APPSIGNAL_TRANSACTION]) .to eq(last_transaction) end - it "registers an rack.after_reply callback that completes the transaction" do - request.env[Appsignal::Rack::RACK_AFTER_REPLY] = [] - expect do - on_start - end.to change { request.env[Appsignal::Rack::RACK_AFTER_REPLY].length }.by(1) + describe "registers an rack.after_reply callback that completes the transaction" do + def perform + request.env[Appsignal::Rack::RACK_AFTER_REPLY] = [] + expect do + on_start + end.to change { request.env[Appsignal::Rack::RACK_AFTER_REPLY].length }.by(1) - expect(Appsignal::Transaction.current).to eq(last_transaction) + expect(Appsignal::Transaction.current).to eq(last_transaction) - callback = request.env[Appsignal::Rack::RACK_AFTER_REPLY].first - callback.call + callback = request.env[Appsignal::Rack::RACK_AFTER_REPLY].first + callback.call - expect(Appsignal::Transaction.current).to be_kind_of(Appsignal::Transaction::NilTransaction) + expect(Appsignal::Transaction.current).to be_kind_of(Appsignal::Transaction::NilTransaction) + end - expect(last_transaction.ext.queue_start).to eq(queue_start_time) - expect(last_transaction).to include_event( - "name" => "process_request.rack", - "title" => "callback: after_reply" - ) + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + use_test_logger + perform + + expect(last_transaction.backend.queue_start).to eq(queue_start_time) + expect(last_transaction).to include_event( + "name" => "process_request.rack", + "title" => "callback: after_reply" + ) + end + + it "in collector mode", :collector_mode do + start_collector_agent + use_test_logger + perform + + queue_event = Array(root_span.events).find { |e| e.name == "appsignal.queue_start" } + expect(queue_event.timestamp) + .to eq((Time.at(queue_start_time.to_i / 1000.0).to_r * 1_000_000_000).to_i) + event = event_span_for("process_request.rack") + expect(event).not_to be_nil + expect(event.parent_span_id).to eq(root_span.span_id) + expect(event.name).to eq("process_request.rack (callback: after_reply)") + end end context "with error inside rack.after_reply handler" do - before do + def trigger_after_reply_error on_start # A random spot we can access to raise an error for this test expect(request.env[Appsignal::Rack::APPSIGNAL_TRANSACTION]) @@ -133,11 +249,17 @@ def on_error(error) callback.call end - it "completes the transaction" do + it_in_both_modes "completes the transaction" do + use_test_logger + trigger_after_reply_error + expect(last_transaction).to be_completed end - it "logs an error" do + it_in_both_modes "logs an error" do + use_test_logger + trigger_after_reply_error + expect(logs).to contains_log( :error, "Error occurred in Appsignal::Rack::EventHandler's after_reply: " \ @@ -146,7 +268,8 @@ def on_error(error) end end - it "logs errors from rack.after_reply callbacks" do + it_in_both_modes "logs errors from rack.after_reply callbacks" do + use_test_logger on_start expect(request.env[Appsignal::Rack::APPSIGNAL_TRANSACTION]) @@ -161,7 +284,8 @@ def on_error(error) ) end - it "logs an error in case of an error" do + it_in_both_modes "logs an error in case of an error" do + use_test_logger expect(Appsignal::Transaction) .to receive(:create).and_raise(ExampleStandardError, "oh no") @@ -175,34 +299,99 @@ def on_error(error) end describe "#on_error" do - it "reports the error" do - on_start - on_error(ExampleStandardError.new("the error")) + describe "reports the error" do + def perform + on_start + on_error(ExampleStandardError.new("the error")) + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + use_test_logger + perform - expect(last_transaction).to have_error("ExampleStandardError", "the error") + expect(last_transaction).to have_error("ExampleStandardError", "the error") + end + + it "in collector mode", :collector_mode do + start_collector_agent + use_test_logger + perform + # The error is recorded on the still-open span; finish it so the + # exception event exports. + Appsignal::Transaction.complete_current! + + # The error is set while the `process_request.rack` event span is the + # current span (on_start opens it; it is not finished before on_error), + # so the exception event rides on that event span, not the root span. + error_span = span_exporter.finished_spans.find do |span| + Array(span.events).any? { |e| e.name == "exception" } + end + expect(error_span).not_to be_nil + event = error_span.events.find { |e| e.name == "exception" } + expect(event.attributes["exception.type"]).to eq("ExampleStandardError") + expect(event.attributes["exception.message"]).to eq("the error") + expect(event.attributes["exception.stacktrace"]).to be_a(String) + expect(event.attributes["appsignal.alert_this_error"]).to eq(true) + expect(error_span.status.code).to eq(::OpenTelemetry::Trace::Status::ERROR) + end end context "when not active" do let(:appsignal_env) { :inactive_env } - it "does not report the transaction" do - on_start - on_error(ExampleStandardError.new("the error")) + describe "does not report the transaction" do + def perform + on_start + on_error(ExampleStandardError.new("the error")) + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + use_test_logger + perform + + expect(last_transaction).to_not have_error + end + + it "in collector mode", :collector_mode do + start_collector_agent + use_test_logger + perform - expect(last_transaction).to_not have_error + expect(exception_events).to be_empty + end end end context "when the handler is nested in another EventHandler" do - it "does not report the error on the transaction" do - on_start - described_class.new.on_error(request, response, ExampleStandardError.new("the error")) + describe "does not report the error on the transaction" do + def perform + on_start + described_class.new.on_error(request, response, ExampleStandardError.new("the error")) + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + use_test_logger + perform - expect(last_transaction).to_not have_error + expect(last_transaction).to_not have_error + end + + it "in collector mode", :collector_mode do + start_collector_agent + use_test_logger + perform + Appsignal::Transaction.complete_current! + + expect(exception_events).to be_empty + end end end - it "logs an error in case of an internal error" do + it_in_both_modes "logs an error in case of an internal error" do + use_test_logger on_start expect(request.env[Appsignal::Rack::APPSIGNAL_TRANSACTION]) @@ -224,84 +413,76 @@ def on_finish(given_request = request, given_response = response) event_handler_instance.on_finish(given_request, given_response) end - it "doesn't do anything without a transaction" do - on_start - - request.env[Appsignal::Rack::APPSIGNAL_TRANSACTION] = nil - - on_finish - - expect(last_transaction).to_not have_action - expect(last_transaction).to_not include_events - expect(last_transaction).to include("sample_data" => {}) - expect(last_transaction).to_not be_completed - end - - context "when not active" do - let(:appsignal_env) { :inactive_env } - - it "doesn't do anything" do - request.env[Appsignal::Rack::APPSIGNAL_TRANSACTION] = http_request_transaction + describe "doesn't do anything without a transaction" do + def perform + on_start + request.env[Appsignal::Rack::APPSIGNAL_TRANSACTION] = nil on_finish + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + use_test_logger + perform expect(last_transaction).to_not have_action expect(last_transaction).to_not include_events expect(last_transaction).to include("sample_data" => {}) expect(last_transaction).to_not be_completed end - end - - it "sets params on the transaction" do - on_start - on_finish - - expect(last_transaction).to include_params( - "query_param1" => "value1", - "query_param2" => "value2", - "post_param1" => "value1", - "post_param2" => "value2" - ) - end - it "sets headers on the transaction" do - on_start - on_finish + it "in collector mode", :collector_mode do + start_collector_agent + use_test_logger + perform - expect(last_transaction).to include_environment( - "REQUEST_METHOD" => "POST", - "PATH_INFO" => "/path" - ) + expect(last_transaction).to_not be_completed + expect(root_span).to be_nil + expect(event_spans).to be_empty + end end - it "sets session data on the transaction" do - on_start - on_finish + context "when not active" do + let(:appsignal_env) { :inactive_env } - expect(last_transaction).to include_session_data( - "session1" => "value1", - "session2" => "value2" - ) - end + describe "doesn't do anything" do + def perform + request.env[Appsignal::Rack::APPSIGNAL_TRANSACTION] = http_request_transaction + on_finish + end - it "sets the queue start time on the transaction" do - on_start - on_finish + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + use_test_logger + perform - expect(last_transaction).to have_queue_start(queue_start_time) - end + expect(last_transaction).to_not have_action + expect(last_transaction).to_not include_events + expect(last_transaction).to include("sample_data" => {}) + expect(last_transaction).to_not be_completed + end - it "completes the transaction" do - on_start - on_finish + it "in collector mode", :collector_mode do + start_collector_agent + use_test_logger + perform - expect(last_transaction).to_not have_action - expect(last_transaction).to be_completed + expect(last_transaction).to_not be_completed + expect(event_spans).to be_empty + end + end end - context "without a response" do - it "sets params on the transaction" do + describe "sets params on the transaction" do + def perform on_start on_finish + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + use_test_logger + perform expect(last_transaction).to include_params( "query_param1" => "value1", @@ -311,9 +492,31 @@ def on_finish(given_request = request, given_response = response) ) end - it "sets headers on the transaction" do + it "in collector mode", :collector_mode do + start_collector_agent + use_test_logger + perform + + params = JSON.parse(root_span.attributes["appsignal.request.payload"]) + expect(params).to include( + "query_param1" => "value1", + "query_param2" => "value2", + "post_param1" => "value1", + "post_param2" => "value2" + ) + end + end + + describe "sets headers on the transaction" do + def perform on_start on_finish + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + use_test_logger + perform expect(last_transaction).to include_environment( "REQUEST_METHOD" => "POST", @@ -321,9 +524,28 @@ def on_finish(given_request = request, given_response = response) ) end - it "sets session data on the transaction" do + it "in collector mode", :collector_mode do + start_collector_agent + use_test_logger + perform + + # Only true HTTP headers map to `http.request.header.*`; the CGI vars + # REQUEST_METHOD/PATH_INFO are intentionally dropped. + expect(root_span.attributes["http.request.header.accept"]).to eq("application/json") + expect(root_span.attributes.keys).to_not include("http.request.header.request-method") + end + end + + describe "sets session data on the transaction" do + def perform on_start on_finish + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + use_test_logger + perform expect(last_transaction).to include_session_data( "session1" => "value1", @@ -331,71 +553,347 @@ def on_finish(given_request = request, given_response = response) ) end - it "sets the queue start time on the transaction" do + it "in collector mode", :collector_mode do + start_collector_agent + use_test_logger + perform + + session = JSON.parse(root_span.attributes["appsignal.request.session_data"]) + expect(session).to include( + "session1" => "value1", + "session2" => "value2" + ) + end + end + + describe "sets the queue start time on the transaction" do + def perform on_start on_finish + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + use_test_logger + perform expect(last_transaction).to have_queue_start(queue_start_time) end - it "completes the transaction" do + it "in collector mode", :collector_mode do + start_collector_agent + use_test_logger + perform + + # `set_queue_start` is an intentional no-op in collector mode. + expect(last_transaction).to_not have_queue_start + end + end + + describe "completes the transaction" do + def perform on_start - on_finish(request, nil) + on_finish + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + use_test_logger + perform - # The action is not set on purpose, as we can't set a normalized route - # It requires the app to set an action name expect(last_transaction).to_not have_action expect(last_transaction).to be_completed end - it "does not set a response_status tag" do - on_start - on_finish(request, nil) + it "in collector mode", :collector_mode do + start_collector_agent + use_test_logger + perform - expect(last_transaction).to_not include_tags("response_status" => anything) + expect(root_span.attributes).to_not have_key("appsignal.action_name") + expect(last_transaction).to be_completed end + end - it "does not report a response_status counter metric" do - expect(Appsignal).to_not receive(:increment_counter) - .with(:response_status, anything, anything) + context "without a response" do + describe "sets params on the transaction" do + def perform + on_start + on_finish + end - on_start - on_finish(request, nil) + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + use_test_logger + perform + + expect(last_transaction).to include_params( + "query_param1" => "value1", + "query_param2" => "value2", + "post_param1" => "value1", + "post_param2" => "value2" + ) + end + + it "in collector mode", :collector_mode do + start_collector_agent + use_test_logger + perform + + params = JSON.parse(root_span.attributes["appsignal.request.payload"]) + expect(params).to include( + "query_param1" => "value1", + "query_param2" => "value2", + "post_param1" => "value1", + "post_param2" => "value2" + ) + end end - context "with an error previously recorded by on_error" do - it "sets response status 500 as a tag" do + describe "sets headers on the transaction" do + def perform + on_start + on_finish + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + use_test_logger + perform + + expect(last_transaction).to include_environment( + "REQUEST_METHOD" => "POST", + "PATH_INFO" => "/path" + ) + end + + it "in collector mode", :collector_mode do + start_collector_agent + use_test_logger + perform + + expect(root_span.attributes["http.request.header.accept"]).to eq("application/json") + expect(root_span.attributes.keys).to_not include("http.request.header.request-method") + end + end + + describe "sets session data on the transaction" do + def perform + on_start + on_finish + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + use_test_logger + perform + + expect(last_transaction).to include_session_data( + "session1" => "value1", + "session2" => "value2" + ) + end + + it "in collector mode", :collector_mode do + start_collector_agent + use_test_logger + perform + + session = JSON.parse(root_span.attributes["appsignal.request.session_data"]) + expect(session).to include( + "session1" => "value1", + "session2" => "value2" + ) + end + end + + describe "sets the queue start time on the transaction" do + def perform + on_start + on_finish + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + use_test_logger + perform + + expect(last_transaction).to have_queue_start(queue_start_time) + end + + it "in collector mode", :collector_mode do + start_collector_agent + use_test_logger + perform + + expect(last_transaction).to_not have_queue_start + end + end + + describe "completes the transaction" do + # The action is not set on purpose, as we can't set a normalized route. + # It requires the app to set an action name. + def perform on_start - on_error(ExampleStandardError.new("the error")) on_finish(request, nil) + end - expect(last_transaction).to include_tags("response_status" => 500) + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + use_test_logger + perform + + expect(last_transaction).to_not have_action + expect(last_transaction).to be_completed end - it "increments the response status counter for response status 500" do - expect(Appsignal).to receive(:increment_counter) - .with(:response_status, 1, :status => 500, :namespace => :web) + it "in collector mode", :collector_mode do + start_collector_agent + use_test_logger + perform + expect(root_span.attributes).to_not have_key("appsignal.action_name") + expect(last_transaction).to be_completed + end + end + + describe "does not set a response_status tag" do + def perform + on_start + on_finish(request, nil) + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + use_test_logger + perform + + expect(last_transaction).to_not include_tags("response_status" => anything) + end + + it "in collector mode", :collector_mode do + start_collector_agent + use_test_logger + perform + + expect(root_span.attributes.keys).to_not include("appsignal.tag.response_status") + end + end + + describe "does not report a response_status counter metric" do + def perform + on_start + on_finish(request, nil) + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + use_test_logger + expect(Appsignal).to_not receive(:increment_counter) + .with(:response_status, anything, anything) + + perform + end + + it "in collector mode", :collector_mode do + start_collector_agent + use_test_logger + perform + + expect(metric_snapshot("response_status")).to be_nil + end + end + + context "with an error previously recorded by on_error" do + describe "sets response status 500 as a tag" do + def perform + on_start + on_error(ExampleStandardError.new("the error")) + on_finish(request, nil) + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + use_test_logger + perform + + expect(last_transaction).to include_tags("response_status" => 500) + end + + it "in collector mode", :collector_mode do + start_collector_agent + use_test_logger + perform + + expect(root_span.attributes["appsignal.tag.response_status"]).to eq(500) + end + end + + it "does not describe a response that was never sent", :collector_mode do + start_collector_agent + use_test_logger on_start on_error(ExampleStandardError.new("the error")) on_finish(request, nil) + + # The 500 stands in for a status the app never sent, so it is reported + # as a tag but not as the attribute the conventions define. + expect(root_span.attributes["appsignal.tag.response_status"]).to eq(500) + expect(root_span.attributes).to_not have_key("http.response.status_code") + end + + describe "increments the response status counter for response status 500" do + def perform + on_start + on_error(ExampleStandardError.new("the error")) + on_finish(request, nil) + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + use_test_logger + expect(Appsignal).to receive(:increment_counter) + .with(:response_status, 1, :status => 500, :namespace => :web) + + perform + end + + it "in collector mode", :collector_mode do + start_collector_agent + use_test_logger + perform + + snapshot = metric_snapshot("response_status") + expect(snapshot).not_to be_nil + expect(snapshot.data_points.first.value).to eq(1.0) + expect(snapshot.data_points.first.attributes).to eq( + "status" => 500, + "namespace" => "web" + ) + end end end end context "with error inside on_finish handler" do - before do + def trigger_on_finish_error on_start # A random spot we can access to raise an error for this test expect(Appsignal).to receive(:increment_counter).and_raise(ExampleStandardError, "oh no") on_finish end - it "completes the transaction" do + it_in_both_modes "completes the transaction" do + use_test_logger + trigger_on_finish_error + expect(last_transaction).to be_completed end - it "logs an error" do + it_in_both_modes "logs an error" do + use_test_logger + trigger_on_finish_error + expect(logs).to contains_log( :error, "Error occurred in Appsignal::Rack::EventHandler#on_finish: ExampleStandardError: oh no" @@ -404,73 +902,201 @@ def on_finish(given_request = request, given_response = response) end context "when the handler is nested in another EventHandler" do - it "does not complete the transaction" do - on_start - described_class.new.on_finish(request, response) + describe "does not complete the transaction" do + def perform + on_start + described_class.new.on_finish(request, response) + end - expect(last_transaction).to_not have_action - expect(last_transaction).to_not include_metadata - expect(last_transaction).to_not include_events - expect(last_transaction.to_h).to include("sample_data" => {}) - expect(last_transaction).to_not be_completed + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + use_test_logger + perform + + expect(last_transaction).to_not have_action + expect(last_transaction).to_not include_metadata + expect(last_transaction).to_not include_events + expect(last_transaction.to_h).to include("sample_data" => {}) + expect(last_transaction).to_not be_completed + end + + it "in collector mode", :collector_mode do + start_collector_agent + use_test_logger + perform + + expect(last_transaction).to_not be_completed + expect(root_span).to be_nil + expect(event_spans).to be_empty + end end end - it "doesn't set the action name if already set" do - on_start - last_transaction.set_action("My action") - on_finish + describe "doesn't set the action name if already set" do + def perform + on_start + last_transaction.set_action("My action") + on_finish + end - expect(last_transaction).to have_action("My action") - end + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + use_test_logger + perform - it "finishes the process_request.rack event" do - on_start - on_finish + expect(last_transaction).to have_action("My action") + end - expect(last_transaction).to include_event( - "name" => "process_request.rack", - "title" => "callback: on_finish" - ) + it "in collector mode", :collector_mode do + start_collector_agent + use_test_logger + perform + + expect(root_span.name).to eq("My action") + expect(root_span.attributes["appsignal.action_name"]).to eq("My action") + end end - context "with response" do - it "sets the response status as a tag" do + describe "finishes the process_request.rack event" do + def perform on_start on_finish + end - expect(last_transaction).to include_tags("response_status" => 200) + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + use_test_logger + perform + + expect(last_transaction).to include_event( + "name" => "process_request.rack", + "title" => "callback: on_finish" + ) end - it "increments the response status counter for response status" do - expect(Appsignal).to receive(:increment_counter) - .with(:response_status, 1, :status => 200, :namespace => :web) + it "in collector mode", :collector_mode do + start_collector_agent + use_test_logger + perform - on_start - on_finish + event = event_span_for("process_request.rack") + expect(event).not_to be_nil + expect(event.parent_span_id).to eq(root_span.span_id) + expect(event.name).to eq("process_request.rack (callback: on_finish)") end + end - context "with an error previously recorded by on_error" do - it "sets response status from the response as a tag" do + context "with response" do + describe "describing the response" do + # The `process_request.rack` event is finished at the start of + # `on_finish`, which leaves the transaction's own span as the one + # attributes go on. + it "sets the response status on the transaction span only", :collector_mode do + start_collector_agent + use_test_logger on_start - on_error(ExampleStandardError.new("the error")) on_finish - expect(last_transaction).to include_tags("response_status" => 200) + expect(root_span.attributes["http.response.status_code"]).to eq(200) + expect(event_spans).to_not be_empty + event_spans.each do |span| + expect(span.attributes).to_not have_key("http.response.status_code") + end end - it "increments the response status counter based on the response" do - expect(Appsignal).to receive(:increment_counter) - .with(:response_status, 1, :status => 200, :namespace => :web) + it "reads the status off the response", :collector_mode do + start_collector_agent + use_test_logger + on_start + on_finish(request, Rack::Events::BufferedResponse.new(404, {}, ["body"])) + + expect(root_span.attributes["http.response.status_code"]).to eq(404) + end + end + describe "sets the response status as a tag" do + def perform on_start - on_error(ExampleStandardError.new("the error")) on_finish end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + use_test_logger + perform + + expect(last_transaction).to include_tags("response_status" => 200) + end + + it "in collector mode", :collector_mode do + start_collector_agent + use_test_logger + perform + + expect(root_span.attributes["appsignal.tag.response_status"]).to eq(200) + end + end + + context "with an error previously recorded by on_error" do + describe "sets response status from the response as a tag" do + def perform + on_start + on_error(ExampleStandardError.new("the error")) + on_finish + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + use_test_logger + perform + + expect(last_transaction).to include_tags("response_status" => 200) + end + + it "in collector mode", :collector_mode do + start_collector_agent + use_test_logger + perform + + expect(root_span.attributes["appsignal.tag.response_status"]).to eq(200) + end + end + + describe "increments the response status counter based on the response" do + def perform + on_start + on_error(ExampleStandardError.new("the error")) + on_finish + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + use_test_logger + expect(Appsignal).to receive(:increment_counter) + .with(:response_status, 1, :status => 200, :namespace => :web) + + perform + end + + it "in collector mode", :collector_mode do + start_collector_agent + use_test_logger + perform + + snapshot = metric_snapshot("response_status") + expect(snapshot).not_to be_nil + expect(snapshot.data_points.first.value).to eq(1.0) + expect(snapshot.data_points.first.attributes).to eq( + "status" => 200, + "namespace" => "web" + ) + end + end end end - it "logs an error in case of an error" do + it_in_both_modes "logs an error in case of an error" do + use_test_logger # A random spot we can access to raise an error for this test expect(Appsignal).to receive(:increment_counter).and_raise(ExampleStandardError, "oh no") @@ -484,3 +1110,88 @@ def on_finish(given_request = request, given_response = response) end end end + +# Separate top-level describe so it doesn't inherit the parameterized +# `before { start_agent(:env => appsignal_env) }` above (which would clobber +# collector mode); `start_agent` comes from the mode contexts. The agent has no +# in-memory metric readout, so agent mode keeps the `increment_counter` mock +# while collector mode asserts the counter reaches the OpenTelemetry backend. +describe Appsignal::Rack::EventHandler, "response status counter" do + let(:env) do + { + "REQUEST_METHOD" => "GET", + "PATH_INFO" => "/path", + "rack.input" => StringIO.new("") + } + end + let(:request) { Rack::Request.new(env) } + let(:response) { Rack::Events::BufferedResponse.new(200, {}, ["body"]) } + let(:event_handler_instance) do + described_class.new.tap do |handler| + handler.using_appsignal_event_middleware = true + end + end + + describe "for a successful request" do + def perform + event_handler_instance.on_start(request, response) + event_handler_instance.on_finish(request, response) + end + + it "in agent mode", :agent_mode do + start_agent + + expect(Appsignal).to receive(:increment_counter) + .with(:response_status, 1, :status => 200, :namespace => :web) + + perform + end + + it "in collector mode", :collector_mode do + start_collector_agent + + perform + + snapshot = metric_snapshot("response_status") + expect(snapshot).not_to be_nil + expect(snapshot.data_points.first.value).to eq(1.0) + expect(snapshot.data_points.first.attributes).to eq( + "status" => 200, + "namespace" => "web" + ) + end + end + + describe "for a request that errors" do + # No response, and an error recorded by `on_error`, so the status comes + # from the error (500) rather than the response. + def perform + event_handler_instance.on_start(request, response) + event_handler_instance.on_error(request, response, ExampleStandardError.new("the error")) + event_handler_instance.on_finish(request, nil) + end + + it "in agent mode", :agent_mode do + start_agent + + expect(Appsignal).to receive(:increment_counter) + .with(:response_status, 1, :status => 500, :namespace => :web) + + perform + end + + it "in collector mode", :collector_mode do + start_collector_agent + + perform + + snapshot = metric_snapshot("response_status") + expect(snapshot).not_to be_nil + expect(snapshot.data_points.first.value).to eq(1.0) + expect(snapshot.data_points.first.attributes).to eq( + "status" => 500, + "namespace" => "web" + ) + end + end +end diff --git a/spec/lib/appsignal/rack/grape_middleware_spec.rb b/spec/lib/appsignal/rack/grape_middleware_spec.rb index a43a36c62..ca4ccca42 100644 --- a/spec/lib/appsignal/rack/grape_middleware_spec.rb +++ b/spec/lib/appsignal/rack/grape_middleware_spec.rb @@ -14,14 +14,7 @@ let(:env) do Rack::MockRequest.env_for("/ping", :method => "POST") end - let(:transaction) { http_request_transaction } - before do - stub_const("GrapeExample::Api", app) - start_agent - end - around do |example| - keep_transactions { example.run } - end + before { stub_const("GrapeExample::Api", app) } def make_request(env) app.call(env) @@ -44,10 +37,30 @@ def make_request_with_exception(env, exception_class, exception_message) end end - it "sets the error" do - make_request_with_exception(env, ExampleException, "error message") + describe "sets the error" do + def perform + make_request_with_exception(env, ExampleException, "error message") + end + + it "in agent mode", :agent_mode do + start_agent + perform + + expect(last_transaction).to have_error("ExampleException", "error message") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform - expect(last_transaction).to have_error("ExampleException", "error message") + event = root_span.events.find { |e| e.name == "exception" } + expect(event).not_to be_nil + expect(event.attributes["exception.type"]).to eq("ExampleException") + expect(event.attributes["exception.message"]).to eq("error message") + expect(event.attributes["exception.stacktrace"]).to be_a(String) + expect(event.attributes["appsignal.alert_this_error"]).to eq(true) + expect(root_span.status.code).to eq(::OpenTelemetry::Trace::Status::ERROR) + end end context "with env['grape.skip_appsignal_error'] = true" do @@ -62,10 +75,24 @@ def make_request_with_exception(env, exception_class, exception_message) end end - it "does not add the error" do - make_request_with_exception(env, ExampleException, "error message") + describe "does not add the error" do + def perform + make_request_with_exception(env, ExampleException, "error message") + end + + it "in agent mode", :agent_mode do + start_agent + perform + + expect(last_transaction).to_not have_error + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform - expect(last_transaction).to_not have_error + expect(exception_events).to be_empty + end end end end @@ -83,11 +110,31 @@ def make_request_with_exception(env, exception_class, exception_message) Rack::MockRequest.env_for("/hello", :method => "GET") end - it "sets non-unique route path" do - make_request(env) + describe "sets non-unique route path" do + def perform + make_request(env) + end + + it "in agent mode", :agent_mode do + start_agent + perform + + expect(last_transaction).to have_action("GET::GrapeExample::Api#/hello") + expect(last_transaction).to include_metadata("path" => "/hello", "method" => "GET") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform - expect(last_transaction).to have_action("GET::GrapeExample::Api#/hello") - expect(last_transaction).to include_metadata("path" => "/hello", "method" => "GET") + expect(root_span.name).to eq("GET::GrapeExample::Api#/hello") + expect(root_span.kind).to eq(:server) + expect(scope_of(root_span)).to eq(["appsignal-ruby/grape", Appsignal::VERSION]) + expect(root_span.attributes["appsignal.action_name"]) + .to eq("GET::GrapeExample::Api#/hello") + expect(root_span.attributes["appsignal.tag.path"]).to eq("/hello") + expect(root_span.attributes["appsignal.tag.method"]).to eq("GET") + end end end @@ -109,15 +156,62 @@ def make_request_with_exception(env, exception_class, exception_message) Rack::MockRequest.env_for("/users/123", :method => "GET") end - it "sets non-unique route_param path" do - make_request(env) + describe "sets non-unique route_param path" do + def perform + make_request(env) + end + + it "in agent mode", :agent_mode do + start_agent + perform + + expect(last_transaction).to have_action("GET::GrapeExample::Api#/users/:id/") + expect(last_transaction).to include_metadata("path" => "/users/:id/", "method" => "GET") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform - expect(last_transaction).to have_action("GET::GrapeExample::Api#/users/:id/") - expect(last_transaction).to include_metadata("path" => "/users/:id/", "method" => "GET") + expect(root_span.name).to eq("GET::GrapeExample::Api#/users/:id/") + expect(root_span.attributes["appsignal.action_name"]) + .to eq("GET::GrapeExample::Api#/users/:id/") + expect(root_span.attributes["appsignal.tag.path"]).to eq("/users/:id/") + expect(root_span.attributes["appsignal.tag.method"]).to eq("GET") + end end end context "with namespaced path" do + shared_examples "sets the namespaced path" do |action| + describe "sets namespaced path" do + def perform + make_request(env) + end + + it "in agent mode", :agent_mode do + start_agent + perform + + expect(last_transaction).to have_action(action) + expect(last_transaction).to include_metadata( + "path" => "/v1/beta/ping", + "method" => "POST" + ) + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + + expect(root_span.name).to eq(action) + expect(root_span.attributes["appsignal.action_name"]).to eq(action) + expect(root_span.attributes["appsignal.tag.path"]).to eq("/v1/beta/ping") + expect(root_span.attributes["appsignal.tag.method"]).to eq("POST") + end + end + end + context "with symbols" do let(:app) do Class.new(::Grape::API) do @@ -136,13 +230,7 @@ def make_request_with_exception(env, exception_class, exception_message) Rack::MockRequest.env_for("/v1/beta/ping", :method => "POST") end - it "sets namespaced path" do - make_request(env) - - expect(last_transaction).to have_action("POST::GrapeExample::Api#/v1/beta/ping") - expect(last_transaction).to include_metadata("path" => "/v1/beta/ping", - "method" => "POST") - end + include_examples "sets the namespaced path", "POST::GrapeExample::Api#/v1/beta/ping" end context "with strings" do @@ -164,15 +252,7 @@ def make_request_with_exception(env, exception_class, exception_message) Rack::MockRequest.env_for("/v1/beta/ping", :method => "POST") end - it "sets namespaced path" do - make_request(env) - - expect(last_transaction).to have_action("POST::GrapeExample::Api#/v1/beta/ping") - expect(last_transaction).to include_metadata( - "path" => "/v1/beta/ping", - "method" => "POST" - ) - end + include_examples "sets the namespaced path", "POST::GrapeExample::Api#/v1/beta/ping" end context "with / prefix" do @@ -193,13 +273,7 @@ def make_request_with_exception(env, exception_class, exception_message) Rack::MockRequest.env_for("/v1/beta/ping", :method => "POST") end - it "sets namespaced path" do - make_request(env) - - expect(last_transaction).to have_action("POST::GrapeExample::Api#/v1/beta/ping") - expect(last_transaction).to include_metadata("path" => "/v1/beta/ping", - "method" => "POST") - end + include_examples "sets the namespaced path", "POST::GrapeExample::Api#/v1/beta/ping" end end end diff --git a/spec/lib/appsignal/rack/hanami_middleware_spec.rb b/spec/lib/appsignal/rack/hanami_middleware_spec.rb index eccd441fa..988a88dde 100644 --- a/spec/lib/appsignal/rack/hanami_middleware_spec.rb +++ b/spec/lib/appsignal/rack/hanami_middleware_spec.rb @@ -14,9 +14,6 @@ end let(:middleware) { Appsignal::Rack::HanamiMiddleware.new(app, {}) } - before { start_agent } - around { |example| keep_transactions { example.run } } - def make_request(env) if DependencyHelper.hanami2_2_present? instance = @@ -31,34 +28,97 @@ def self.name end context "without params" do - it "sets no request parameters on the transaction" do - make_request(env) + describe "sets no request parameters on the transaction" do + def perform + make_request(env) + end + + it "in agent mode", :agent_mode do + start_agent + perform + + expect(last_transaction).to_not include_params + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform - expect(last_transaction).to_not include_params + expect(root_span.attributes.keys).to_not include("appsignal.request.payload") + end end end context "with params" do let(:router_params) { { "param1" => "value1", "param2" => "value2" } } - it "sets request parameters on the transaction" do - make_request(env) + describe "sets request parameters on the transaction" do + def perform + make_request(env) + end + + it "in agent mode", :agent_mode do + start_agent + perform + + expect(last_transaction).to include_params("param1" => "value1", "param2" => "value2") + end - expect(last_transaction).to include_params("param1" => "value1", "param2" => "value2") + it "in collector mode", :collector_mode do + start_collector_agent + perform + + expect(root_span.kind).to eq(:server) + expect(scope_of(root_span)).to eq(["appsignal-ruby/hanami", Appsignal::VERSION]) + params = JSON.parse(root_span.attributes["appsignal.request.payload"]) + expect(params).to include("param1" => "value1", "param2" => "value2") + end end end - it "reports a process_action.hanami event" do - make_request(env) + describe "reports a process_action.hanami event" do + def perform + make_request(env) + end + + it "in agent mode", :agent_mode do + start_agent + perform + + expect(last_transaction).to include_event("name" => "process_action.hanami") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform - expect(last_transaction).to include_event("name" => "process_action.hanami") + span = event_spans.find { |s| s.name == "process_action.hanami" } + expect(span).not_to be_nil + expect(span.parent_span_id).to eq(root_span.span_id) + end end if DependencyHelper.hanami2_2_present? - it "sets action name on the transaction" do - make_request(env) + describe "sets action name on the transaction" do + def perform + make_request(env) + end + + it "in agent mode", :agent_mode do + start_agent + perform + + expect(last_transaction).to have_action("HanamiApp::Actions::Books::Index") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform - expect(last_transaction).to have_action("HanamiApp::Actions::Books::Index") + expect(root_span.name).to eq("HanamiApp::Actions::Books::Index") + expect(root_span.attributes["appsignal.action_name"]) + .to eq("HanamiApp::Actions::Books::Index") + end end end end diff --git a/spec/lib/appsignal/rack/instrumentation_middleware_spec.rb b/spec/lib/appsignal/rack/instrumentation_middleware_spec.rb index 6dffe3235..33c31e2ce 100644 --- a/spec/lib/appsignal/rack/instrumentation_middleware_spec.rb +++ b/spec/lib/appsignal/rack/instrumentation_middleware_spec.rb @@ -3,36 +3,165 @@ let(:env) { Rack::MockRequest.env_for("/some/path") } let(:middleware) { described_class.new(app, {}) } - before { start_agent } - around { |example| keep_transactions { example.run } } - def make_request(env) middleware.call(env) end context "without an exception" do - it "reports a process_request_middleware.rack event" do - make_request(env) + describe "reports a process_request_middleware.rack event" do + def perform + make_request(env) + end + + it "in agent mode", :agent_mode do + start_agent + perform + + expect(last_transaction).to include_event("name" => "process_request_middleware.rack") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + + expect(event_spans.map(&:name)).to include("process_request_middleware.rack") + expect(root_span.kind).to eq(:server) + span = event_spans.find { |s| s.name == "process_request_middleware.rack" } + expect(span).not_to be_nil + expect(span.parent_span_id).to eq(root_span.span_id) + expect(scope_of(root_span)).to eq(["appsignal-ruby/rack", Appsignal::VERSION]) + expect(scope_of(span)).to eq(["appsignal-ruby/rack", Appsignal::VERSION]) + end + end + + describe "marking the transaction as an incoming HTTP request" do + # These describe the request as a whole, so they belong on the + # transaction's span and on none of the events recorded within it. + it "sets the request attributes on the transaction span only", :collector_mode do + start_collector_agent + make_request(env) + + expect(root_span.attributes["http.request.method"]).to eq("GET") + expect(root_span.attributes["url.path"]).to eq("/some/path") + expect(root_span.attributes["url.scheme"]).to eq("http") + # This request has no query string, so there is nothing to report and + # the attribute is left out rather than sent empty. + expect(root_span.attributes).to_not have_key("url.query") + expect(event_spans).to_not be_empty + event_spans.each do |span| + expect(span.attributes).to_not have_key("http.request.method") + expect(span.attributes).to_not have_key("url.path") + expect(span.attributes).to_not have_key("url.scheme") + expect(span.attributes).to_not have_key("url.query") + end + end + + it "reads the method off the request", :collector_mode do + start_collector_agent + make_request(Rack::MockRequest.env_for("/some/path", :method => "POST")) + + expect(root_span.attributes["http.request.method"]).to eq("POST") + end + + it "reads the path, scheme and query string off the request", :collector_mode do + start_collector_agent + make_request(Rack::MockRequest.env_for("https://example.com/other/path?query=value")) + + # The path is the path on its own. The query string is a separate + # attribute, so it must not end up in the path. + expect(root_span.attributes["url.path"]).to eq("/other/path") + expect(root_span.attributes["url.scheme"]).to eq("https") + # Sent whole and unfiltered. The collector filters it with the + # `filter_request_query_parameters` option. + expect(root_span.attributes["url.query"]).to eq("query=value") + end + end - expect(last_transaction).to include_event("name" => "process_request_middleware.rack") + describe "describing the response" do + # The status is only known after the app has been called, by which time the + # instrumented event has closed. That is what makes the transaction's own + # span the one this lands on. + it "sets the response status on the transaction span only", :collector_mode do + start_collector_agent + make_request(env) + + expect(root_span.attributes["http.response.status_code"]).to eq(200) + expect(event_spans).to_not be_empty + event_spans.each do |span| + expect(span.attributes).to_not have_key("http.response.status_code") + end + end + + context "when the app responds with an error status" do + let(:app) { DummyApp.new { |_env| [404, {}, ["Not found"]] } } + + it "reads the status off the response", :collector_mode do + start_collector_agent + make_request(env) + + expect(root_span.attributes["http.response.status_code"]).to eq(404) + end + end + + context "when the app raises" do + let(:app) { DummyApp.new { |_env| raise ExampleException, "error" } } + + # The conventions ask for the status only when a response was sent, and + # a request that raised never sent one. + it "sets no response status", :collector_mode do + start_collector_agent + expect { make_request(env) }.to raise_error(ExampleException) + + expect(root_span.attributes).to_not have_key("http.response.status_code") + end + end end end context "with custom action name" do let(:app) { DummyApp.new { |_env| Appsignal.set_action("MyAction") } } - it "reports the custom action name" do - make_request(env) + describe "reports the custom action name" do + def perform + make_request(env) + end - expect(last_transaction).to have_action("MyAction") + it "in agent mode", :agent_mode do + start_agent + perform + + expect(last_transaction).to have_action("MyAction") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + + expect(root_span.name).to eq("MyAction") + expect(root_span.attributes["appsignal.action_name"]).to eq("MyAction") + end end end context "without action name metadata" do - it "reports no action name" do - make_request(env) + describe "reports no action name" do + def perform + make_request(env) + end + + it "in agent mode", :agent_mode do + start_agent + perform + + expect(last_transaction).to_not have_action + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform - expect(last_transaction).to_not have_action + expect(root_span.attributes).to_not have_key("appsignal.action_name") + end end end end diff --git a/spec/lib/appsignal/rack/rails_instrumentation_spec.rb b/spec/lib/appsignal/rack/rails_instrumentation_spec.rb index 5b28b5be0..e5853dbf4 100644 --- a/spec/lib/appsignal/rack/rails_instrumentation_spec.rb +++ b/spec/lib/appsignal/rack/rails_instrumentation_spec.rb @@ -26,9 +26,10 @@ class MockController; end ) end let(:middleware) { Appsignal::Rack::RailsInstrumentation.new(app, {}) } - around { |example| keep_transactions { example.run } } - before do - start_agent + + # The middleware wraps an existing (parent) transaction, so it must be built + # after the agent starts; register it from the example body, not a `before`. + def setup_transaction env[Appsignal::Rack::APPSIGNAL_TRANSACTION] = transaction end @@ -42,14 +43,49 @@ def make_request_with_error(error_class, error_message) end context "with a request that doesn't raise an error" do - before { make_request } + describe "calls the next middleware in the stack" do + def perform + setup_transaction + make_request + end + + it "in agent mode", :agent_mode do + start_agent + perform + + expect(app).to be_called + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + # The middleware leaves the parent open; finish it to export the span. + transaction.complete - it "calls the next middleware in the stack" do - expect(app).to be_called + expect(app).to be_called + end end - it "does not instrument an event" do - expect(last_transaction).to_not include_events + describe "does not instrument an event" do + def perform + setup_transaction + make_request + end + + it "in agent mode", :agent_mode do + start_agent + perform + + expect(last_transaction).to_not include_events + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + expect(event_spans).to be_empty + end end end @@ -57,69 +93,220 @@ def make_request_with_error(error_class, error_message) let(:app) do DummyApp.new { |_env| raise ExampleException, "error message" } end - before do - make_request_with_error(ExampleException, "error message") + + describe "calls the next middleware in the stack" do + def perform + setup_transaction + make_request_with_error(ExampleException, "error message") + end + + it "in agent mode", :agent_mode do + start_agent + perform + + expect(app).to be_called + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + expect(app).to be_called + end end - it "calls the next middleware in the stack" do - expect(app).to be_called + describe "reports the error on the transaction" do + def perform + setup_transaction + make_request_with_error(ExampleException, "error message") + end + + it "in agent mode", :agent_mode do + start_agent + perform + + expect(last_transaction).to have_error("ExampleException", "error message") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + event = root_span.events.find { |e| e.name == "exception" } + expect(event).not_to be_nil + expect(event.attributes["exception.type"]).to eq("ExampleException") + expect(event.attributes["exception.message"]).to eq("error message") + expect(event.attributes["exception.stacktrace"]).to be_a(String) + expect(event.attributes["appsignal.alert_this_error"]).to eq(true) + expect(root_span.status.code).to eq(::OpenTelemetry::Trace::Status::ERROR) + end + end + end + + describe "sets the controller action as the action name" do + def perform + setup_transaction + make_request + end + + it "in agent mode", :agent_mode do + start_agent + perform + + expect(last_transaction).to have_namespace(Appsignal::Transaction::HTTP_REQUEST) + expect(last_transaction).to have_action("MockController#index") end - it "reports the error on the transaction" do - expect(last_transaction).to have_error("ExampleException", "error message") + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + expect(root_span.kind).to eq(:server) + expect(root_span.attributes["appsignal.namespace"]) + .to eq("web") + expect(root_span.name).to eq("MockController#index") + expect(root_span.attributes["appsignal.action_name"]).to eq("MockController#index") end end - it "sets the controller action as the action name" do - make_request + describe "the instrumentation scope" do + # When no parent transaction is present, this middleware creates the + # transaction itself and tags it with the Rails scope. In a full Rails + # stack the Rack event handler usually creates it first, under the Rack + # scope, and this middleware wraps that instead. + it "records under the Rails scope when it creates the transaction", + :collector_mode do + start_collector_agent + make_request - expect(last_transaction).to have_namespace(Appsignal::Transaction::HTTP_REQUEST) - expect(last_transaction).to have_action("MockController#index") + expect(scope_of(root_span)).to eq(["appsignal-ruby/rails", Appsignal::VERSION]) + end end - it "sets request metadata on the transaction" do - make_request + describe "sets request metadata on the transaction" do + def perform + setup_transaction + make_request + end + + it "in agent mode", :agent_mode do + start_agent + perform - expect(last_transaction).to include_metadata( - "method" => "GET", - "path" => "/blog" - ) - expect(last_transaction).to include_tags("request_id" => "request_id123") + expect(last_transaction).to include_metadata( + "method" => "GET", + "path" => "/blog" + ) + expect(last_transaction).to include_tags("request_id" => "request_id123") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + # Metadata and tags are both emitted as `appsignal.tag.*` attributes. + expect(root_span.attributes["appsignal.tag.method"]).to eq("GET") + expect(root_span.attributes["appsignal.tag.path"]).to eq("/blog") + expect(root_span.attributes["appsignal.tag.request_id"]).to eq("request_id123") + end end - it "reports Rails filter parameters" do - make_request + describe "reports Rails filter parameters" do + def perform + setup_transaction + make_request + end - expect(last_transaction).to include_params( - "controller" => "blog_posts", - "action" => "show", - "id" => "1", - "my_custom_param" => "[FILTERED]", - "password" => "[FILTERED]" - ) + it "in agent mode", :agent_mode do + start_agent + perform + + expect(last_transaction).to include_params( + "controller" => "blog_posts", + "action" => "show", + "id" => "1", + "my_custom_param" => "[FILTERED]", + "password" => "[FILTERED]" + ) + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + params = JSON.parse(root_span.attributes["appsignal.request.payload"]) + expect(params).to include( + "controller" => "blog_posts", + "action" => "show", + "id" => "1", + "my_custom_param" => "[FILTERED]", + "password" => "[FILTERED]" + ) + end end context "with an invalid HTTP request method" do - it "does not store the invalid HTTP request method" do - env[:request_method] = "FOO" - env["REQUEST_METHOD"] = "FOO" - logs = capture_logs { make_request } - - expect(last_transaction).to_not include_metadata("method" => anything) - expect(logs).to contains_log( - :error, - "Exception while fetching the HTTP request method: " - ) + describe "does not store the invalid HTTP request method" do + def perform + setup_transaction + env[:request_method] = "FOO" + env["REQUEST_METHOD"] = "FOO" + capture_logs { make_request } + end + + it "in agent mode", :agent_mode do + start_agent + logs = perform + + expect(last_transaction).to_not include_metadata("method" => anything) + expect(logs).to contains_log( + :error, + "Exception while fetching the HTTP request method: " + ) + end + + it "in collector mode", :collector_mode do + start_collector_agent + logs = perform + transaction.complete + + expect(root_span.attributes.keys).to_not include("appsignal.tag.method") + expect(logs).to contains_log( + :error, + "Exception while fetching the HTTP request method: " + ) + end end end context "with a request path that's not a route" do - it "doesn't set an action name" do - env[:path] = "/unknown-route" - env["action_controller.instance"] = nil - make_request + describe "doesn't set an action name" do + def perform + setup_transaction + env[:path] = "/unknown-route" + env["action_controller.instance"] = nil + make_request + end + + it "in agent mode", :agent_mode do + start_agent + perform + + expect(last_transaction).to_not have_action + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete - expect(last_transaction).to_not have_action + expect(root_span.attributes).to_not have_key("appsignal.action_name") + end end end end diff --git a/spec/lib/appsignal/rack/sinatra_instrumentation_spec.rb b/spec/lib/appsignal/rack/sinatra_instrumentation_spec.rb index 165317ee5..35c5088cd 100644 --- a/spec/lib/appsignal/rack/sinatra_instrumentation_spec.rb +++ b/spec/lib/appsignal/rack/sinatra_instrumentation_spec.rb @@ -21,24 +21,17 @@ def make_request_with_error(error) end let(:middleware) { Appsignal::Rack::SinatraInstrumentation.new(app) } - before { start_agent } - around do |example| - keep_transactions { example.run } - end - describe "#call" do before { allow(middleware).to receive(:raw_payload).and_return({}) } - it "doesn't instrument requests" do + it_in_both_modes "doesn't instrument requests" do expect { make_request }.to_not(change { created_transactions.count }) end end describe ".settings" do - subject { middleware.settings } - - it "returns the app's settings" do - expect(subject).to eq(app.settings) + it_in_both_modes "returns the app's settings" do + expect(middleware.settings).to eq(app.settings) end end end @@ -55,14 +48,14 @@ def make_request_with_error(error) let(:options) { {} } let(:middleware) { Appsignal::Rack::SinatraBaseInstrumentation.new(app, options) } - before { start_agent(:env => appsignal_env) } - around { |example| keep_transactions { example.run } } + # Pass the example's Appsignal env through to the mode contexts' `start_agent`. + let(:start_agent_args) { { :env => appsignal_env } } describe "#initialize" do context "with no settings method in the Sinatra app" do let(:app) { double(:call => true) } - it "does not raise errors" do + it_in_both_modes "does not raise errors" do expect(middleware.raise_errors_on).to be(false) end end @@ -70,7 +63,7 @@ def make_request_with_error(error) context "with no raise_errors setting in the Sinatra app" do let(:app) { double(:call => true, :settings => double) } - it "does not raise errors" do + it_in_both_modes "does not raise errors" do expect(middleware.raise_errors_on).to be(false) end end @@ -78,7 +71,7 @@ def make_request_with_error(error) context "with raise_errors turned off in the Sinatra app" do let(:app) { double(:call => true, :settings => double(:raise_errors => false)) } - it "raises errors" do + it_in_both_modes "raises errors" do expect(middleware.raise_errors_on).to be(false) end end @@ -86,7 +79,7 @@ def make_request_with_error(error) context "with raise_errors turned on in the Sinatra app" do let(:app) { double(:call => true, :settings => double(:raise_errors => true)) } - it "raises errors" do + it_in_both_modes "raises errors" do expect(middleware.raise_errors_on).to be(true) end end @@ -98,11 +91,11 @@ def make_request_with_error(error) context "when appsignal is not active" do let(:appsignal_env) { :inactive_env } - it "does not instrument requests" do + it_in_both_modes "does not instrument requests" do expect { make_request }.to_not(change { created_transactions.count }) end - it "calls the next middleware in the stack" do + it_in_both_modes "calls the next middleware in the stack" do make_request expect(app).to have_received(:call).with(env) @@ -111,16 +104,50 @@ def make_request_with_error(error) context "when appsignal is active" do context "without an error" do - it "creates a transaction for the request" do - expect { make_request }.to(change { created_transactions.count }.by(1)) + describe "creates a transaction for the request" do + def perform + make_request + end - expect(last_transaction).to have_namespace(Appsignal::Transaction::HTTP_REQUEST) + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + expect { perform }.to(change { created_transactions.count }.by(1)) + + expect(last_transaction).to have_namespace(Appsignal::Transaction::HTTP_REQUEST) + end + + it "in collector mode", :collector_mode do + start_collector_agent + expect { perform }.to(change { created_transactions.count }.by(1)) + + expect(root_span.attributes["appsignal.namespace"]) + .to eq("web") + expect(root_span.kind).to eq(:server) + end end - it "reports a process_action.sinatra event" do - make_request + describe "reports a process_action.sinatra event" do + def perform + make_request + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + + expect(last_transaction).to include_event("name" => "process_action.sinatra") + end - expect(last_transaction).to include_event("name" => "process_action.sinatra") + it "in collector mode", :collector_mode do + start_collector_agent + perform + + span = event_spans.find { |s| s.name == "process_action.sinatra" } + expect(span).not_to be_nil + expect(span.parent_span_id).to eq(root_span.span_id) + expect(scope_of(root_span)).to eq(["appsignal-ruby/sinatra", Appsignal::VERSION]) + expect(scope_of(span)).to eq(["appsignal-ruby/sinatra", Appsignal::VERSION]) + end end end @@ -128,29 +155,78 @@ def make_request_with_error(error) let(:error) { ExampleException.new("error message") } before { env["sinatra.error"] = error } - it "creates a transaction for the request" do - expect { make_request }.to(change { created_transactions.count }.by(1)) + describe "creates a transaction for the request" do + def perform + make_request + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + expect { perform }.to(change { created_transactions.count }.by(1)) + + expect(last_transaction).to have_namespace(Appsignal::Transaction::HTTP_REQUEST) + end - expect(last_transaction).to have_namespace(Appsignal::Transaction::HTTP_REQUEST) + it "in collector mode", :collector_mode do + start_collector_agent + expect { perform }.to(change { created_transactions.count }.by(1)) + + expect(root_span.attributes["appsignal.namespace"]) + .to eq("web") + end end context "when raise_errors is off" do let(:settings) { double(:raise_errors => false) } - it "records the error" do - make_request + describe "records the error" do + def perform + make_request + end - expect(last_transaction).to have_error("ExampleException", "error message") + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + + expect(last_transaction).to have_error("ExampleException", "error message") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + + event = root_span.events.find { |e| e.name == "exception" } + expect(event).not_to be_nil + expect(event.attributes["exception.type"]).to eq("ExampleException") + expect(event.attributes["exception.message"]).to eq("error message") + expect(event.attributes["exception.stacktrace"]).to be_a(String) + expect(event.attributes["appsignal.alert_this_error"]).to eq(true) + expect(root_span.status.code).to eq(::OpenTelemetry::Trace::Status::ERROR) + end end end context "when raise_errors is on" do let(:settings) { double(:raise_errors => true) } - it "does not record the error" do - make_request + describe "does not record the error" do + def perform + make_request + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + + expect(last_transaction).to_not have_error + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform - expect(last_transaction).to_not have_error + expect(exception_events).to be_empty + end end end @@ -162,19 +238,48 @@ def make_request_with_error(error) ) end - it "does not record the error" do - make_request + describe "does not record the error" do + def perform + make_request + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform - expect(last_transaction).to_not have_error + expect(last_transaction).to_not have_error + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + + expect(exception_events).to be_empty + end end end end describe "action name" do - it "sets the action to the request method and path" do - make_request + describe "sets the action to the request method and path" do + def perform + make_request + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + + expect(last_transaction).to have_action("GET /path") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform - expect(last_transaction).to have_action("GET /path") + expect(root_span.name).to eq("GET /path") + expect(root_span.attributes["appsignal.action_name"]).to eq("GET /path") + end end context "without 'sinatra.route' env" do @@ -182,20 +287,49 @@ def make_request_with_error(error) Rack::MockRequest.env_for("/path", "REQUEST_METHOD" => "GET") end - it "doesn't set an action name" do - make_request + describe "doesn't set an action name" do + def perform + make_request + end - expect(last_transaction).to_not have_action + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + + expect(last_transaction).to_not have_action + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + + expect(root_span.attributes).to_not have_key("appsignal.action_name") + end end end context "with mounted modular application" do before { env["SCRIPT_NAME"] = "/api" } - it "sets the action name with an application prefix path" do - make_request + describe "sets the action name with an application prefix path" do + def perform + make_request + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform - expect(last_transaction).to have_action("GET /api/path") + expect(last_transaction).to have_action("GET /api/path") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + + expect(root_span.name).to eq("GET /api/path") + expect(root_span.attributes["appsignal.action_name"]).to eq("GET /api/path") + end end context "without 'sinatra.route' env" do @@ -203,10 +337,24 @@ def make_request_with_error(error) Rack::MockRequest.env_for("/path", "REQUEST_METHOD" => "GET") end - it "doesn't set an action name" do - make_request + describe "doesn't set an action name" do + def perform + make_request + end - expect(last_transaction).to_not have_action + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + + expect(last_transaction).to_not have_action + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + + expect(root_span.attributes).to_not have_key("appsignal.action_name") + end end end end diff --git a/spec/lib/appsignal/transaction/extension_backend_spec.rb b/spec/lib/appsignal/transaction/extension_backend_spec.rb new file mode 100644 index 000000000..d73da5635 --- /dev/null +++ b/spec/lib/appsignal/transaction/extension_backend_spec.rb @@ -0,0 +1,246 @@ +# frozen_string_literal: true + +describe Appsignal::Transaction::ExtensionBackend do + before { start_agent } + + let(:backend) { described_class.new("abc-123", Appsignal::Transaction::HTTP_REQUEST) } + + describe "#initialize" do + it "wraps a real extension transaction when the extension is loaded" do + handle = backend.instance_variable_get(:@handle) + expect(handle).to be_kind_of(Appsignal::Extension::Transaction) + end + + context "when an existing handle is passed in" do + it "wraps that handle directly without starting a new transaction" do + existing_handle = Appsignal::Extension.start_transaction("other-id", "background_job", 0) + + backend_with_handle = described_class.new( + "ignored-id", + "ignored-namespace", + :handle => existing_handle + ) + + expect(backend_with_handle.instance_variable_get(:@handle)).to be(existing_handle) + end + end + + context "when the extension cannot be loaded", :extension_installation_failure do + around { |example| Appsignal::Testing.without_testing { example.run } } + + it "falls back to a MockTransaction" do + backend = described_class.new("abc-123", Appsignal::Transaction::HTTP_REQUEST) + expect(backend.instance_variable_get(:@handle)) + .to be_kind_of(Appsignal::Extension::MockTransaction) + end + end + end + + describe "#duplicate" do + it "returns a new ExtensionBackend wrapping a duplicated extension transaction" do + duplicate = backend.duplicate("new-id") + + expect(duplicate).to be_kind_of(described_class) + expect(duplicate).not_to be(backend) + expect(duplicate.instance_variable_get(:@handle)) + .not_to be(backend.instance_variable_get(:@handle)) + end + end + + describe "#params_mapping" do + it "maps every params channel to the single agent params bucket" do + expect(backend.params_mapping).to eq( + :params => :params, + :request_payload => :params, + :function_parameters => :params, + :query_parameters => :params + ) + end + end + + describe "method delegation" do + let(:handle) { backend.instance_variable_get(:@handle) } + + it "forwards #start_event to the handle" do + expect(handle).to receive(:start_event).with(0) + backend.start_event + end + + it "forwards #finish_event to the handle" do + expect(handle).to receive(:finish_event).with("name", "title", "body", 1, 0) + backend.finish_event("name", "title", "body", 1) + end + + it "forwards #record_event to the handle" do + expect(handle).to receive(:record_event).with("name", "title", "body", 1, 1000, 0) + backend.record_event("name", "title", "body", 1, 1000) + end + + it "forwards #set_action to the handle" do + expect(handle).to receive(:set_action).with("MyAction") + backend.set_action("MyAction") + end + + it "forwards #set_namespace to the handle" do + expect(handle).to receive(:set_namespace).with("background_job") + backend.set_namespace("background_job") + end + + it "forwards #set_queue_start to the handle" do + expect(handle).to receive(:set_queue_start).with(123_456) + backend.set_queue_start(123_456) + end + + it "forwards #set_metadata to the handle" do + expect(handle).to receive(:set_metadata).with("key", "value") + backend.set_metadata("key", "value") + end + + # OpenTelemetry attributes have nowhere to go in the agent protocol, so the + # backend drops them rather than storing them somewhere else. + it "drops #set_attributes without touching the handle" do + expect(handle).to_not receive(:set_metadata) + expect(handle).to_not receive(:set_sample_data) + + expect { backend.set_attributes("db.system.name" => "redis") }.to_not raise_error + end + + it "serializes the sample data to Data and forwards #set_sample_data to the handle" do + raw = { "a" => 1 } + data = Appsignal::Utils::Data.generate(raw) + expect(Appsignal::Utils::Data).to receive(:generate).with(raw).and_return(data) + expect(handle).to receive(:set_sample_data).with("params", data) + backend.set_sample_data("params", raw) + end + + it "serializes the backtrace Array to Data and forwards #set_error to the handle" do + allow(backend).to receive(:set_sample_data) + data = Appsignal::Utils::Data.generate(["line 1"]) + expect(Appsignal::Utils::Data).to receive(:generate).with(["line 1"]).and_return(data) + expect(handle).to receive(:set_error).with("RuntimeError", "boom", data) + backend.set_error("RuntimeError", "boom", ["line 1"], [], false) + end + + it "forwards an empty Data array when the backtrace is nil" do + allow(backend).to receive(:set_sample_data) + data = Appsignal::Extension.data_array_new + expect(Appsignal::Extension).to receive(:data_array_new).and_return(data) + expect(handle).to receive(:set_error).with("RuntimeError", "boom", data) + backend.set_error("RuntimeError", "boom", nil, [], false) + end + + it "flushes the causes as error_causes sample data" do + allow(handle).to receive(:set_error) + causes = [{ + :name => "ArgumentError", + :message => "bad arg", + :backtrace => ["/app/lib/foo.rb:10:in `bar'"] + }] + expect(backend).to receive(:set_sample_data).with("error_causes", an_instance_of(Array)) + backend.set_error("RuntimeError", "boom", ["line 1"], causes, false) + end + + it "forwards #finish to the handle and returns its value" do + expect(handle).to receive(:finish).with(0).and_return(true) + expect(backend.finish).to eq(true) + end + + it "forwards #complete to the handle" do + expect(handle).to receive(:complete) + backend.complete + end + + it "drops the transaction on #discard without completing the handle" do + expect(handle).to_not receive(:complete) + backend.discard + end + + it "forwards #to_json to the handle" do + expect(handle).to receive(:to_json).and_return("{}") + expect(backend.to_json).to eq("{}") + end + + it "forwards #queue_start to the handle" do + backend.set_queue_start(99) + expect(backend.queue_start).to eq(99) + end + + it "forwards #_completed? to the handle" do + expect(backend._completed?).to eq(false) + backend.complete + expect(backend._completed?).to eq(true) + end + end + + describe "breadcrumbs" do + let(:handle) { backend.instance_variable_get(:@handle) } + + it "caps the buffer at the breadcrumb limit, keeping the most recent" do + 25.times { |i| backend.add_breadcrumb(:index => i) } + + buffer = backend.instance_variable_get(:@breadcrumbs) + expect(buffer.length).to eq(Appsignal::Transaction::BREADCRUMB_LIMIT) + expect(buffer.first).to eq(:index => 5) + expect(buffer.last).to eq(:index => 24) + end + + it "flushes the buffered breadcrumbs as sample data on complete" do + backend.add_breadcrumb(:action => "click") + data = Appsignal::Utils::Data.generate([{ :action => "click" }]) + expect(Appsignal::Utils::Data).to receive(:generate) + .with([{ :action => "click" }]).and_return(data) + expect(handle).to receive(:set_sample_data).with("breadcrumbs", data) + expect(handle).to receive(:complete) + + backend.complete + end + + it "does not flush sample data when there are no breadcrumbs" do + expect(handle).to_not receive(:set_sample_data) + expect(handle).to receive(:complete) + + backend.complete + end + + it "copies the buffer into a duplicate" do + backend.add_breadcrumb(:action => "click") + duplicate = backend.duplicate("new-id") + + expect(duplicate.instance_variable_get(:@breadcrumbs)).to eq([{ :action => "click" }]) + end + end + + describe "error_causes projection" do + it "projects causes to the first-line shape" do + causes = [{ + :name => "ArgumentError", + :message => "bad arg", + :backtrace => ["/app/lib/foo.rb:10:in `bar'"] + }] + projected = backend.send(:error_causes_sample_data, causes, false) + + expect(projected.first).to include(:name => "ArgumentError", :message => "bad arg") + expect(projected.first[:first_line]["original"]).to eq("/app/lib/foo.rb:10:in `bar'") + expect(projected.first[:first_line]["line"]).to eq(10) + end + + it "marks the last cause as not the root cause when the chain was truncated" do + causes = [{ :name => "E", :message => "m", :backtrace => ["/app/x.rb:1:in `y'"] }] + + expect(backend.send(:error_causes_sample_data, causes, true).last[:is_root_cause]) + .to eq(false) + end + + it "leaves the first line nil for a cause with no backtrace" do + causes = [{ :name => "E", :message => "m", :backtrace => nil }] + + expect(backend.send(:error_causes_sample_data, causes, false).first[:first_line]).to be_nil + end + end + + describe "#supports_multiple_errors?" do + it "returns false (extra errors are reported as duplicate transactions)" do + expect(backend.supports_multiple_errors?).to eq(false) + end + end +end diff --git a/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb b/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb new file mode 100644 index 000000000..7db4c39b3 --- /dev/null +++ b/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb @@ -0,0 +1,1914 @@ +# frozen_string_literal: true + +require "opentelemetry/sdk" if DependencyHelper.opentelemetry_present? + +describe Appsignal::Transaction::OpenTelemetryBackend, + :if => DependencyHelper.opentelemetry_present? do + let(:span_exporter) { ::OpenTelemetry::SDK::Trace::Export::InMemorySpanExporter.new } + let(:tracer_provider) do + provider = ::OpenTelemetry::SDK::Trace::TracerProvider.new + provider.add_span_processor( + ::OpenTelemetry::SDK::Trace::Export::SimpleSpanProcessor.new(span_exporter) + ) + provider + end + + before do + ::OpenTelemetry.tracer_provider = tracer_provider + @backends_created = [] + # Warn-once state is process-wide, so clear it between examples. + described_class.clear_warned! + + # OTel reports context-balance violations (e.g. DetachError) through its + # error handler, which by default only logs. Capture them so the after hook + # can fail the example on an unexpected one -- an accidental imbalance should + # be a red test, not a silent log. An example that deliberately provokes one + # sets @expect_otel_errors. + @otel_errors = [] + @original_otel_error_handler = ::OpenTelemetry.error_handler + ::OpenTelemetry.error_handler = + lambda { |exception: nil, message: nil| @otel_errors << [exception, message] } + end + + # Each `create_backend` call constructs a real backend, which attaches an + # OTel context on initialize. We track them all here and complete any that + # the test didn't complete itself, so leftover spans / context attachments + # don't pollute the next test. Complete in reverse (LIFO) order: the contexts + # are stacked in creation order, so the last one created must detach first. + after do + @backends_created.reverse_each { |backend| backend.complete unless backend._completed? } + ::OpenTelemetry.error_handler = @original_otel_error_handler + expect(@otel_errors).to be_empty unless @expect_otel_errors + end + + def create_backend(namespace = "http_request", opentelemetry_scope: nil) + described_class.new("abc-123", namespace, :opentelemetry_scope => opentelemetry_scope) + .tap { |b| @backends_created << b } + end + + def foreign_tracer + ::OpenTelemetry.tracer_provider.tracer("foreign-instrumentation") + end + + # Start a foreign span, make it the current OTel context for the block, and + # detach it afterwards (LIFO). Models another instrumentation's span sitting on + # top of AppSignal's context. + def with_foreign_current_span(name = "foreign") + foreign = foreign_tracer.start_span(name) + token = ::OpenTelemetry::Context.attach(::OpenTelemetry::Trace.context_with_span(foreign)) + yield foreign + ensure + ::OpenTelemetry::Context.detach(token) + foreign.finish + end + + def finished_span(span) + span_exporter.finished_spans.find { |s| s.span_id == span.context.span_id } + end + + def event_names(finished) + Array(finished&.events).map(&:name) + end + + describe "instrumentation scope" do + def root_span + span_exporter.finished_spans.find { |s| [:server, :consumer].include?(s.kind) } + end + + def event_spans + span_exporter.finished_spans.reject { |s| [:server, :consumer].include?(s.kind) } + end + + def scope_of(span) + [span.instrumentation_scope.name, span.instrumentation_scope.version] + end + + it "puts the root span under the given scope" do + backend = create_backend( + "http_request", + :opentelemetry_scope => ["appsignal-ruby/rack", "1.2.3"] + ) + backend.set_action("MyAction") + backend.complete + + expect(scope_of(root_span)).to eq(["appsignal-ruby/rack", "1.2.3"]) + end + + it "puts an event span under the scope passed at start_event" do + backend = create_backend + backend.start_event(:opentelemetry_scope => ["appsignal-ruby/redis", "4.5.6"]) + backend.finish_event("query.redis", "GET foo", nil, Appsignal::EventFormatter::DEFAULT) + backend.set_action("MyAction") + backend.complete + + expect(scope_of(event_spans.first)).to eq(["appsignal-ruby/redis", "4.5.6"]) + end + + it "puts a recorded event span under the scope passed at record_event" do + backend = create_backend + backend.record_event( + "query.data_mapper", "DM Query", nil, Appsignal::EventFormatter::DEFAULT, 1000, + :opentelemetry_scope => ["appsignal-ruby/data_mapper", "7.8.9"] + ) + backend.set_action("MyAction") + backend.complete + + expect(scope_of(event_spans.first)).to eq(["appsignal-ruby/data_mapper", "7.8.9"]) + end + + it "falls back to the default scope when none is given" do + backend = create_backend + backend.start_event + backend.finish_event("query.redis", "GET foo", nil, Appsignal::EventFormatter::DEFAULT) + backend.set_action("MyAction") + backend.complete + + expect(scope_of(root_span)).to eq(["appsignal-ruby", Appsignal::VERSION]) + expect(scope_of(event_spans.first)).to eq(["appsignal-ruby", Appsignal::VERSION]) + end + + it "falls back to the default scope when the name is blank" do + backend = create_backend("http_request", :opentelemetry_scope => ["", "1.2.3"]) + backend.set_action("MyAction") + backend.complete + + expect(scope_of(root_span)).to eq(["appsignal-ruby", Appsignal::VERSION]) + end + end + + describe "#initialize" do + it "constructs without raising" do + expect { create_backend }.not_to raise_error + end + + it "names the span 'appsignal.transaction '" do + create_backend("http_request").complete + expect(span_exporter.finished_spans.first.name).to eq("appsignal.transaction http_request") + end + + describe "span kind" do + def create_backend_with_kind(kind) + described_class.new("abc-123", "http_request", :opentelemetry_kind => kind) + .tap { |b| @backends_created << b } + end + + [:server, :consumer, :producer, :internal].each do |kind| + it "uses the given opentelemetry_kind #{kind.inspect} as the SpanKind" do + create_backend_with_kind(kind).complete + expect(span_exporter.finished_spans.first.kind).to eq(kind) + end + end + + it "defaults to a server span when no kind is given" do + create_backend.complete + expect(span_exporter.finished_spans.first.kind).to eq(:server) + end + + it "does not derive the kind from the namespace" do + create_backend("background_job").complete + expect(span_exporter.finished_spans.first.kind).to eq(:server) + end + + it "falls back to the default kind and warns on an unknown value" do + expect(Appsignal.internal_logger).to receive(:warn) + .with(a_string_including("opentelemetry_kind")) + + create_backend_with_kind(:bogus).complete + + expect(span_exporter.finished_spans.first.kind).to eq(:server) + end + + it "warns only once for a repeated unknown value" do + expect(Appsignal.internal_logger).to receive(:warn).once + + 2.times { create_backend_with_kind(:bogus).complete } + end + end + + context "with an incoming opentelemetry_context" do + let(:trace_id_hex) { "0af7651916cd43dd8448eb211c80319c" } + let(:span_id_hex) { "b7ad6b7169203331" } + let(:remote_context) do + # Build the remote parent context directly instead of parsing a + # `traceparent` through `OpenTelemetry.propagation`. The backend's job + # is to parent under a context it is handed; extracting one from a + # carrier is the Rack middleware's job, covered by its own specs. + # Building it here also keeps this a self-contained unit test: parsing + # would depend on the global propagator, which is only configured as a + # side effect of booting the SDK in some other example. + span_context = ::OpenTelemetry::Trace::SpanContext.new( + :trace_id => [trace_id_hex].pack("H*"), + :span_id => [span_id_hex].pack("H*"), + :trace_flags => ::OpenTelemetry::Trace::TraceFlags.from_byte(0x01), + :remote => true + ) + ::OpenTelemetry::Trace.context_with_span( + ::OpenTelemetry::Trace.non_recording_span(span_context) + ) + end + + def create_backend_with_context(context, relationship: nil) + described_class.new( + "abc-123", + "http_request", + :opentelemetry_context => context, + :opentelemetry_relationship => relationship + ).tap { |b| @backends_created << b } + end + + def link_span_context(root) + root.links.first.span_context + end + + it "parents under the remote span by default (continues the trace)" do + backend = create_backend_with_context(remote_context) + backend.complete + root = finished_span(backend.instance_variable_get(:@span)) + + expect(root.hex_trace_id).to eq(trace_id_hex) + expect(root.parent_span_id.unpack1("H*")).to eq(span_id_hex) + expect(Array(root.links)).to be_empty + end + + it "parents under the remote span with :parent" do + backend = create_backend_with_context(remote_context, :relationship => :parent) + backend.complete + root = finished_span(backend.instance_variable_get(:@span)) + + expect(root.hex_trace_id).to eq(trace_id_hex) + expect(root.parent_span_id.unpack1("H*")).to eq(span_id_hex) + expect(Array(root.links)).to be_empty + end + + it "starts a new trace linked back to the remote span with :link" do + backend = create_backend_with_context(remote_context, :relationship => :link) + backend.complete + root = finished_span(backend.instance_variable_get(:@span)) + + # A new unit of work: its own trace, no parent... + expect(root.hex_trace_id).not_to eq(trace_id_hex) + expect(root.parent_span_id).to eq(::OpenTelemetry::Trace::INVALID_SPAN_ID) + + # ... but linked back to the remote span. + expect(root.links.size).to eq(1) + expect(link_span_context(root).hex_trace_id).to eq(trace_id_hex) + expect(link_span_context(root).hex_span_id).to eq(span_id_hex) + end + + it "parents under the remote span and links back to it with :both" do + backend = create_backend_with_context(remote_context, :relationship => :both) + backend.complete + root = finished_span(backend.instance_variable_get(:@span)) + + # Continues the trace as a child... + expect(root.hex_trace_id).to eq(trace_id_hex) + expect(root.parent_span_id.unpack1("H*")).to eq(span_id_hex) + + # ... and keeps the explicit link back to the same span. + expect(root.links.size).to eq(1) + expect(link_span_context(root).hex_trace_id).to eq(trace_id_hex) + expect(link_span_context(root).hex_span_id).to eq(span_id_hex) + end + + it "ignores the remote span with :none (plain root span)" do + backend = create_backend_with_context(remote_context, :relationship => :none) + backend.complete + root = finished_span(backend.instance_variable_get(:@span)) + + expect(root.hex_trace_id).not_to eq(trace_id_hex) + expect(root.parent_span_id).to eq(::OpenTelemetry::Trace::INVALID_SPAN_ID) + expect(Array(root.links)).to be_empty + end + + it "falls back to the default relationship and warns on an unknown value" do + expect(Appsignal.internal_logger).to receive(:warn) + .with(a_string_including("opentelemetry_relationship")) + + backend = create_backend_with_context(remote_context, :relationship => :bogus) + backend.complete + root = finished_span(backend.instance_variable_get(:@span)) + + # Falls back to :parent, so it continues the trace as a child rather + # than silently dropping the incoming context like :none. + expect(root.hex_trace_id).to eq(trace_id_hex) + expect(root.parent_span_id.unpack1("H*")).to eq(span_id_hex) + end + + [:parent, :link, :both, :none].each do |relationship| + it "starts a plain root span with #{relationship.inspect} when no context is given" do + backend = described_class.new( + "abc-123", "http_request", :opentelemetry_relationship => relationship + ).tap { |b| @backends_created << b } + backend.complete + root = finished_span(backend.instance_variable_get(:@span)) + + expect(root.hex_trace_id).not_to eq(trace_id_hex) + expect(root.parent_span_id).to eq(::OpenTelemetry::Trace::INVALID_SPAN_ID) + expect(Array(root.links)).to be_empty + end + end + end + + it "attaches the new span as the OpenTelemetry current span" do + backend = create_backend + expect(::OpenTelemetry::Trace.current_span) + .to eq(backend.instance_variable_get(:@span)) + end + + it "ignores the ambient OpenTelemetry context and starts a new trace" do + outer_tracer = ::OpenTelemetry.tracer_provider.tracer("outer") + outer = outer_tracer.start_span("outer") + outer_token = + ::OpenTelemetry::Context.attach(::OpenTelemetry::Trace.context_with_span(outer)) + begin + backend = create_backend + backend_span = backend.instance_variable_get(:@span) + expect(backend_span.parent_span_id).to eq(::OpenTelemetry::Trace::INVALID_SPAN_ID) + expect(backend_span.context.trace_id).not_to eq(outer.context.trace_id) + # Complete (detach the root context) before detaching the outer token, + # so the detaches happen in LIFO order. + backend.complete + ensure + ::OpenTelemetry::Context.detach(outer_token) + outer.finish + end + end + + it "restores the previously active OpenTelemetry context on #complete" do + outer_tracer = ::OpenTelemetry.tracer_provider.tracer("outer") + outer = outer_tracer.start_span("outer") + outer_token = + ::OpenTelemetry::Context.attach(::OpenTelemetry::Trace.context_with_span(outer)) + begin + backend = create_backend + expect(::OpenTelemetry::Trace.current_span) + .to eq(backend.instance_variable_get(:@span)) + + backend.complete + + expect(::OpenTelemetry::Trace.current_span).to eq(outer) + ensure + ::OpenTelemetry::Context.detach(outer_token) + outer.finish + end + end + end + + describe "write method smoke tests" do + it "accepts #start_event without raising" do + expect { create_backend.start_event }.not_to raise_error + end + + it "accepts #finish_event without raising" do + expect { create_backend.finish_event("name", "title", "body", 1) }.not_to raise_error + end + + it "accepts #record_event without raising" do + expect { create_backend.record_event("name", "title", "body", 1, 1000) }.not_to raise_error + end + + it "accepts #set_metadata without raising" do + expect { create_backend.set_metadata("key", "value") }.not_to raise_error + end + + it "accepts #set_sample_data without raising" do + expect { create_backend.set_sample_data("request_payload", "anything") }.not_to raise_error + end + end + + describe "#params_mapping" do + it "keeps the request payload and function parameters in separate buckets" do + expect(create_backend.params_mapping).to eq( + :params => :request_payload, + :request_payload => :request_payload, + :function_parameters => :function_parameters, + :query_parameters => :query_parameters + ) + end + end + + describe "#set_sample_data params channels" do + def attribute_for(backend, key, data) + backend.set_sample_data(key, data) + backend.complete + span_exporter.finished_spans.first.attributes + end + + it "routes the request_payload channel to appsignal.request.payload" do + attributes = attribute_for(create_backend, "request_payload", "id" => 1) + + expect(attributes["appsignal.request.payload"]).to eq(JSON.generate("id" => 1)) + expect(attributes).to_not have_key("appsignal.function.parameters") + end + + it "routes the function_parameters channel to appsignal.function.parameters" do + attributes = attribute_for(create_backend, "function_parameters", "id" => 1) + + expect(attributes["appsignal.function.parameters"]).to eq(JSON.generate("id" => 1)) + expect(attributes).to_not have_key("appsignal.request.payload") + end + + it "routes the query_parameters channel to appsignal.request.query_parameters" do + attributes = attribute_for(create_backend, "query_parameters", "id" => 1) + + expect(attributes["appsignal.request.query_parameters"]).to eq(JSON.generate("id" => 1)) + expect(attributes).to_not have_key("appsignal.request.payload") + expect(attributes).to_not have_key("appsignal.function.parameters") + end + end + + describe "#start_event with opentelemetry_kind" do + def event_span_named(name) + span_exporter.finished_spans.find { |s| s.name == name } + end + + it "creates the event span with the given span kind" do + backend = create_backend + backend.start_event(:opentelemetry_kind => :client) + backend.finish_event("request.net_http", "GET", "", Appsignal::EventFormatter::DEFAULT) + backend.complete + + expect(event_span_named("request.net_http (GET)").kind).to eq(:client) + end + + it "defaults to an internal span when no kind is given" do + backend = create_backend + backend.start_event + backend.finish_event("sql.query", "title", "", Appsignal::EventFormatter::DEFAULT) + backend.complete + + expect(event_span_named("sql.query (title)").kind).to eq(:internal) + end + end + + describe "#set_queue_start" do + let(:metrics) { Appsignal::Metrics::OpenTelemetryBackend } + + it "adds an appsignal.queue_start event on the root span at the queue time" do + allow(metrics).to receive(:add_distribution_value) + backend = create_backend + backend.set_queue_start(1_700_000_000_000) + backend.complete + + event = span_exporter.finished_spans.first.events + .find { |e| e.name == "appsignal.queue_start" } + expect(event).not_to be_nil + expect(event.timestamp) + .to eq((Time.at(1_700_000_000_000 / 1000.0).to_r * 1_000_000_000).to_i) + end + + it "emits the queue duration metric in two series on completion" do + backend = create_backend("background_job") + backend.set_action("BackgroundJob#perform") + start_time = backend.instance_variable_get(:@start_time) + queue_start = ((start_time.to_f * 1000) - 5_000).round + + expect(metrics).to receive(:add_distribution_value).with( + "transaction_queue_duration", be_within(1_000).of(5_000), :namespace => "background" + ) + expect(metrics).to receive(:add_distribution_value).with( + "transaction_queue_duration", be_within(1_000).of(5_000), + :namespace => "background", :hostname => an_instance_of(String) + ) + + backend.set_queue_start(queue_start) + backend.complete + end + + it "does not emit the queue duration metric when no action was set" do + expect(metrics).to_not receive(:add_distribution_value) + backend = create_backend("background_job") + start_time = backend.instance_variable_get(:@start_time) + queue_start = ((start_time.to_f * 1000) - 5_000).round + + backend.set_queue_start(queue_start) + backend.complete + end + + it "ignores values below the epoch-ms floor" do + expect(metrics).to_not receive(:add_distribution_value) + backend = create_backend + backend.set_action("PagesController#show") + backend.set_queue_start(10) + backend.complete + + expect(Array(span_exporter.finished_spans.first.events).map(&:name)) + .to_not include("appsignal.queue_start") + end + end + + describe "allocation counts" do + let(:metrics) { Appsignal::Metrics::OpenTelemetryBackend } + + before do + configure(:options => { :enable_allocation_tracking => true }) + allow(metrics).to receive(:increment_counter) + # Drive the thread's allocation counter by hand so deltas are exact. + @allocations = 0 + allow(Appsignal::Extension).to receive(:allocation_count) { @allocations } + end + + # An event span's name leads with the event's category, optionally followed + # by the title in parentheses, so match on the part before the title. + def event_span_for(category) + span_exporter.finished_spans.find { |s| s.name.sub(/ \(.*\)\z/, "") == category } + end + + it "sets the transaction total on the root span from the delta since start" do + @allocations = 100 + backend = create_backend + backend.set_action("PagesController#show") + @allocations = 450 + span = backend.instance_variable_get(:@span) + backend.complete + + attributes = finished_span(span).attributes + expect(attributes["appsignal.transaction_allocation_count"]).to eq(350) + # No events, so all of it is the transaction's own self. + expect(attributes["appsignal.self_allocation_count"]).to eq(350) + end + + it "sets the root self to the transaction's allocations outside any event" do + @allocations = 100 + backend = create_backend + backend.set_action("PagesController#show") + @allocations = 130 # 30 before the event + backend.start_event + @allocations = 175 # 45 inside the event + backend.finish_event("sql.query", "SQL", "SELECT 1", 0) + @allocations = 200 # 25 after the event + span = backend.instance_variable_get(:@span) + backend.complete + + attributes = finished_span(span).attributes + # Total 100 -> 200 = 100; the event took 45, so 55 happened outside it. + expect(attributes["appsignal.transaction_allocation_count"]).to eq(100) + expect(attributes["appsignal.self_allocation_count"]).to eq(55) + end + + it "sets a childless event's full and self counts to the delta over the event" do + @allocations = 100 + backend = create_backend + @allocations = 130 + backend.start_event + @allocations = 175 + backend.finish_event("sql.query", "SQL", "SELECT 1", 0) + + attributes = event_span_for("sql.query").attributes + expect(attributes["appsignal.allocation_count"]).to eq(45) + expect(attributes["appsignal.self_allocation_count"]).to eq(45) + end + + it "excludes a child event's allocations from the parent's self count" do + @allocations = 100 + backend = create_backend + @allocations = 110 + backend.start_event # outer + @allocations = 130 + backend.start_event # inner + @allocations = 175 + backend.finish_event("sql.query", "SQL", "SELECT 1", 0) + @allocations = 200 + backend.finish_event("template.render", "Render", "", 0) + + inner = event_span_for("sql.query").attributes + outer = event_span_for("template.render").attributes + # Inner: full == self == 45 (no children). + expect(inner["appsignal.allocation_count"]).to eq(45) + expect(inner["appsignal.self_allocation_count"]).to eq(45) + # Outer: full 90 covers the inner event; self 45 excludes it. + expect(outer["appsignal.allocation_count"]).to eq(90) + expect(outer["appsignal.self_allocation_count"]).to eq(45) + end + + it "sets no allocation attribute for a recorded event" do + backend = create_backend + backend.record_event("sql.query", "SQL", "SELECT 1", 0, 1_000_000) + + attributes = event_span_for("sql.query").attributes + expect(attributes).to_not have_key("appsignal.allocation_count") + expect(attributes).to_not have_key("appsignal.self_allocation_count") + end + + # Exercises a tree deeper and wider than a single parent-child: three levels + # of nesting, two siblings, allocations by the parent between its children, + # and a recorded event whose allocations must fall into the enclosing event. + # A two-level test can't catch a parent credited with a child's self instead + # of its full count, because a childless child has self == full; here `e2` + # has its own child, so the two differ. + # + # transaction root (start 0) + # e1 (start 10) + # e2 (start 15) + # e3 full 15, self 15 (20 -> 35) + # e2 own work: 5 before + 7 after e3 -> self 12, full 27 + # r (recorded): its 8 allocs stay in e1 (42 -> 50) + # e4 full 12, self 12 (58 -> 70) + # e1 own: 5 + 8 (r) + 8 + 10 = 31 self, full 70 + # 10 allocs happen before e1 starts, so the transaction total is 80. + it "computes self correctly across a deep, wide tree with a recorded event" do + @allocations = 0 + backend = create_backend + backend.set_action("PagesController#show") + + @allocations = 10 + backend.start_event # e1 + @allocations = 15 + backend.start_event # e2 + @allocations = 20 + backend.start_event # e3 + @allocations = 35 + backend.finish_event("e3", "e3", "", 0) + @allocations = 42 + backend.finish_event("e2", "e2", "", 0) + @allocations = 50 # e1's own allocations, recorded below (must not be excluded) + backend.record_event("r", "r", "", 0, 1_000_000) + @allocations = 58 + backend.start_event # e4 + @allocations = 70 + backend.finish_event("e4", "e4", "", 0) + @allocations = 80 + backend.finish_event("e1", "e1", "", 0) + + span = backend.instance_variable_get(:@span) + backend.complete + + # [full, self] for an event span, by category name. + counts = lambda do |category| + attributes = event_span_for(category).attributes + [attributes["appsignal.allocation_count"], attributes["appsignal.self_allocation_count"]] + end + + expect(counts.call("e3")).to eq([15, 15]) + expect(counts.call("e2")).to eq([27, 12]) + expect(counts.call("e4")).to eq([12, 12]) + # e1's self (31) includes the recorded event's 8 allocations and excludes + # both e2 (27, which itself includes e3) and e4 (12). + expect(counts.call("e1")).to eq([70, 31]) + + expect(event_span_for("r").attributes).to_not have_key("appsignal.allocation_count") + root = finished_span(span).attributes + # Transaction total spans everything, including the 10 allocations before e1. + expect(root["appsignal.transaction_allocation_count"]).to eq(80) + # Root self excludes e1 (the only top-level event, full 70), leaving the 10 + # allocations that happened before any event started. + expect(root["appsignal.self_allocation_count"]).to eq(10) + end + + it "emits the allocation count metric in two series when an action was set" do + @allocations = 100 + backend = create_backend + backend.set_action("PagesController#show") + @allocations = 300 + + expect(metrics).to receive(:increment_counter).with( + "transaction_allocation_count", 200, :namespace => "web" + ) + expect(metrics).to receive(:increment_counter).with( + "transaction_allocation_count", 200, + :namespace => "web", :action => "PagesController#show" + ) + + backend.complete + end + + it "does not emit the allocation count metric when no action was set" do + @allocations = 100 + backend = create_backend + @allocations = 300 + expect(metrics).to_not receive(:increment_counter) + + backend.complete + end + + it "does not emit the allocation count metric when the delta is zero" do + @allocations = 100 + backend = create_backend + backend.set_action("PagesController#show") + expect(metrics).to_not receive(:increment_counter) + + backend.complete + end + + it "does not emit the allocation count metric when discarded" do + @allocations = 100 + backend = create_backend + backend.set_action("PagesController#show") + @allocations = 300 + expect(metrics).to_not receive(:increment_counter) + + backend.discard + end + + # The counter is thread-local and only climbs, so a lower value at finish + # than at start means the work moved threads. The delta is meaningless. + it "drops an event's allocation counts and warns when the counter reversed" do + @allocations = 200 + backend = create_backend + @allocations = 250 + backend.start_event + @allocations = 100 # finished on another thread: counter went backwards + logs = capture_logs { backend.finish_event("sql.query", "SQL", "SELECT 1", 0) } + + attributes = event_span_for("sql.query").attributes + expect(attributes).to_not have_key("appsignal.allocation_count") + expect(attributes).to_not have_key("appsignal.self_allocation_count") + expect(logs).to include("allocation counter decreased") + end + + it "drops the transaction allocation counts and warns when the counter reversed" do + @allocations = 500 + backend = create_backend + backend.set_action("PagesController#show") + @allocations = 100 # finished on another thread: counter went backwards + span = backend.instance_variable_get(:@span) + expect(metrics).to_not receive(:increment_counter) + + logs = capture_logs { backend.complete } + + attributes = finished_span(span).attributes + expect(attributes).to_not have_key("appsignal.transaction_allocation_count") + expect(attributes).to_not have_key("appsignal.self_allocation_count") + expect(logs).to include("allocation counter decreased") + end + + context "when allocation tracking is disabled" do + before { configure(:options => { :enable_allocation_tracking => false }) } + + it "sets no allocation attributes and emits no metric" do + @allocations = 100 + backend = create_backend + backend.set_action("PagesController#show") + @allocations = 300 + span = backend.instance_variable_get(:@span) + expect(metrics).to_not receive(:increment_counter) + + backend.complete + + expect(finished_span(span).attributes) + .to_not have_key("appsignal.transaction_allocation_count") + end + end + end + + describe "#set_action" do + it "renames the root span to the action" do + backend = create_backend + backend.set_action("PagesController#show") + backend.complete + + expect(span_exporter.finished_spans.first.name).to eq("PagesController#show") + end + + it "sets the appsignal.action_name attribute on the root span" do + backend = create_backend + backend.set_action("PagesController#show") + backend.complete + + expect(span_exporter.finished_spans.first.attributes["appsignal.action_name"]) + .to eq("PagesController#show") + end + end + + describe "#set_attributes" do + def root_span_of(backend) + finished_span(backend.instance_variable_get(:@span)) + end + + it "sets the attributes on the root span when no event is open" do + backend = create_backend + backend.set_attributes("http.request.method" => "GET") + backend.complete + + expect(root_span_of(backend).attributes["http.request.method"]).to eq("GET") + end + + it "sets the attributes on the open event span" do + backend = create_backend + backend.start_event + event_span = backend.instance_variable_get(:@event_stack).last.first + backend.set_attributes("db.system.name" => "redis") + backend.finish_event("query.redis", "GET foo", nil, Appsignal::EventFormatter::DEFAULT) + backend.complete + + expect(finished_span(event_span).attributes["db.system.name"]).to eq("redis") + end + + # The span an attribute lands on decides how the trace timeline reads it, so + # an attribute meant for the transaction must not leak onto an event. + it "leaves the root span untouched when an event is open" do + backend = create_backend + backend.start_event + backend.set_attributes("db.system.name" => "redis") + backend.finish_event("query.redis", "GET foo", nil, Appsignal::EventFormatter::DEFAULT) + backend.complete + + expect(root_span_of(backend).attributes).to_not have_key("db.system.name") + end + + it "sets the attributes on the innermost open event span" do + backend = create_backend + backend.start_event + outer_span = backend.instance_variable_get(:@event_stack).last.first + backend.start_event + inner_span = backend.instance_variable_get(:@event_stack).last.first + backend.set_attributes("db.system.name" => "redis") + backend.finish_event("query.redis", "GET foo", nil, Appsignal::EventFormatter::DEFAULT) + backend.finish_event("perform.job", "MyJob", nil, Appsignal::EventFormatter::DEFAULT) + backend.complete + + expect(finished_span(inner_span).attributes["db.system.name"]).to eq("redis") + expect(finished_span(outer_span).attributes).to_not have_key("db.system.name") + end + + it "converts values OTLP does not accept to strings" do + backend = create_backend + backend.set_attributes("appsignal.group" => :render) + backend.complete + + expect(root_span_of(backend).attributes["appsignal.group"]).to eq("render") + end + + it "keeps the values OTLP accepts as they are" do + backend = create_backend + backend.set_attributes( + "string" => "value", + "integer" => 1, + "float" => 1.5, + "boolean" => true + ) + backend.complete + + attributes = root_span_of(backend).attributes + expect(attributes["string"]).to eq("value") + expect(attributes["integer"]).to eq(1) + expect(attributes["float"]).to eq(1.5) + expect(attributes["boolean"]).to be(true) + end + + it "converts symbol keys to strings" do + backend = create_backend + backend.set_attributes(:"db.system.name" => "redis") + backend.complete + + expect(root_span_of(backend).attributes["db.system.name"]).to eq("redis") + end + end + + describe "appsignal.namespace attribute" do + # The backend converts the internal namespaces to the values the collector + # expects; everything else passes through. + { + "http_request" => "web", + "background_job" => "background", + "action_cable" => "action_cable", + "custom" => "custom" + }.each do |namespace, expected| + it "maps the constructor namespace #{namespace.inspect} to #{expected.inspect}" do + create_backend(namespace).complete + + expect(span_exporter.finished_spans.first.attributes["appsignal.namespace"]) + .to eq(expected) + end + end + + describe "#set_namespace" do + it "overwrites the appsignal.namespace attribute" do + backend = create_backend("http_request") + backend.set_namespace("custom") + backend.complete + + expect(span_exporter.finished_spans.first.attributes["appsignal.namespace"]) + .to eq("custom") + end + + it "converts the overriding namespace to its canonical value" do + backend = create_backend("custom") + backend.set_namespace("background_job") + backend.complete + + expect(span_exporter.finished_spans.first.attributes["appsignal.namespace"]) + .to eq("background") + end + + it "does not change the span kind (fixed at creation)" do + backend = create_backend("http_request") + backend.set_namespace("background_job") + backend.complete + + expect(span_exporter.finished_spans.first.kind).to eq(:server) + end + end + end + + describe "#set_error" do + def exception_event(backend) + backend.complete + root_span_of(backend).events.find { |e| e.name == "exception" } + end + + def root_span_of(backend) + backend_span_id = backend.instance_variable_get(:@span).context.span_id + span_exporter.finished_spans.find { |s| s.span_id == backend_span_id } + end + + it "records an exception span-event on the root span" do + backend = create_backend + backend.set_error("RuntimeError", "boom", ["line 1", "line 2"], [], false) + + event = exception_event(backend) + expect(event).not_to be_nil + expect(event.attributes["exception.type"]).to eq("RuntimeError") + expect(event.attributes["exception.message"]).to eq("boom") + expect(event.attributes["exception.stacktrace"]).to eq("line 1\nline 2") + end + + it "sets the span status to error" do + backend = create_backend + backend.set_error("RuntimeError", "boom", ["line 1"], [], false) + backend.complete + + backend_span_id = backend.instance_variable_get(:@span).context.span_id + root = span_exporter.finished_spans.find { |s| s.span_id == backend_span_id } + expect(root.status.code).to eq(::OpenTelemetry::Trace::Status::ERROR) + end + + it "sets error.type on the span to the exception class" do + backend = create_backend + backend.set_error("RuntimeError", "boom", ["line 1"], [], false) + backend.complete + + expect(root_span_of(backend).attributes["error.type"]).to eq("RuntimeError") + end + + it "keeps the last error.type when a span collects more than one error" do + backend = create_backend + backend.set_error("RuntimeError", "first", ["line 1"], [], false) + backend.set_error("ArgumentError", "second", ["line 2"], [], false) + backend.complete + + expect(root_span_of(backend).attributes["error.type"]).to eq("ArgumentError") + end + + it "sets error.type on the span that is current when called" do + backend = create_backend + backend.start_event + backend.set_error("RuntimeError", "boom", ["line 1"], [], false) + backend.finish_event("sql.query", "title", "body", Appsignal::EventFormatter::DEFAULT) + backend.complete + + event_span = span_exporter.finished_spans.find { |s| s.name == "sql.query (title)" } + + expect(event_span.attributes["error.type"]).to eq("RuntimeError") + expect(root_span_of(backend).attributes).not_to have_key("error.type") + end + + it "does not set error.type when no error is set" do + backend = create_backend + backend.complete + + expect(root_span_of(backend).attributes).not_to have_key("error.type") + end + + it "omits exception.stacktrace content when there is no backtrace" do + backend = create_backend + backend.set_error("RuntimeError", "boom", nil, [], false) + + expect(exception_event(backend).attributes["exception.stacktrace"]).to eq("") + end + + it "emits causes as an appsignal.error_causes JSON attribute matching ErrorSubCause" do + backend = create_backend + causes = [ + { :name => "ArgumentError", :message => "bad arg", :backtrace => ["cause 1", "cause 2"] }, + { :name => "KeyError", :message => "missing", :backtrace => ["cause 3"] } + ] + backend.set_error("RuntimeError", "boom", ["line 1"], causes, false) + + parsed = JSON.parse(exception_event(backend).attributes["appsignal.error_causes"]) + expect(parsed).to eq( + [ + { "name" => "ArgumentError", "message" => "bad arg", "lines" => ["cause 1", "cause 2"] }, + { "name" => "KeyError", "message" => "missing", "lines" => ["cause 3"] } + ] + ) + end + + it "defaults a cause's lines to an empty Array when it has no backtrace" do + backend = create_backend + backend.set_error( + "RuntimeError", "boom", ["line 1"], + [{ :name => "ArgumentError", :message => "bad arg", :backtrace => nil }], + false + ) + + parsed = JSON.parse(exception_event(backend).attributes["appsignal.error_causes"]) + expect(parsed).to eq([{ "name" => "ArgumentError", "message" => "bad arg", "lines" => [] }]) + end + + describe "a cause backtrace that shares its last lines with the error's" do + def parsed_causes(backend) + JSON.parse(exception_event(backend).attributes["appsignal.error_causes"]) + end + + def cause_lines(backend) + parsed_causes(backend).map { |cause| cause["lines"] } + end + + it "drops the trailing lines the cause shares with the error" do + backend = create_backend + causes = [ + { + :name => "ArgumentError", :message => "bad arg", + :backtrace => ["cause 1", "cause 2", "shared 1", "shared 2"] + }, + { + :name => "KeyError", :message => "missing", + :backtrace => ["cause 3", "shared 1", "shared 2"] + } + ] + backend.set_error( + "RuntimeError", "boom", ["line 1", "shared 1", "shared 2"], causes, false + ) + + expect(cause_lines(backend)).to eq([["cause 1", "cause 2"], ["cause 3"]]) + end + + it "keeps every line when the last lines differ" do + backend = create_backend + causes = [ + { + :name => "ArgumentError", :message => "bad arg", + :backtrace => ["shared 1", "shared 2", "cause 1"] + } + ] + backend.set_error( + "RuntimeError", "boom", ["shared 1", "shared 2", "line 1"], causes, false + ) + + expect(cause_lines(backend)).to eq([["shared 1", "shared 2", "cause 1"]]) + end + + it "keeps the first line when every line is shared" do + backend = create_backend + causes = [ + { + :name => "ArgumentError", :message => "bad arg", + :backtrace => ["shared 1", "shared 2"] + } + ] + backend.set_error( + "RuntimeError", "boom", ["line 1", "shared 1", "shared 2"], causes, false + ) + + expect(cause_lines(backend)).to eq([["shared 1"]]) + end + + it "keeps the first line when the cause's backtrace is the error's backtrace" do + backend = create_backend + lines = ["shared 1", "shared 2"] + causes = [{ :name => "ArgumentError", :message => "bad arg", :backtrace => lines }] + backend.set_error("RuntimeError", "boom", lines, causes, false) + + expect(cause_lines(backend)).to eq([["shared 1"]]) + end + + it "keeps every line when the error has no backtrace" do + causes = [{ :name => "ArgumentError", :message => "bad arg", :backtrace => ["cause 1"] }] + + nil_backtrace = create_backend + nil_backtrace.set_error("RuntimeError", "boom", nil, causes, false) + expect(cause_lines(nil_backtrace)).to eq([["cause 1"]]) + + empty_backtrace = create_backend + empty_backtrace.set_error("RuntimeError", "boom", [], causes, false) + expect(cause_lines(empty_backtrace)).to eq([["cause 1"]]) + end + + it "sends no lines for a cause without a backtrace" do + backend = create_backend + causes = [ + { :name => "ArgumentError", :message => "bad arg", :backtrace => nil }, + { :name => "KeyError", :message => "missing", :backtrace => [] } + ] + backend.set_error("RuntimeError", "boom", ["shared 1"], causes, false) + + expect(cause_lines(backend)).to eq([[], []]) + end + + it "reports how many lines were dropped from each cause" do + backend = create_backend + causes = [ + { + :name => "ArgumentError", :message => "bad arg", + :backtrace => ["cause 1", "cause 2", "shared 1", "shared 2"] + }, + { + :name => "KeyError", :message => "missing", + :backtrace => ["cause 3", "shared 2"] + } + ] + backend.set_error( + "RuntimeError", "boom", ["line 1", "shared 1", "shared 2"], causes, false + ) + + expect(parsed_causes(backend)).to eq( + [ + { + "name" => "ArgumentError", "message" => "bad arg", + "lines" => ["cause 1", "cause 2"], "lines_omitted" => 2 + }, + { + "name" => "KeyError", "message" => "missing", + "lines" => ["cause 3"], "lines_omitted" => 1 + } + ] + ) + end + + it "reports no dropped lines for a cause that shares no lines" do + backend = create_backend + causes = [ + { + :name => "ArgumentError", :message => "bad arg", + :backtrace => ["shared 1", "shared 2", "cause 1"] + } + ] + backend.set_error( + "RuntimeError", "boom", ["shared 1", "shared 2", "line 1"], causes, false + ) + + expect(parsed_causes(backend).first).not_to have_key("lines_omitted") + end + + # Every line is shared, but the first one is kept, so it was not dropped + # and is not counted. + it "does not count the shared line it keeps when every line is shared" do + backend = create_backend + causes = [ + { + :name => "ArgumentError", :message => "bad arg", + :backtrace => ["shared 1", "shared 2", "shared 3"] + } + ] + backend.set_error( + "RuntimeError", "boom", ["line 1", "shared 1", "shared 2", "shared 3"], causes, false + ) + + cause = parsed_causes(backend).first + expect(cause["lines"]).to eq(["shared 1"]) + expect(cause["lines_omitted"]).to eq(2) + end + + it "reports no dropped lines when the only line a cause has is shared" do + backend = create_backend + causes = [{ :name => "ArgumentError", :message => "bad arg", :backtrace => ["shared 1"] }] + backend.set_error("RuntimeError", "boom", ["line 1", "shared 1"], causes, false) + + cause = parsed_causes(backend).first + expect(cause["lines"]).to eq(["shared 1"]) + expect(cause).not_to have_key("lines_omitted") + end + end + + it "does not set appsignal.error_causes when there are no causes" do + backend = create_backend + backend.set_error("RuntimeError", "boom", ["line 1"], [], false) + + expect(exception_event(backend).attributes).not_to have_key("appsignal.error_causes") + end + + it "flags the error for the collector and lets it compute the digest" do + backend = create_backend + backend.set_error("RuntimeError", "boom", ["line 1"], [], false) + + attributes = exception_event(backend).attributes + # The gem flags the exception so the collector reports it even on a + # non-root span; the collector computes the digest itself. + expect(attributes["appsignal.alert_this_error"]).to eq(true) + expect(attributes).not_to have_key("appsignal.error_digest") + end + + it "records the exception on the span that is current when called" do + backend = create_backend + backend.start_event + backend.set_error("RuntimeError", "boom", ["line 1"], [], false) + backend.finish_event("sql.query", "title", "body", Appsignal::EventFormatter::DEFAULT) + backend.complete + + event_span = span_exporter.finished_spans + .find { |s| s.name == "sql.query (title)" } + backend_span_id = backend.instance_variable_get(:@span).context.span_id + root = span_exporter.finished_spans.find { |s| s.span_id == backend_span_id } + + expect(event_span.events.map(&:name)).to include("exception") + expect(Array(root.events).map(&:name)).not_to include("exception") + end + + it "records one exception event per call (multiple errors on one span)" do + backend = create_backend + backend.set_error("RuntimeError", "first", ["line 1"], [], false) + backend.set_error("ArgumentError", "second", ["line 2"], [], false) + backend.complete + + backend_span_id = backend.instance_variable_get(:@span).context.span_id + root = span_exporter.finished_spans.find { |s| s.span_id == backend_span_id } + events = root.events.select { |e| e.name == "exception" } + expect(events.map { |e| e.attributes["exception.type"] }) + .to eq(["RuntimeError", "ArgumentError"]) + expect(events.map { |e| e.attributes["exception.message"] }).to eq(["first", "second"]) + end + end + + describe "#supports_multiple_errors?" do + it "returns true (multiple exception events on one span)" do + expect(create_backend.supports_multiple_errors?).to eq(true) + end + end + + describe "#finish" do + it "returns true so Transaction#complete runs the sample_data path" do + expect(create_backend.finish).to eq(true) + end + end + + describe "#complete" do + it "finishes the OTel span" do + backend = create_backend + span = backend.instance_variable_get(:@span) + backend.complete + + # `finished_spans` returns immutable `SpanData` structs, not the + # mutable `Span` objects we hold a reference to — compare by span_id. + expect(span_exporter.finished_spans.map(&:span_id)).to include(span.context.span_id) + end + + it "detaches the OTel context (current_span back to INVALID)" do + backend = create_backend + expect(::OpenTelemetry::Trace.current_span).not_to eq(::OpenTelemetry::Trace::Span::INVALID) + + backend.complete + + expect(::OpenTelemetry::Trace.current_span).to eq(::OpenTelemetry::Trace::Span::INVALID) + end + + it "toggles _completed? from false to true" do + backend = create_backend + expect(backend._completed?).to eq(false) + + backend.complete + + expect(backend._completed?).to eq(true) + end + + context "when no action was set" do + it "flags the subtrace as ignored so the collector drops the placeholder-named root" do + backend = create_backend + span = backend.instance_variable_get(:@span) + backend.complete + + finished = finished_span(span) + expect(finished.name).to eq("appsignal.transaction http_request") + expect(finished.attributes["appsignal.ignore_subtrace"]).to be(true) + end + end + + context "when an action was set" do + it "does not flag the subtrace as ignored" do + backend = create_backend + backend.set_action("PagesController#show") + span = backend.instance_variable_get(:@span) + backend.complete + + finished = finished_span(span) + expect(finished.attributes["appsignal.action_name"]).to eq("PagesController#show") + expect(finished.attributes).to_not have_key("appsignal.ignore_subtrace") + end + end + end + + describe "#discard" do + it "sets appsignal.ignore_subtrace = true on the root span" do + backend = create_backend + span = backend.instance_variable_get(:@span) + backend.discard + + finished = span_exporter.finished_spans.find { |s| s.span_id == span.context.span_id } + expect(finished.attributes["appsignal.ignore_subtrace"]).to be(true) + end + + it "finishes the OTel span" do + backend = create_backend + span = backend.instance_variable_get(:@span) + backend.discard + + expect(span_exporter.finished_spans.map(&:span_id)).to include(span.context.span_id) + end + + it "detaches the OTel context (current_span back to INVALID)" do + backend = create_backend + expect(::OpenTelemetry::Trace.current_span).not_to eq(::OpenTelemetry::Trace::Span::INVALID) + + backend.discard + + expect(::OpenTelemetry::Trace.current_span).to eq(::OpenTelemetry::Trace::Span::INVALID) + end + + it "toggles _completed? from false to true" do + backend = create_backend + expect(backend._completed?).to eq(false) + + backend.discard + + expect(backend._completed?).to eq(true) + end + + it "is idempotent" do + backend = create_backend + backend.discard + + expect { backend.discard }.not_to raise_error + end + end + + describe "#duplicate" do + # Collector mode records every error eagerly on one trace, so the Transaction + # never duplicates the backend. Duplication is agent-only. + it "raises NotImplementedError" do + expect { create_backend.duplicate("new-id") }.to raise_error(NotImplementedError) + end + end + + describe "#to_json" do + it 'returns "{}" so Transaction#to_h yields an empty Hash' do + backend = create_backend + expect(backend.to_json).to eq("{}") + expect(JSON.parse(backend.to_json)).to eq({}) + end + end + + describe "#queue_start" do + it "returns nil (set_queue_start is a no-op for now)" do + backend = create_backend + backend.set_queue_start(123_456) + expect(backend.queue_start).to be_nil + end + end + + # Smoke test: a Transaction backed by an OpenTelemetryBackend exercises + # every public API path without raising, and emits exactly one OTel root + # span on completion. The lifecycle behavior (kind, name, context attach) + # is covered above; this test mostly guards that no-op methods don't + # accidentally start crashing when called from the Transaction. + describe "Transaction backed by this backend (collector-mode shape)" do + before { start_agent } + + def new_transaction_with_otel_backend(namespace = Appsignal::Transaction::HTTP_REQUEST) + backend = described_class.new("abc-123", namespace) + @backends_created << backend + Appsignal::Transaction.new(namespace, :backend => backend) + end + + it "does not raise across the create -> events -> set_action -> complete flow" do + expect do + transaction = new_transaction_with_otel_backend + transaction.set_action("MyController#index") + transaction.set_namespace(Appsignal::Transaction::BACKGROUND_JOB) + transaction.set_queue_start(1_000_000) + transaction.set_metadata("foo", "bar") + transaction.start_event + transaction.finish_event("sql.query", "title", "SELECT 1", 1) + transaction.add_tags(:tag => "value") + transaction.add_error(RuntimeError.new("boom")) + transaction.complete + transaction.to_h + end.not_to raise_error + end + + it "produces an empty Hash from #to_h (to_json returns {})" do + transaction = new_transaction_with_otel_backend + transaction.start_event + transaction.finish_event("event", "title", "body", 1) + transaction.complete + + expect(transaction.to_h).to eq({}) + end + + it "emits a root span plus a child event span on completion" do + transaction = new_transaction_with_otel_backend + transaction.start_event + transaction.finish_event("event", "title", "body", Appsignal::EventFormatter::DEFAULT) + transaction.complete + + expect(span_exporter.finished_spans.size).to eq(2) + kinds = span_exporter.finished_spans.map(&:kind) + expect(kinds).to include(:server) + expect(kinds).to include(:internal) + end + end + + describe "event stack" do + describe "#start_event" do + it "opens a child span and attaches it as the current OTel context" do + backend = create_backend + root_span = backend.instance_variable_get(:@span) + + backend.start_event + + current = ::OpenTelemetry::Trace.current_span + expect(current).not_to eq(root_span) + expect(current.context.trace_id).to eq(root_span.context.trace_id) + + stack = backend.instance_variable_get(:@event_stack) + expect(stack.size).to eq(1) + expect(stack.first.first).to eq(current) + end + end + + describe "#finish_event" do + it "pops the stack, names the span, finishes it, and detaches the context" do + backend = create_backend + root_span = backend.instance_variable_get(:@span) + + backend.start_event + backend.finish_event("custom.event", "Title", "Body", + Appsignal::EventFormatter::DEFAULT) + + expect(backend.instance_variable_get(:@event_stack)).to be_empty + expect(::OpenTelemetry::Trace.current_span).to eq(root_span) + + event_span = span_exporter.finished_spans + .find { |s| s.name == "custom.event (Title)" } + expect(event_span).not_to be_nil + # The event name leads the span name and the human-readable title + # follows in parentheses. There is no separate appsignal.category + # attribute. + expect(event_span.name).to eq("custom.event (Title)") + expect(event_span.attributes).not_to have_key("appsignal.category") + expect(event_span.attributes["appsignal.body"]).to eq("Body") + expect(event_span.attributes).not_to have_key("appsignal.title") + end + + it "does nothing if the event stack is empty (unpaired finish_event)" do + backend = create_backend + expect do + backend.finish_event("custom.event", "T", "B", + Appsignal::EventFormatter::DEFAULT) + end.not_to raise_error + end + end + + describe "#record_event" do + it "creates a child span with the event name and a backdated start_timestamp" do + backend = create_backend + duration_ns = 1_000_000_000 # 1 second + backend.record_event("custom.event", "T", "B", + Appsignal::EventFormatter::DEFAULT, duration_ns) + + span = span_exporter.finished_spans + .find { |s| s.name == "custom.event (T)" } + expect(span).not_to be_nil + expect(span.name).to eq("custom.event (T)") + observed = span.end_timestamp - span.start_timestamp + # Allow a small slack for clock jitter and the time elapsed + # between computing start_time and calling finish. + expect(observed).to be_within(50_000_000).of(duration_ns) + end + + it "does NOT push onto the event stack" do + backend = create_backend + backend.record_event("custom.event", nil, nil, + Appsignal::EventFormatter::DEFAULT, 1_000) + expect(backend.instance_variable_get(:@event_stack)).to be_empty + end + + it "puts opentelemetry_attributes on the finished span" do + backend = create_backend + backend.record_event("custom.event", "T", "B", + Appsignal::EventFormatter::DEFAULT, 1_000, + :opentelemetry_attributes => { "db.system.name" => "postgresql" }) + + span = span_exporter.finished_spans.find { |s| s.name == "custom.event (T)" } + expect(span.attributes["db.system.name"]).to eq("postgresql") + end + + it "lets its own db.system.name win over the SQL sentinel" do + backend = create_backend + backend.record_event("sql.query", "Q", "SELECT 1", + Appsignal::EventFormatter::SQL_BODY_FORMAT, 1_000, + :opentelemetry_attributes => { "db.system.name" => "postgresql" }) + + span = span_exporter.finished_spans.find { |s| s.name == "sql.query (Q)" } + expect(span.attributes["db.system.name"]).to eq("postgresql") + expect(span.attributes["db.query.text"]).to eq("SELECT 1") + end + + # A caller passing an explicit nil (e.g. a lookup that came back empty) + # must not block the sentinel. Attributes.format coerces nil to "", so + # the key is present either way; only a non-blank value should count as + # naming a real engine. A span left with "" would skip the collector's + # sanitizer entirely, unlike other_sql, which the sanitizer recognizes. + it "falls back to the SQL sentinel when the engine name is explicitly blank" do + backend = create_backend + backend.record_event("sql.query", "Q", "SELECT 1", + Appsignal::EventFormatter::SQL_BODY_FORMAT, 1_000, + :opentelemetry_attributes => { "db.system.name" => nil }) + + span = span_exporter.finished_spans.find { |s| s.name == "sql.query (Q)" } + expect(span.attributes["db.system.name"]).to eq("other_sql") + end + end + + describe "nested events" do + it "produces a properly nested span tree" do + backend = create_backend + root_span = backend.instance_variable_get(:@span) + + backend.start_event + outer_span = backend.instance_variable_get(:@event_stack).last.first + backend.start_event + inner_span = backend.instance_variable_get(:@event_stack).last.first + + backend.finish_event("inner.event", nil, nil, + Appsignal::EventFormatter::DEFAULT) + backend.finish_event("outer.event", nil, nil, + Appsignal::EventFormatter::DEFAULT) + + inner = span_exporter.finished_spans.find { |s| s.name == "inner.event" } + outer = span_exporter.finished_spans.find { |s| s.name == "outer.event" } + + expect(inner.span_id).to eq(inner_span.context.span_id) + expect(outer.span_id).to eq(outer_span.context.span_id) + expect(inner.parent_span_id).to eq(outer_span.context.span_id) + expect(outer.parent_span_id).to eq(root_span.context.span_id) + end + end + + describe "attribute mapping" do + it "writes db.query.text + db.system.name for SQL bodies (not appsignal.body)" do + backend = create_backend + backend.start_event + backend.finish_event("sql.query", "Q", "SELECT 1", + Appsignal::EventFormatter::SQL_BODY_FORMAT) + + attrs = span_exporter.finished_spans + .find { |s| s.name == "sql.query (Q)" }.attributes + expect(attrs["db.query.text"]).to eq("SELECT 1") + expect(attrs["db.system.name"]).to eq("other_sql") + expect(attrs).not_to have_key("appsignal.body") + end + + # This is the bug the fallback guards against: an integration names the + # real engine mid-event through `set_attributes` (as + # `add_opentelemetry_attributes` does), and the SQL sentinel written at + # `finish_event` must not clobber it on the finished span. + it "keeps an engine name set mid-event over the SQL sentinel" do + backend = create_backend + backend.start_event + backend.set_attributes("db.system.name" => "postgresql") + backend.finish_event("sql.query", "Q", "SELECT 1", + Appsignal::EventFormatter::SQL_BODY_FORMAT) + + attrs = span_exporter.finished_spans + .find { |s| s.name == "sql.query (Q)" }.attributes + expect(attrs["db.system.name"]).to eq("postgresql") + expect(attrs["db.query.text"]).to eq("SELECT 1") + end + + # Attributes.format coerces an explicit nil to "", so the key is + # present on the formatted hash either way. Only a non-blank value + # should count as naming a real engine; a blank one must still fall + # back to the sentinel; otherwise the span would skip the collector's + # sanitizer entirely, unlike other_sql, which the sanitizer recognizes. + it "falls back to the SQL sentinel when set_attributes names a blank engine" do + backend = create_backend + backend.start_event + backend.set_attributes("db.system.name" => nil) + backend.finish_event("sql.query", "Q", "SELECT 1", + Appsignal::EventFormatter::SQL_BODY_FORMAT) + + attrs = span_exporter.finished_spans + .find { |s| s.name == "sql.query (Q)" }.attributes + expect(attrs["db.system.name"]).to eq("other_sql") + end + + it "writes appsignal.body for default bodies (no db.* attributes)" do + backend = create_backend + backend.start_event + backend.finish_event("custom", "T", "Body", + Appsignal::EventFormatter::DEFAULT) + + attrs = span_exporter.finished_spans + .find { |s| s.name == "custom (T)" }.attributes + expect(attrs["appsignal.body"]).to eq("Body") + expect(attrs).not_to have_key("db.query.text") + expect(attrs).not_to have_key("db.system.name") + end + + it "omits the body attribute entirely when body is empty or nil" do + backend = create_backend + backend.start_event + backend.finish_event("no.body", "T", nil, + Appsignal::EventFormatter::DEFAULT) + backend.start_event + backend.finish_event("empty.body", "T", "", + Appsignal::EventFormatter::DEFAULT) + + no_body = span_exporter.finished_spans + .find { |s| s.name == "no.body (T)" } + empty_body = span_exporter.finished_spans + .find { |s| s.name == "empty.body (T)" } + expect(no_body.attributes).not_to have_key("appsignal.body") + expect(no_body.attributes).not_to have_key("db.query.text") + expect(empty_body.attributes).not_to have_key("appsignal.body") + expect(empty_body.attributes).not_to have_key("db.query.text") + end + + # The semantic conventions require the datastore on every database span, + # and an event with a SQL body format is a database span whether or not + # there is a query to record with it. + it "names the datastore for a SQL body format without a query" do + backend = create_backend + backend.start_event + backend.finish_event("sql.no_query", "T", nil, + Appsignal::EventFormatter::SQL_BODY_FORMAT) + + attrs = span_exporter.finished_spans + .find { |s| s.name == "sql.no_query (T)" }.attributes + expect(attrs["db.system.name"]).to eq("other_sql") + expect(attrs).not_to have_key("db.query.text") + expect(attrs).not_to have_key("appsignal.body") + end + + it "falls back to the event name as the span name when title is empty or nil" do + backend = create_backend + backend.start_event + backend.finish_event("no.title", nil, "Body", + Appsignal::EventFormatter::DEFAULT) + backend.start_event + backend.finish_event("empty.title", "", "Body", + Appsignal::EventFormatter::DEFAULT) + + no_title = span_exporter.finished_spans + .find { |s| s.name == "no.title" } + empty_title = span_exporter.finished_spans + .find { |s| s.name == "empty.title" } + # With no usable title, the span name is the event name itself. + expect(no_title.name).to eq("no.title") + expect(empty_title.name).to eq("empty.title") + expect(no_title.attributes).not_to have_key("appsignal.title") + expect(empty_title.attributes).not_to have_key("appsignal.title") + end + + it "does not repeat the event name when the title is the same" do + backend = create_backend + backend.start_event + backend.finish_event("query.postgres", "query.postgres", "Body", + Appsignal::EventFormatter::DEFAULT) + + span = span_exporter.finished_spans + .find { |s| s.name == "query.postgres" } + # Some integrations pass the event name as the title too. The span + # name is then just the name, not "query.postgres (query.postgres)". + expect(span).not_to be_nil + end + end + + describe "#complete with unfinished event spans" do + it "drains the event stack without raising, finishing each span with the placeholder name" do + backend = create_backend + backend.start_event + backend.start_event + + expect { backend.complete }.not_to raise_error + expect(backend.instance_variable_get(:@event_stack)).to be_empty + + # Both drained spans keep the placeholder name; root span keeps its own. + names = span_exporter.finished_spans.map(&:name) + expect(names.count("[unfinished transaction event]")).to eq(2) + end + end + end + + describe "#add_breadcrumb" do + def breadcrumb(overrides = {}) + { + :time => 1_700_000_000, + :category => "network", + :action => "GET /", + :message => "ok", + :metadata => { "code" => "200" } + }.merge(overrides) + end + + it "emits an appsignal.breadcrumb event with the breadcrumb's fields and time" do + backend = create_backend + backend.add_breadcrumb(breadcrumb) + backend.complete + + event = finished_span(backend.instance_variable_get(:@span)).events + .find { |e| e.name == "appsignal.breadcrumb" } + expect(event.attributes["category"]).to eq("network") + expect(event.attributes["action"]).to eq("GET /") + expect(event.attributes["message"]).to eq("ok") + expect(JSON.parse(event.attributes["metadata"])).to eq("code" => "200") + expect(event.timestamp).to eq((Time.at(1_700_000_000).to_r * 1_000_000_000).to_i) + end + + it "lands on the open event span when one is open" do + backend = create_backend + backend.start_event + event_span = backend.instance_variable_get(:@event_stack).last.first + backend.add_breadcrumb(breadcrumb) + backend.finish_event("custom", "T", "B", Appsignal::EventFormatter::DEFAULT) + backend.complete + + expect(event_names(finished_span(event_span))).to include("appsignal.breadcrumb") + end + + it "caps at BREADCRUMB_LIMIT, keeping the first ones added" do + backend = create_backend + limit = Appsignal::Transaction::BREADCRUMB_LIMIT + (limit + 5).times { |i| backend.add_breadcrumb(breadcrumb(:action => "act-#{i}")) } + backend.complete + + crumbs = finished_span(backend.instance_variable_get(:@span)).events + .select { |e| e.name == "appsignal.breadcrumb" } + expect(crumbs.size).to eq(limit) + expect(crumbs.first.attributes["action"]).to eq("act-0") + expect(crumbs.last.attributes["action"]).to eq("act-#{limit - 1}") + end + end + + # AppSignal writes to the OpenTelemetry SDK but does not read its global + # current span to decide where its own data goes: errors and breadcrumbs land + # on AppSignal's own span (the open event span, or the root), never on a + # foreign span that happens to be current. Parenting is the one thing that + # does follow the global context, so foreign and AppSignal spans nest under + # each other. + describe "interop with foreign OpenTelemetry spans" do + describe "AppSignal data lands on AppSignal's own spans" do + it "records an error on the root span, not a foreign current span" do + backend = create_backend + foreign = nil + with_foreign_current_span do |f| + foreign = f + backend.set_error("RuntimeError", "boom", ["line 1"], [], false) + end + backend.complete + + expect(event_names(finished_span(backend.instance_variable_get(:@span)))) + .to include("exception") + expect(event_names(finished_span(foreign))).not_to include("exception") + end + + it "records an error on the open event span, not a foreign current span" do + backend = create_backend + backend.start_event + event_span = backend.instance_variable_get(:@event_stack).last.first + foreign = nil + with_foreign_current_span do |f| + foreign = f + backend.set_error("RuntimeError", "boom", ["line 1"], [], false) + end + backend.finish_event("sql.query", "title", "body", Appsignal::EventFormatter::DEFAULT) + backend.complete + + expect(event_names(finished_span(event_span))).to include("exception") + expect(event_names(finished_span(foreign))).not_to include("exception") + expect(event_names(finished_span(backend.instance_variable_get(:@span)))) + .not_to include("exception") + end + + it "sets attributes on the root span, not a foreign current span" do + backend = create_backend + foreign = nil + with_foreign_current_span do |f| + foreign = f + backend.set_attributes("http.request.method" => "GET") + end + backend.complete + + expect(finished_span(backend.instance_variable_get(:@span)) + .attributes["http.request.method"]).to eq("GET") + expect(finished_span(foreign).attributes).to_not have_key("http.request.method") + end + + it "sets attributes on the open event span, not a foreign current span" do + backend = create_backend + backend.start_event + event_span = backend.instance_variable_get(:@event_stack).last.first + foreign = nil + with_foreign_current_span do |f| + foreign = f + backend.set_attributes("db.system.name" => "redis") + end + backend.finish_event("query.redis", "GET foo", nil, Appsignal::EventFormatter::DEFAULT) + backend.complete + + expect(finished_span(event_span).attributes["db.system.name"]).to eq("redis") + expect(finished_span(foreign).attributes).to_not have_key("db.system.name") + end + + it "records a breadcrumb on the root span, not a foreign current span" do + backend = create_backend + foreign = nil + with_foreign_current_span do |f| + foreign = f + backend.add_breadcrumb( + :time => 1_700_000_000, :category => "c", :action => "a", + :message => "m", :metadata => {} + ) + end + backend.complete + + expect(event_names(finished_span(backend.instance_variable_get(:@span)))) + .to include("appsignal.breadcrumb") + expect(event_names(finished_span(foreign))).not_to include("appsignal.breadcrumb") + end + end + + describe "tree shape (parenting follows the global context)" do + it "parents a foreign span under the open AppSignal event span" do + backend = create_backend + backend.start_event + event_span = backend.instance_variable_get(:@event_stack).last.first + + foreign = foreign_tracer.start_span("foreign") + foreign.finish + + backend.finish_event("e", "t", "b", Appsignal::EventFormatter::DEFAULT) + backend.complete + + expect(finished_span(foreign).parent_span_id).to eq(event_span.context.span_id) + end + + it "parents a foreign span under the root span when no event is open" do + backend = create_backend + root = backend.instance_variable_get(:@span) + + foreign = foreign_tracer.start_span("foreign") + foreign.finish + backend.complete + + expect(finished_span(foreign).parent_span_id).to eq(root.context.span_id) + end + + it "parents an AppSignal event span under a foreign current span" do + backend = create_backend + event_span = nil + foreign_id = nil + with_foreign_current_span do |foreign| + foreign_id = foreign.context.span_id + backend.start_event + event_span = backend.instance_variable_get(:@event_stack).last.first + backend.finish_event("e", "t", "b", Appsignal::EventFormatter::DEFAULT) + end + backend.complete + + expect(finished_span(event_span).parent_span_id).to eq(foreign_id) + end + end + + describe "context lifecycle" do + it "unwinds cleanly when a foreign span attaches and detaches inside an event" do + backend = create_backend + root = backend.instance_variable_get(:@span) + backend.start_event + + with_foreign_current_span { nil } + + backend.finish_event("e", "t", "b", Appsignal::EventFormatter::DEFAULT) + expect(::OpenTelemetry::Trace.current_span).to eq(root) + expect(backend.instance_variable_get(:@event_stack)).to be_empty + + backend.complete + expect(::OpenTelemetry::Trace.current_span).to eq(::OpenTelemetry::Trace::Span::INVALID) + # The after hook asserts no OTel context error was recorded. + end + + it "does not defend against a co-resident context leak (characterization)" do + # If another instrumentation attaches a context and never detaches it, + # AppSignal's own detach pops that leaked frame instead of its own and + # OTel signals a DetachError. AppSignal does not try to recover. This + # records the current behaviour; it is not a guarantee. + @expect_otel_errors = true + + backend = create_backend + backend.start_event + leaked = foreign_tracer.start_span("leaky") + ::OpenTelemetry::Context.attach(::OpenTelemetry::Trace.context_with_span(leaked)) + + backend.finish_event("e", "t", "b", Appsignal::EventFormatter::DEFAULT) + + expect(@otel_errors.map(&:first)) + .to include(an_instance_of(::OpenTelemetry::Context::DetachError)) + + backend.complete + leaked.finish + # Clear the deliberately leaked frame so it can't pollute later examples. + ::OpenTelemetry::Context.clear + end + end + end +end diff --git a/spec/lib/appsignal/transaction_spec.rb b/spec/lib/appsignal/transaction_spec.rb index eae3aed2d..a45aaa3cd 100644 --- a/spec/lib/appsignal/transaction_spec.rb +++ b/spec/lib/appsignal/transaction_spec.rb @@ -3,10 +3,21 @@ let(:time) { Time.at(fixed_time) } let(:root_path) { nil } - before do - start_agent(:options => options, :root_path => root_path) + before do |example| + # Only auto-start the agent for non-mode examples. Mode-tagged examples + # (`:agent_mode`/`:collector_mode`) start the agent themselves in their body + # (agent mode via `start_agent(**start_agent_args)`, collector mode via + # `start_collector_agent`) -- the dual-mode start principle -- so starting it + # here too would clobber the collector setup / leave the test in agent mode. + unless example.metadata[:agent_mode] || example.metadata[:collector_mode] + start_agent(:options => options, :root_path => root_path) + end Timecop.freeze(time) end + + # Mode-tagged examples start the agent in their body; expose the same + # `:options`/`:root_path` the automatic start above would have used. + let(:start_agent_args) { { :options => options, :root_path => root_path } } after { Timecop.return } around do |example| keep_transactions do @@ -36,11 +47,56 @@ end end - context "when an explicit extension transaction is passed in the initialiser" do - let(:ext) { "some_ext" } + context "when an explicit backend is passed in the initialiser" do + let(:backend) do + # The transaction reads `params_mapping` from its backend on creation, + # so the stand-in has to answer it. + double( + "backend", + :params_mapping => { + :params => :params, + :request_payload => :params, + :function_parameters => :params + } + ) + end + + it "assigns the backend to the transaction" do + expect(described_class.new("web", :backend => backend).backend).to be(backend) + end + end + + context "with OpenTelemetry attributes" do + it "passes the OpenTelemetry context, kind and relationship to the backend" do + otel_context = "some-otel-context" + expect(Appsignal::Backends.transaction).to receive(:new).with( + kind_of(String), + default_namespace, + :opentelemetry_context => otel_context, + :opentelemetry_scope => nil, + :opentelemetry_kind => :consumer, + :opentelemetry_relationship => :both + ).and_call_original - it "assigns the extension transaction to the transaction" do - expect(described_class.new("web", :ext => ext).ext).to be(ext) + Appsignal::Transaction.create( + default_namespace, + :opentelemetry_context => otel_context, + :opentelemetry_kind => :consumer, + :opentelemetry_relationship => :both + ) + end + + it "defaults the OpenTelemetry attributes to nil" do + expect(Appsignal::Backends.transaction).to receive(:new).with( + kind_of(String), + default_namespace, + :opentelemetry_context => nil, + :opentelemetry_scope => nil, + :opentelemetry_kind => nil, + :opentelemetry_relationship => nil + ).and_call_original + + Appsignal::Transaction.create(default_namespace) end end @@ -99,6 +155,225 @@ expect(current_transaction.transaction_id).to eq("transaction_id_2") end end + + describe "transaction state after create" do + it_in_both_modes do + transaction = create_transaction + expect(transaction.namespace).to eq(Appsignal::Transaction::HTTP_REQUEST) + expect(transaction.transaction_id).to be_a(String) + expect(transaction.transaction_id).not_to be_empty + end + end + + describe "OpenTelemetry root span" do + it "defaults to a SERVER span named after the namespace", :collector_mode do + start_collector_agent + create_transaction(Appsignal::Transaction::HTTP_REQUEST) + Appsignal::Transaction.complete_current! + + expect(span_exporter.finished_spans.size).to eq(1) + span = span_exporter.finished_spans.first + expect(span.kind).to eq(:server) + expect(span.name).to eq("appsignal.transaction http_request") + end + + it "uses the given opentelemetry_kind", :collector_mode do + start_collector_agent + Appsignal::Transaction.create( + Appsignal::Transaction::HTTP_REQUEST, + :opentelemetry_kind => :consumer + ) + Appsignal::Transaction.complete_current! + + expect(span_exporter.finished_spans.first.kind).to eq(:consumer) + end + end + + describe "OpenTelemetry current context" do + it "in collector mode", :collector_mode do + start_collector_agent + expect(::OpenTelemetry::Trace.current_span).to eq(::OpenTelemetry::Trace::Span::INVALID) + + create_transaction(Appsignal::Transaction::HTTP_REQUEST) + + expect(::OpenTelemetry::Trace.current_span).not_to eq(::OpenTelemetry::Trace::Span::INVALID) + expect(::OpenTelemetry::Trace.current_span.context.trace_id).not_to be_nil + + Appsignal::Transaction.complete_current! + + expect(::OpenTelemetry::Trace.current_span).to eq(::OpenTelemetry::Trace::Span::INVALID) + end + end + + describe "OpenTelemetry interop with a foreign current span" do + it "keeps errors and breadcrumbs on AppSignal's span", :collector_mode do + start_collector_agent + transaction = create_transaction(Appsignal::Transaction::HTTP_REQUEST) + + # A foreign instrumentation's span becomes current inside an AppSignal + # event. AppSignal's error and breadcrumb should still land on its own + # event span, not on the foreign span. + transaction.start_event + foreign = ::OpenTelemetry.tracer_provider.tracer("foreign").start_span("foreign-call") + foreign_token = + ::OpenTelemetry::Context.attach(::OpenTelemetry::Trace.context_with_span(foreign)) + + transaction.add_error(ExampleStandardError.new("boom")) + transaction.add_breadcrumb("network", "GET /", "ok", { "code" => "200" }) + + ::OpenTelemetry::Context.detach(foreign_token) + foreign.finish + transaction.finish_event("sql.query", "Query", "SELECT 1", + Appsignal::EventFormatter::DEFAULT) + Appsignal::Transaction.complete_current! + + event_span = event_spans.find { |s| s.name == "sql.query (Query)" } + foreign_span = span_exporter.finished_spans.find { |s| s.name == "foreign-call" } + + expect(event_span.events.map(&:name)).to include("exception", "appsignal.breadcrumb") + expect(Array(foreign_span.events).map(&:name)).to be_empty + end + + it "runs an error block on the span current when the error was added", :collector_mode do + start_collector_agent + transaction = create_transaction(Appsignal::Transaction::HTTP_REQUEST) + + # The block runs when the error is added, while the event span is still + # current -- not deferred to completion, when the root span would be + # current. A breadcrumb added from the block lands on the event span. + transaction.start_event + transaction.add_error(ExampleStandardError.new("boom")) do |t| + t.add_breadcrumb("network", "GET /", "ok", { "code" => "200" }) + end + transaction.finish_event("sql.query", "Query", "SELECT 1", + Appsignal::EventFormatter::DEFAULT) + Appsignal::Transaction.complete_current! + + event_span = event_spans.find { |s| s.name == "sql.query (Query)" } + expect(event_span.events.map(&:name)).to include("exception", "appsignal.breadcrumb") + expect(Array(root_span.events).map(&:name)).to_not include("appsignal.breadcrumb") + end + end + end + + # A block handed to AppSignal (an error block, or an after_create/ + # before_complete hook) is user code that can raise. Because these blocks + # can run far from where they were defined -- an error block runs at + # completion in agent mode -- a failure must be logged and swallowed, never + # raised into whatever drove creation or completion. Otherwise, in collector + # mode, the transaction's OpenTelemetry context is left attached to the + # fiber and leaks into the next request on that thread. + describe "when a block handed to the transaction raises" do + describe "an error block" do + it_in_both_modes "completes the transaction and logs, without raising" do + transaction = create_transaction(Appsignal::Transaction::HTTP_REQUEST) + + logs = capture_logs do + expect do + transaction.add_error(ExampleStandardError.new("boom")) do + raise ExampleStandardError, "error block boom" + end + Appsignal::Transaction.complete_current! + end.to_not raise_error + end + + expect(transaction).to be_completed + expect(logs).to contains_log(:error, + /Error in the error block, defined at .+error block boom/) + end + + it "names the reporting helper in the log when a source is given" do + transaction = create_transaction(Appsignal::Transaction::HTTP_REQUEST) + + logs = capture_logs do + transaction.add_error( + ExampleStandardError.new("boom"), + :source => "Appsignal.send_error" + ) { raise ExampleStandardError, "error block boom" } + Appsignal::Transaction.complete_current! + end + + expect(logs).to contains_log( + :error, + /Error in the block passed to Appsignal\.send_error, defined at .+error block boom/ + ) + end + + it "detaches the OpenTelemetry context", :collector_mode do + start_collector_agent + transaction = create_transaction(Appsignal::Transaction::HTTP_REQUEST) + + expect do + transaction.add_error(ExampleStandardError.new("boom")) do + raise ExampleStandardError, "error block boom" + end + Appsignal::Transaction.complete_current! + end.to_not raise_error + + expect(::OpenTelemetry::Trace.current_span) + .to eq(::OpenTelemetry::Trace::Span::INVALID) + end + end + + describe "a before_complete hook" do + it_in_both_modes "completes the transaction and logs, without raising" do + Appsignal::Transaction.before_complete do |_transaction, _error| + raise ExampleStandardError, "before_complete boom" + end + transaction = create_transaction(Appsignal::Transaction::HTTP_REQUEST) + + logs = capture_logs do + expect { Appsignal::Transaction.complete_current! }.to_not raise_error + end + + expect(transaction).to be_completed + expect(logs).to contains_log( + :error, /Error in the before_complete hook, defined at .+before_complete boom/ + ) + end + + it "detaches the OpenTelemetry context", :collector_mode do + start_collector_agent + Appsignal::Transaction.before_complete do |_transaction, _error| + raise ExampleStandardError, "before_complete boom" + end + create_transaction(Appsignal::Transaction::HTTP_REQUEST) + + expect { Appsignal::Transaction.complete_current! }.to_not raise_error + expect(::OpenTelemetry::Trace.current_span) + .to eq(::OpenTelemetry::Trace::Span::INVALID) + end + end + + describe "an after_create hook" do + it_in_both_modes "creates the transaction and logs, without raising" do + Appsignal::Transaction.after_create do |_transaction| + raise ExampleStandardError, "after_create boom" + end + + logs = capture_logs do + expect { create_transaction(Appsignal::Transaction::HTTP_REQUEST) } + .to_not raise_error + end + + expect(logs).to contains_log( + :error, /Error in the after_create hook, defined at .+after_create boom/ + ) + end + + it "detaches the OpenTelemetry context on the next completion", :collector_mode do + start_collector_agent + Appsignal::Transaction.after_create do |_transaction| + raise ExampleStandardError, "after_create boom" + end + + create_transaction(Appsignal::Transaction::HTTP_REQUEST) + Appsignal::Transaction.complete_current! + + expect(::OpenTelemetry::Trace.current_span) + .to eq(::OpenTelemetry::Trace::Span::INVALID) + end + end end describe ".current" do @@ -183,6 +458,16 @@ end.to_not(change { Thread.current[:appsignal_transaction] }) end end + + describe "current transaction after complete_current!" do + it_in_both_modes do + create_transaction(Appsignal::Transaction::HTTP_REQUEST) + Appsignal::Transaction.complete_current! + + expect(Appsignal::Transaction.current).to be_a(Appsignal::Transaction::NilTransaction) + expect(Appsignal::Transaction.current?).to be(false) + end + end end describe "#complete" do @@ -204,14 +489,10 @@ end context "when a transaction is marked as discarded" do - it "does not complete the transaction" do + it "marks the transaction as discarded" do expect do transaction.discard! end.to change { transaction.discarded? }.from(false).to(true) - - transaction.complete - - expect(transaction).to_not be_completed end it "logs a debug message" do @@ -223,17 +504,72 @@ "Skipping transaction 'mock_transaction_id' because it was manually discarded." end + describe "completing a discarded transaction" do + def perform + transaction.discard! + transaction.complete + end + + it "in agent mode", :agent_mode do + start_agent + perform + + # Nothing is reported: the transaction is dropped, not completed. + expect(transaction).to_not be_completed + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + + # The root span is still finished and exported, but flagged so the + # collector ignores the whole subtrace. + expect(root_span.attributes["appsignal.ignore_subtrace"]).to be(true) + # The discarded transaction's context is detached -- it does not leak + # as the thread's current OTel span. + expect(::OpenTelemetry::Trace.current_span) + .to eq(::OpenTelemetry::Trace::Span::INVALID) + end + end + context "when a discarded transaction is restored" do - before { transaction.discard! } + it "unmarks the transaction as discarded" do + transaction.discard! - it "completes the transaction" do expect do transaction.restore! end.to change { transaction.discarded? }.from(true).to(false) + end - transaction.complete + describe "completing a restored transaction" do + # Set an action so this isolates the restore/discard behaviour: an + # actionless transaction would be flagged `ignore_subtrace` on its own + # (see "#complete" / actionless handling), which is unrelated to + # whether it was restored. + def perform + transaction.set_action("SomeController#action") + transaction.discard! + transaction.restore! + transaction.complete + end + + it "in agent mode", :agent_mode do + start_agent + perform - expect(transaction).to be_completed + expect(transaction).to be_completed + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + + # The transaction is reported as normal: the root span is exported + # without the ignore flag, so the collector keeps the subtrace. + expect(transaction).to be_completed + expect(root_span).not_to be_nil + expect(root_span.attributes).not_to have_key("appsignal.ignore_subtrace") + end end end end @@ -364,7 +700,7 @@ it "the duplicate transaction has a different extension transaction than the original" do original_transaction, duplicate_transaction = created_transactions - expect(original_transaction.ext).to_not eq(duplicate_transaction.ext) + expect(original_transaction.backend).to_not eq(duplicate_transaction.backend) end it "marks transaction as duplicate on the duplicate transaction" do @@ -537,6 +873,26 @@ ) end end + + describe "completed? after #complete" do + it_in_both_modes do + transaction = create_transaction(Appsignal::Transaction::HTTP_REQUEST) + transaction.complete + + expect(transaction.completed?).to be(true) + end + end + + describe "OpenTelemetry span emission" do + it "emits no span until complete is called", :collector_mode do + start_collector_agent + create_transaction(Appsignal::Transaction::HTTP_REQUEST) + expect(span_exporter.finished_spans).to be_empty + + Appsignal::Transaction.complete_current! + expect(span_exporter.finished_spans.size).to eq(1) + end + end end context "pausing" do @@ -599,7 +955,7 @@ let(:transaction) { new_transaction } it "loads the AppSignal extension" do - expect(transaction.ext).to_not be_nil + expect(transaction.backend).to_not be_nil end context "when extension is not loaded", :extension_installation_failure do @@ -608,7 +964,7 @@ end it "does not error on missing extension method calls" do - expect(transaction.ext).to be_kind_of(Appsignal::Extension::MockTransaction) + expect(transaction.backend).to be_kind_of(Appsignal::Transaction::ExtensionBackend) transaction.start_event transaction.finish_event( "name", @@ -726,8 +1082,12 @@ context "when enqueue instrumentation is disabled" do let(:options) { { :enable_job_enqueue_instrumentation => false } } - it "reports enqueue events as suppressed" do - expect(transaction.job_enqueue_events_suppressed?).to be(true) + # The config option is not part of this question. Each enqueue + # integration checks it separately, because disabling enqueue + # instrumentation also disables trace context propagation, while + # suppression by an outer integration keeps it. + it "does not report enqueue events as suppressed" do + expect(transaction.job_enqueue_events_suppressed?).to be(false) end end end @@ -739,615 +1099,1769 @@ expect(transaction.method(:add_params)).to eq(transaction.method(:set_params)) end - it "adds the params to the transaction" do - params = { "key" => "value" } - transaction.add_params(params) - - transaction._sample - expect(transaction).to include_params(params) - end + describe "adding the params to the transaction" do + def perform + transaction.add_params("key" => "value") + end - it "merges the params on the transaction" do - transaction.add_params("abc" => "value") - transaction.add_params("def" => "value") - transaction.add_params { { "xyz" => "value" } } + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + transaction._sample - transaction._sample - expect(transaction).to include_params( - "abc" => "value", - "def" => "value", - "xyz" => "value" - ) - end + expect(transaction).to include_params("key" => "value") + end - it "adds the params to the transaction with a block" do - params = { "key" => "value" } - transaction.add_params { params } + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete - transaction._sample - expect(transaction).to include_params(params) + expect(JSON.parse(root_span.attributes["appsignal.request.payload"])) + .to eq("key" => "value") + end end - it "adds the params block value when both an argument and block are given" do - arg_params = { "argument" => "value" } - block_params = { "block" => "value" } - transaction.add_params(arg_params) { block_params } - - transaction._sample - expect(transaction).to include_params(block_params) - end + describe "merging the params on the transaction" do + def perform + transaction.add_params("abc" => "value") + transaction.add_params("def" => "value") + transaction.add_params { { "xyz" => "value" } } + end - it "logs an error if an error occurred storing the params" do - transaction.add_params { raise "uh oh" } + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + transaction._sample - logs = capture_logs { transaction._sample } - expect(logs).to contains_log( - :error, - "Exception while fetching params: RuntimeError: uh oh" - ) - end + expect(transaction).to include_params( + "abc" => "value", + "def" => "value", + "xyz" => "value" + ) + end - it "does not update the params on the transaction if the given value is nil" do - params = { "key" => "value" } - transaction.add_params(params) - transaction.add_params(nil) + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete - transaction._sample - expect(transaction).to include_params(params) + expect(JSON.parse(root_span.attributes["appsignal.request.payload"])).to eq( + "abc" => "value", + "def" => "value", + "xyz" => "value" + ) + end end - context "with AppSignal filtering" do - let(:options) { { :filter_parameters => %w[foo] } } - - it "returns sanitized custom params" do - transaction.add_params("foo" => "value", "baz" => "bat") + describe "adding the params to the transaction with a block" do + def perform + transaction.add_params { { "key" => "value" } } + end + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform transaction._sample - expect(transaction).to include_params("foo" => "[FILTERED]", "baz" => "bat") + + expect(transaction).to include_params("key" => "value") end - end - end - describe "#add_params_if_nil" do - let(:transaction) { new_transaction } + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete - it "has a #set_params_if_nil alias" do - expect(transaction.method(:add_params_if_nil)).to eq(transaction.method(:set_params_if_nil)) + expect(JSON.parse(root_span.attributes["appsignal.request.payload"])) + .to eq("key" => "value") + end end - context "when the params are not set" do - it "adds the params to the transaction" do - params = { "key" => "value" } - transaction.add_params_if_nil(params) + describe "adding the params block value when both an argument and block are given" do + def perform + transaction.add_params("argument" => "value") { { "block" => "value" } } + end + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform transaction._sample - expect(transaction).to include_params(params) + + expect(transaction).to include_params("block" => "value") end - it "adds the params to the transaction with a block" do - params = { "key" => "value" } - transaction.add_params_if_nil { params } + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + expect(JSON.parse(root_span.attributes["appsignal.request.payload"])) + .to eq("block" => "value") + end + end - transaction._sample - expect(transaction).to include_params(params) + describe "when an error occurs storing the params" do + def perform + transaction.add_params { raise "uh oh" } end - it "adds the params block value when both an argument and block are given" do - arg_params = { "argument" => "value" } - block_params = { "block" => "value" } - transaction.add_params_if_nil(arg_params) { block_params } + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform - transaction._sample - expect(transaction).to include_params(block_params) + logs = capture_logs { transaction._sample } + expect(logs).to contains_log( + :error, + "Exception while fetching params (params): RuntimeError: uh oh" + ) end - it "does not update the params on the transaction if the given value is nil" do - params = { "key" => "value" } - transaction.add_params(params) - transaction.add_params_if_nil(nil) + it "in collector mode", :collector_mode do + start_collector_agent + perform - transaction._sample - expect(transaction).to include_params(params) + logs = capture_logs { transaction.complete } + expect(logs).to contains_log( + :error, + "Exception while fetching params (request_payload): RuntimeError: uh oh" + ) + expect(root_span.attributes).to_not have_key("appsignal.request.payload") end end - context "when the params are set" do - it "does not update the params on the transaction" do - preset_params = { "other" => "params" } - params = { "key" => "value" } - transaction.add_params(preset_params) - transaction.add_params_if_nil(params) + describe "when the given params value is nil" do + def perform + transaction.add_params("key" => "value") + transaction.add_params(nil) + end + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform transaction._sample - expect(transaction).to include_params(preset_params) + + expect(transaction).to include_params("key" => "value") end - it "does not update the params with a block on the transaction" do - preset_params = { "other" => "params" } - params = { "key" => "value" } - transaction.add_params(preset_params) - transaction.add_params_if_nil { params } + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete - transaction._sample - expect(transaction).to include_params(preset_params) + expect(JSON.parse(root_span.attributes["appsignal.request.payload"])) + .to eq("key" => "value") end end - context "when the params were set as an empty value" do - it "does not set params on the transaction" do - transaction.add_params("key1" => "value") - transaction.set_empty_params! - transaction.add_params_if_nil("key2" => "value") + context "with AppSignal filtering" do + let(:options) { { :filter_parameters => %w[foo] } } - transaction._sample - expect(transaction).to_not include_params + describe "sanitizing the params" do + def perform + transaction.add_params("foo" => "value", "baz" => "bat") + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + transaction._sample + + expect(transaction).to include_params("foo" => "[FILTERED]", "baz" => "bat") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + expect(JSON.parse(root_span.attributes["appsignal.request.payload"])) + .to eq("foo" => "[FILTERED]", "baz" => "bat") + end end end end - describe "#add_session_data" do + describe "#add_params_if_nil" do let(:transaction) { new_transaction } - it "has a #set_session_data alias" do - expect(transaction.method(:add_session_data)).to eq(transaction.method(:set_session_data)) + it "has a #set_params_if_nil alias" do + expect(transaction.method(:add_params_if_nil)).to eq(transaction.method(:set_params_if_nil)) end - it "adds the session data to the transaction" do - data = { "key" => "value" } - transaction.add_session_data(data) + context "when the params are not set" do + describe "adding the params to the transaction" do + def perform + transaction.add_params_if_nil("key" => "value") + end - transaction._sample - expect(transaction).to include_session_data(data) - end + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + transaction._sample - it "merges the session data on the transaction" do - transaction.add_session_data("abc" => "value") - transaction.add_session_data("def" => "value") - transaction.add_session_data { { "xyz" => "value" } } + expect(transaction).to include_params("key" => "value") + end - transaction._sample - expect(transaction).to include_session_data( - "abc" => "value", - "def" => "value", - "xyz" => "value" - ) - end + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete - it "adds the session data to the transaction with a block" do - data = { "key" => "value" } - transaction.add_session_data { data } + expect(JSON.parse(root_span.attributes["appsignal.request.payload"])) + .to eq("key" => "value") + end + end - transaction._sample - expect(transaction).to include_session_data(data) - end + describe "adding the params to the transaction with a block" do + def perform + transaction.add_params_if_nil { { "key" => "value" } } + end - it "adds the session data block value when both an argument and block are given" do - arg_data = { "argument" => "value" } - block_data = { "block" => "value" } - transaction.add_session_data(arg_data) { block_data } + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + transaction._sample - transaction._sample - expect(transaction).to include_session_data(block_data) - end + expect(transaction).to include_params("key" => "value") + end - it "adds certain Ruby objects as Strings" do - transaction.add_session_data("time" => Time.utc(2024, 9, 12, 13, 14, 15)) - transaction.add_session_data("date" => Date.new(2024, 9, 11)) + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete - transaction._sample - expect(transaction).to include_session_data( - "time" => "#", - "date" => "#" - ) - end + expect(JSON.parse(root_span.attributes["appsignal.request.payload"])) + .to eq("key" => "value") + end + end - it "logs an error if an error occurred storing the session data" do - transaction.add_session_data { raise "uh oh" } + describe "adding the params block value when both an argument and block are given" do + def perform + transaction.add_params_if_nil("argument" => "value") { { "block" => "value" } } + end - logs = capture_logs { transaction._sample } - expect(logs).to contains_log( - :error, - "Exception while fetching session data: RuntimeError: uh oh" - ) - end + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + transaction._sample - it "does not update the session data on the transaction if the given value is nil" do - data = { "key" => "value" } - transaction.add_session_data(data) - transaction.add_session_data(nil) + expect(transaction).to include_params("block" => "value") + end - transaction._sample - expect(transaction).to include_session_data(data) - end + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete - context "with filter_session_data" do - let(:options) { { :filter_session_data => ["filtered_key"] } } + expect(JSON.parse(root_span.attributes["appsignal.request.payload"])) + .to eq("block" => "value") + end + end - it "does not include filtered out session data" do - transaction.add_session_data("data" => "value1", "filtered_key" => "filtered_value") + describe "when the given value is nil" do + def perform + transaction.add_params("key" => "value") + transaction.add_params_if_nil(nil) + end - transaction._sample - expect(transaction).to include_session_data("data" => "value1") + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + transaction._sample + + expect(transaction).to include_params("key" => "value") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + expect(JSON.parse(root_span.attributes["appsignal.request.payload"])) + .to eq("key" => "value") + end + end + end + + context "when the params are set" do + describe "not updating the params on the transaction" do + def perform + transaction.add_params("other" => "params") + transaction.add_params_if_nil("key" => "value") + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + transaction._sample + + expect(transaction).to include_params("other" => "params") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + expect(JSON.parse(root_span.attributes["appsignal.request.payload"])) + .to eq("other" => "params") + end + end + + describe "not updating the params with a block on the transaction" do + def perform + transaction.add_params("other" => "params") + transaction.add_params_if_nil { { "key" => "value" } } + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + transaction._sample + + expect(transaction).to include_params("other" => "params") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + expect(JSON.parse(root_span.attributes["appsignal.request.payload"])) + .to eq("other" => "params") + end + end + end + + context "when the params were set as an empty value" do + describe "not setting params on the transaction" do + def perform + transaction.add_params("key1" => "value") + transaction.set_empty_params! + transaction.add_params_if_nil("key2" => "value") + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + transaction._sample + + expect(transaction).to_not include_params + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + expect(root_span.attributes).to_not have_key("appsignal.request.payload") + end + end + end + end + + describe "#add_request_payload" do + let(:transaction) { new_transaction } + + describe "setting the request payload on the transaction" do + def perform + transaction.add_request_payload("key" => "value") + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + transaction._sample + + expect(transaction).to include_params("key" => "value") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + expect(JSON.parse(root_span.attributes["appsignal.request.payload"])) + .to eq("key" => "value") + expect(root_span.attributes).to_not have_key("appsignal.function.parameters") + end + end + + describe "merging and giving the block precedence" do + def perform + transaction.add_request_payload("abc" => "value") + transaction.add_request_payload("def" => "value") + transaction.add_request_payload { { "xyz" => "value" } } + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + transaction._sample + + expect(transaction).to include_params( + "abc" => "value", "def" => "value", "xyz" => "value" + ) + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + expect(JSON.parse(root_span.attributes["appsignal.request.payload"])).to eq( + "abc" => "value", "def" => "value", "xyz" => "value" + ) + end + end + + describe "#add_request_payload_if_nil does not override existing params" do + def perform + transaction.add_request_payload("original" => "value") + transaction.add_request_payload_if_nil("other" => "value") + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + transaction._sample + + expect(transaction).to include_params("original" => "value") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + expect(JSON.parse(root_span.attributes["appsignal.request.payload"])) + .to eq("original" => "value") + end + end + end + + describe "#add_function_parameters" do + let(:transaction) { new_transaction } + + describe "setting the function parameters on the transaction" do + def perform + transaction.add_function_parameters("key" => "value") + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + transaction._sample + + expect(transaction).to include_params("key" => "value") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + expect(JSON.parse(root_span.attributes["appsignal.function.parameters"])) + .to eq("key" => "value") + expect(root_span.attributes).to_not have_key("appsignal.request.payload") + end + end + + describe "#add_function_parameters_if_nil does not override existing params" do + def perform + transaction.add_function_parameters("original" => "value") + transaction.add_function_parameters_if_nil("other" => "value") + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + transaction._sample + + expect(transaction).to include_params("original" => "value") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + expect(JSON.parse(root_span.attributes["appsignal.function.parameters"])) + .to eq("original" => "value") + end + end + + describe "#set_empty_params! also suppresses function parameters set later" do + def perform + transaction.set_empty_params! + transaction.add_function_parameters_if_nil("key" => "value") + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + transaction._sample + + expect(transaction).to_not include_params + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + expect(root_span.attributes).to_not have_key("appsignal.function.parameters") + end + end + end + + describe "#add_query_parameters" do + let(:transaction) { new_transaction } + + describe "setting the query parameters on the transaction" do + def perform + transaction.add_query_parameters("key" => "value") + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + transaction._sample + + expect(transaction).to include_params("key" => "value") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + expect(JSON.parse(root_span.attributes["appsignal.request.query_parameters"])) + .to eq("key" => "value") + expect(root_span.attributes).to_not have_key("appsignal.request.payload") + expect(root_span.attributes).to_not have_key("appsignal.function.parameters") + end + end + + describe "merging and giving the block precedence" do + def perform + transaction.add_query_parameters("abc" => "value") + transaction.add_query_parameters("def" => "value") + transaction.add_query_parameters { { "xyz" => "value" } } + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + transaction._sample + + expect(transaction).to include_params( + "abc" => "value", "def" => "value", "xyz" => "value" + ) + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + expect(JSON.parse(root_span.attributes["appsignal.request.query_parameters"])).to eq( + "abc" => "value", "def" => "value", "xyz" => "value" + ) + end + end + + describe "#add_query_parameters_if_nil does not override existing params" do + def perform + transaction.add_query_parameters("original" => "value") + transaction.add_query_parameters_if_nil("other" => "value") + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + transaction._sample + + expect(transaction).to include_params("original" => "value") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + expect(JSON.parse(root_span.attributes["appsignal.request.query_parameters"])) + .to eq("original" => "value") + end + end + end + + describe "#add_opentelemetry_attributes" do + let(:transaction) { new_transaction } + + describe "adding attributes when no event is open" do + def perform + transaction.add_opentelemetry_attributes("http.request.method" => "GET") + end + + # Attributes describe an OpenTelemetry span, and agent mode has none, so + # they are dropped rather than stored as some other kind of data. + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + transaction._sample + + expect(transaction).to_not include_tags("http.request.method" => "GET") + expect(transaction).to_not include_metadata("http.request.method" => "GET") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + expect(root_span.attributes["http.request.method"]).to eq("GET") + end + end + + describe "adding attributes while an event is open" do + def perform + set_current_transaction(transaction) + Appsignal.instrument("query.redis") do + transaction.add_opentelemetry_attributes("db.system.name" => "redis") + end + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + transaction._sample + + expect(transaction).to_not include_tags("db.system.name" => "redis") + expect(transaction).to_not include_metadata("db.system.name" => "redis") + end + + # Which span an attribute lands on decides how the trace timeline reads + # it, so an event's attributes must not spill onto the transaction. + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + expect(event_span_for("query.redis").attributes["db.system.name"]).to eq("redis") + expect(root_span.attributes).to_not have_key("db.system.name") + end + end + + describe "adding attributes after an event has finished" do + def perform + set_current_transaction(transaction) + Appsignal.instrument("query.redis") { nil } + transaction.add_opentelemetry_attributes("http.request.method" => "GET") + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + transaction._sample + + expect(transaction).to_not include_tags("http.request.method" => "GET") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + expect(root_span.attributes["http.request.method"]).to eq("GET") + expect(event_span_for("query.redis").attributes) + .to_not have_key("http.request.method") + end + end + + describe "when no attributes are given" do + it_in_both_modes "does nothing" do + expect { transaction.add_opentelemetry_attributes }.to_not raise_error + expect { transaction.add_opentelemetry_attributes(nil) }.to_not raise_error + end + end + end + + describe "#add_params deprecation" do + let(:transaction) { new_transaction } + + before { Appsignal::Transaction.reset_params_deprecation_warning! } + + it "does not warn in agent mode", :agent_mode do + start_agent(**start_agent_args) + + expect(Appsignal::Utils::StdoutAndLoggerMessage).to_not receive(:warning) + + transaction.add_params("key" => "value") + end + + it "warns once in collector mode", :collector_mode do + start_collector_agent + + expect(Appsignal::Utils::StdoutAndLoggerMessage).to receive(:warning) + .with(a_string_including("add_request_payload", "add_function_parameters")) + .once + + transaction.add_params("key" => "value") + transaction.add_params("key2" => "value") + end + end + + describe "#add_session_data" do + let(:transaction) { new_transaction } + + it "has a #set_session_data alias" do + expect(transaction.method(:add_session_data)).to eq(transaction.method(:set_session_data)) + end + + describe "adding the session data to the transaction" do + def perform + transaction.add_session_data("key" => "value") + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + transaction._sample + + expect(transaction).to include_session_data("key" => "value") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + expect(JSON.parse(root_span.attributes["appsignal.request.session_data"])) + .to eq("key" => "value") + end + end + + describe "merging the session data on the transaction" do + def perform + transaction.add_session_data("abc" => "value") + transaction.add_session_data("def" => "value") + transaction.add_session_data { { "xyz" => "value" } } + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + transaction._sample + + expect(transaction).to include_session_data( + "abc" => "value", + "def" => "value", + "xyz" => "value" + ) + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + expect(JSON.parse(root_span.attributes["appsignal.request.session_data"])).to eq( + "abc" => "value", + "def" => "value", + "xyz" => "value" + ) + end + end + + describe "adding the session data to the transaction with a block" do + def perform + transaction.add_session_data { { "key" => "value" } } + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + transaction._sample + + expect(transaction).to include_session_data("key" => "value") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + expect(JSON.parse(root_span.attributes["appsignal.request.session_data"])) + .to eq("key" => "value") + end + end + + describe "adding the session data block when an argument and block are given" do + def perform + transaction.add_session_data("argument" => "value") { { "block" => "value" } } + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + transaction._sample + + expect(transaction).to include_session_data("block" => "value") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + expect(JSON.parse(root_span.attributes["appsignal.request.session_data"])) + .to eq("block" => "value") + end + end + + describe "adding certain Ruby objects as Strings" do + def perform + transaction.add_session_data("time" => Time.utc(2024, 9, 12, 13, 14, 15)) + transaction.add_session_data("date" => Date.new(2024, 9, 11)) + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + transaction._sample + + expect(transaction).to include_session_data( + "time" => "#", + "date" => "#" + ) + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + expect(JSON.parse(root_span.attributes["appsignal.request.session_data"])).to eq( + "time" => "#", + "date" => "#" + ) + end + end + + describe "when an error occurs storing the session data" do + def perform + transaction.add_session_data { raise "uh oh" } + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + + logs = capture_logs { transaction._sample } + expect(logs).to contains_log( + :error, + "Exception while fetching session data: RuntimeError: uh oh" + ) + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + + logs = capture_logs { transaction.complete } + expect(logs).to contains_log( + :error, + "Exception while fetching session data: RuntimeError: uh oh" + ) + expect(root_span.attributes).to_not have_key("appsignal.request.session_data") + end + end + + describe "when the given session data value is nil" do + def perform + transaction.add_session_data("key" => "value") + transaction.add_session_data(nil) + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + transaction._sample + + expect(transaction).to include_session_data("key" => "value") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + expect(JSON.parse(root_span.attributes["appsignal.request.session_data"])) + .to eq("key" => "value") + end + end + + context "with filter_session_data" do + let(:options) { { :filter_session_data => ["filtered_key"] } } + + describe "filtering out session data" do + def perform + transaction.add_session_data("data" => "value1", "filtered_key" => "filtered_value") + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + transaction._sample + + expect(transaction).to include_session_data("data" => "value1") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + # Filtering redacts the value (mode-independent, applied before the + # backend) rather than dropping the key. + expect(JSON.parse(root_span.attributes["appsignal.request.session_data"])) + .to eq("data" => "value1", "filtered_key" => "[FILTERED]") + end + end + end + end + + describe "#add_session_data_if_nil" do + let(:transaction) { new_transaction } + + context "when the session data is not set" do + describe "setting the session data on the transaction" do + def perform + transaction.add_session_data_if_nil("key" => "value") + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + transaction._sample + + expect(transaction).to include_session_data("key" => "value") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + expect(JSON.parse(root_span.attributes["appsignal.request.session_data"])) + .to eq("key" => "value") + end + end + + describe "updating the session data on the transaction with a block" do + def perform + transaction.add_session_data_if_nil { { "key" => "value" } } + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + transaction._sample + + expect(transaction).to include_session_data("key" => "value") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + expect(JSON.parse(root_span.attributes["appsignal.request.session_data"])) + .to eq("key" => "value") + end + end + + describe "updating with the session data block when an argument and block are given" do + def perform + transaction.add_session_data_if_nil("argument" => "value") { { "block" => "value" } } + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + transaction._sample + + expect(transaction).to include_session_data("block" => "value") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + expect(JSON.parse(root_span.attributes["appsignal.request.session_data"])) + .to eq("block" => "value") + end + end + + describe "when the given value is nil" do + def perform + transaction.add_session_data("key" => "value") + transaction.add_session_data_if_nil(nil) + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + transaction._sample + + expect(transaction).to include_session_data("key" => "value") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + expect(JSON.parse(root_span.attributes["appsignal.request.session_data"])) + .to eq("key" => "value") + end + end + end + + context "when the session data are set" do + describe "not updating the session data on the transaction" do + def perform + transaction.add_session_data("other" => "data") + transaction.add_session_data_if_nil("key" => "value") + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + transaction._sample + + expect(transaction).to include_session_data("other" => "data") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + expect(JSON.parse(root_span.attributes["appsignal.request.session_data"])) + .to eq("other" => "data") + end + end + + describe "not updating the session data with a block on the transaction" do + def perform + transaction.add_session_data("other" => "data") + transaction.add_session_data_if_nil { { "key" => "value" } } + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + transaction._sample + + expect(transaction).to include_session_data("other" => "data") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + expect(JSON.parse(root_span.attributes["appsignal.request.session_data"])) + .to eq("other" => "data") + end + end + end + end + + describe "#add_headers" do + let(:transaction) { new_transaction } + + it "has a #set_headers alias" do + expect(transaction.method(:add_headers)).to eq(transaction.method(:set_headers)) + end + + describe "adding the headers to the transaction" do + def perform + # A true header (kept, normalized in collector mode) and a CGI var + # (kept in agent mode, dropped in collector mode). + transaction.add_headers("HTTP_ACCEPT" => "text/html", "PATH_INFO" => "/path") + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + transaction._sample + + expect(transaction).to include_environment( + "HTTP_ACCEPT" => "text/html", + "PATH_INFO" => "/path" + ) + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + # True headers normalized to the OTel convention; non-header CGI vars + # dropped. + expect(root_span.attributes["http.request.header.accept"]).to eq("text/html") + expect(root_span.attributes).to_not have_key("http.request.header.path-info") + end + end + + describe "merging the headers on the transaction" do + def perform + transaction.add_headers("HTTP_ACCEPT" => "text/html") + transaction.add_headers("HTTP_RANGE" => "bytes=0-") + transaction.add_headers { { "HTTP_CACHE_CONTROL" => "no-cache" } } + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + transaction._sample + + expect(transaction).to include_environment( + "HTTP_ACCEPT" => "text/html", + "HTTP_RANGE" => "bytes=0-", + "HTTP_CACHE_CONTROL" => "no-cache" + ) + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + expect(root_span.attributes["http.request.header.accept"]).to eq("text/html") + expect(root_span.attributes["http.request.header.range"]).to eq("bytes=0-") + expect(root_span.attributes["http.request.header.cache-control"]).to eq("no-cache") + end + end + + describe "adding the headers to the transaction with a block" do + def perform + transaction.add_headers { { "HTTP_ACCEPT" => "text/html" } } + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + transaction._sample + + expect(transaction).to include_environment("HTTP_ACCEPT" => "text/html") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + expect(root_span.attributes["http.request.header.accept"]).to eq("text/html") + end + end + + describe "adding the headers block value when both an argument and block are given" do + def perform + transaction.add_headers("HTTP_ACCEPT" => "arg") { { "HTTP_ACCEPT" => "block" } } + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + transaction._sample + + expect(transaction).to include_environment("HTTP_ACCEPT" => "block") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + expect(root_span.attributes["http.request.header.accept"]).to eq("block") + end + end + + describe "when an error occurs storing the headers" do + def perform + transaction.add_headers { raise "uh oh" } + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + + logs = capture_logs { transaction._sample } + expect(logs).to contains_log( + :error, + "Exception while fetching headers: RuntimeError: uh oh" + ) + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + + logs = capture_logs { transaction.complete } + expect(logs).to contains_log( + :error, + "Exception while fetching headers: RuntimeError: uh oh" + ) + end + end + + describe "when the given headers value is nil" do + def perform + transaction.add_headers("HTTP_ACCEPT" => "text/html") + transaction.add_headers(nil) + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + transaction._sample + + expect(transaction).to include_environment("HTTP_ACCEPT" => "text/html") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + expect(root_span.attributes["http.request.header.accept"]).to eq("text/html") + end + end + + context "with request_headers options" do + let(:options) { { :request_headers => ["HTTP_ACCEPT"] } } + + describe "filtering out headers not in the allowlist" do + def perform + transaction.add_headers("HTTP_ACCEPT" => "text/html", "HTTP_RANGE" => "bytes=0-") + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + transaction._sample + + expect(transaction).to include_environment("HTTP_ACCEPT" => "text/html") + expect(transaction).to_not include_environment("HTTP_RANGE" => "bytes=0-") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + expect(root_span.attributes["http.request.header.accept"]).to eq("text/html") + expect(root_span.attributes).to_not have_key("http.request.header.range") + end + end + end + end + + describe "#add_headers_if_nil" do + let(:transaction) { new_transaction } + + it "has a #set_headers_if_nil alias" do + expect(transaction.method(:add_headers_if_nil)).to eq(transaction.method(:set_headers_if_nil)) + end + + context "when the headers are not set" do + describe "adding the headers to the transaction" do + def perform + transaction.add_headers_if_nil("HTTP_ACCEPT" => "text/html") + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + transaction._sample + + expect(transaction).to include_environment("HTTP_ACCEPT" => "text/html") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + expect(root_span.attributes["http.request.header.accept"]).to eq("text/html") + end + end + + describe "adding the headers to the transaction with a block" do + def perform + transaction.add_headers_if_nil { { "HTTP_ACCEPT" => "text/html" } } + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + transaction._sample + + expect(transaction).to include_environment("HTTP_ACCEPT" => "text/html") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + expect(root_span.attributes["http.request.header.accept"]).to eq("text/html") + end + end + + describe "adding the headers block value when an argument and block are given" do + def perform + transaction.add_headers_if_nil("HTTP_ACCEPT" => "arg") { { "HTTP_ACCEPT" => "block" } } + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + transaction._sample + + expect(transaction).to include_environment("HTTP_ACCEPT" => "block") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + expect(root_span.attributes["http.request.header.accept"]).to eq("block") + end + end + + describe "when the given value is nil" do + def perform + transaction.add_headers("HTTP_ACCEPT" => "text/html") + transaction.add_headers_if_nil(nil) + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + transaction._sample + + expect(transaction).to include_environment("HTTP_ACCEPT" => "text/html") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + expect(root_span.attributes["http.request.header.accept"]).to eq("text/html") + end + end + end + + context "when the headers are set" do + describe "not updating the headers on the transaction" do + def perform + transaction.add_headers("HTTP_ACCEPT" => "first") + transaction.add_headers_if_nil("HTTP_ACCEPT" => "other") + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + transaction._sample + + expect(transaction).to include_environment("HTTP_ACCEPT" => "first") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + expect(root_span.attributes["http.request.header.accept"]).to eq("first") + end + end + + describe "not updating the headers with a block on the transaction" do + def perform + transaction.add_headers("HTTP_ACCEPT" => "first") + transaction.add_headers_if_nil { { "HTTP_ACCEPT" => "other" } } + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + transaction._sample + + expect(transaction).to include_environment("HTTP_ACCEPT" => "first") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + expect(root_span.attributes["http.request.header.accept"]).to eq("first") + end end end end - describe "#add_session_data_if_nil" do + describe "#add_tags" do let(:transaction) { new_transaction } + let(:long_string) { "a" * 10_001 } - context "when the session data is not set" do - it "sets the session data on the transaction" do - data = { "key" => "value" } - transaction.add_session_data_if_nil(data) - - transaction._sample - expect(transaction).to include_session_data(data) + describe "storing tags on the transaction" do + def perform + transaction.add_tags( + :valid_key => "valid_value", + "valid_string_key" => "valid_value", + :both_symbols => :valid_value, + :integer_value => 1, + :hash_value => { "invalid" => "hash" }, + :array_value => %w[invalid array], + :object => Object.new, + :too_long_value => long_string, + long_string => "too_long_key", + :true_tag => true, + :false_tag => false + ) end - it "updates the session data on the transaction with a block" do - data = { "key" => "value" } - transaction.add_session_data_if_nil { data } - + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform transaction._sample - expect(transaction).to include_session_data(data) - end - - it "updates with the session data block when both an argument and block are given" do - arg_data = { "argument" => "value" } - block_data = { "block" => "value" } - transaction.add_session_data_if_nil(arg_data) { block_data } - transaction._sample - expect(transaction).to include_session_data(block_data) + # The extension truncates over-long tag values to 10,000 chars + "...". + expect(transaction).to include_tags( + "valid_key" => "valid_value", + "valid_string_key" => "valid_value", + "both_symbols" => "valid_value", + "integer_value" => 1, + "too_long_value" => "#{"a" * 10_000}...", + long_string => "too_long_key", + "true_tag" => true, + "false_tag" => false + ) end - it "does not update the session data on the transaction if the given value is nil" do - data = { "key" => "value" } - transaction.add_session_data(data) - transaction.add_session_data_if_nil(nil) + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + attributes = root_span.attributes + # Each tag is its own `appsignal.tag.` attribute. Symbols are + # coerced to strings; over-long values are sent whole (not truncated + # like the extension does) -- the collector/server apply their limits. + expect(attributes["appsignal.tag.valid_key"]).to eq("valid_value") + expect(attributes["appsignal.tag.valid_string_key"]).to eq("valid_value") + expect(attributes["appsignal.tag.both_symbols"]).to eq("valid_value") + expect(attributes["appsignal.tag.integer_value"]).to eq(1) + expect(attributes["appsignal.tag.true_tag"]).to eq(true) + expect(attributes["appsignal.tag.false_tag"]).to eq(false) + expect(attributes["appsignal.tag.too_long_value"]).to eq(long_string) + expect(attributes["appsignal.tag.#{long_string}"]).to eq("too_long_key") + # Non-primitive tag values are dropped by `sanitized_tags` in both modes. + expect(attributes).to_not have_key("appsignal.tag.hash_value") + expect(attributes).to_not have_key("appsignal.tag.array_value") + expect(attributes).to_not have_key("appsignal.tag.object") + end + end + + describe "merging the tags when called multiple times" do + def perform + transaction.add_tags(:key1 => "value1") + transaction.add_tags(:key2 => "value2") + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform transaction._sample - expect(transaction).to include_session_data(data) - end - end - - context "when the session data are set" do - it "does not update the session data on the transaction" do - preset_data = { "other" => "data" } - data = { "key" => "value" } - transaction.add_session_data(preset_data) - transaction.add_session_data_if_nil(data) - transaction._sample - expect(transaction).to include_session_data(preset_data) + expect(transaction).to include_tags( + "key1" => "value1", + "key2" => "value2" + ) end - it "does not update the session data with a block on the transaction" do - preset_data = { "other" => "data" } - data = { "key" => "value" } - transaction.add_session_data(preset_data) - transaction.add_session_data_if_nil { data } + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete - transaction._sample - expect(transaction).to include_session_data(preset_data) + expect(root_span.attributes["appsignal.tag.key1"]).to eq("value1") + expect(root_span.attributes["appsignal.tag.key2"]).to eq("value2") end end - end - - describe "#add_headers" do - let(:transaction) { new_transaction } - it "has a #set_headers alias" do - expect(transaction.method(:add_headers)).to eq(transaction.method(:set_headers)) - end + context "with config default_tags" do + let(:options) do + { :default_tags => { "config_tag" => "config_value", "another_tag" => 123 } } + end - it "adds the headers to the transaction" do - headers = { "PATH_INFO" => "value" } - transaction.add_headers(headers) + describe "including default_tags from config" do + def perform + end - transaction._sample - expect(transaction).to include_environment(headers) - end + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + transaction._sample - it "merges the headers on the transaction" do - transaction.add_headers("PATH_INFO" => "value") - transaction.add_headers("REQUEST_METHOD" => "value") - transaction.add_headers { { "HTTP_ACCEPT" => "value" } } + expect(transaction).to include_tags( + "config_tag" => "config_value", + "another_tag" => 123 + ) + end - transaction._sample - expect(transaction).to include_environment( - "PATH_INFO" => "value", - "REQUEST_METHOD" => "value", - "HTTP_ACCEPT" => "value" - ) - end + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete - it "adds the headers to the transaction with a block" do - headers = { "PATH_INFO" => "value" } - transaction.add_headers { headers } + expect(root_span.attributes["appsignal.tag.config_tag"]).to eq("config_value") + expect(root_span.attributes["appsignal.tag.another_tag"]).to eq(123) + end + end - transaction._sample - expect(transaction).to include_environment(headers) - end + describe "transaction tags override default_tags" do + def perform + transaction.add_tags("config_tag" => "transaction_value") + end - it "adds the headers block value when both an argument and block are given" do - arg_data = { "PATH_INFO" => "/arg-path" } - block_data = { "PATH_INFO" => "/block-path" } - transaction.add_headers(arg_data) { block_data } + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + transaction._sample - transaction._sample - expect(transaction).to include_environment(block_data) - end + expect(transaction).to include_tags( + "config_tag" => "transaction_value", + "another_tag" => 123 + ) + end - it "logs an error if an error occurred storing the headers" do - transaction.add_headers { raise "uh oh" } + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete - logs = capture_logs { transaction._sample } - expect(logs).to contains_log( - :error, - "Exception while fetching headers: RuntimeError: uh oh" - ) - end + expect(root_span.attributes["appsignal.tag.config_tag"]).to eq("transaction_value") + expect(root_span.attributes["appsignal.tag.another_tag"]).to eq(123) + end + end - it "does not update the headers on the transaction if the given value is nil" do - headers = { "PATH_INFO" => "value" } - transaction.add_headers(headers) - transaction.add_headers(nil) + describe "merging default_tags with transaction tags" do + def perform + transaction.add_tags("transaction_tag" => "transaction_value") + end - transaction._sample - expect(transaction).to include_environment(headers) - end + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + transaction._sample - context "with request_headers options" do - let(:options) { { :request_headers => ["MY_HEADER"] } } + expect(transaction).to include_tags( + "config_tag" => "config_value", + "another_tag" => 123, + "transaction_tag" => "transaction_value" + ) + end - it "does not include filtered out headers" do - transaction.add_headers("MY_HEADER" => "value1", "filtered_key" => "filtered_value") + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete - transaction._sample - expect(transaction).to include_environment("MY_HEADER" => "value1") + expect(root_span.attributes["appsignal.tag.config_tag"]).to eq("config_value") + expect(root_span.attributes["appsignal.tag.another_tag"]).to eq(123) + expect(root_span.attributes["appsignal.tag.transaction_tag"]).to eq("transaction_value") + end end end end - describe "#add_headers_if_nil" do + describe "#add_custom_data" do let(:transaction) { new_transaction } - it "has a #set_headers_if_nil alias" do - expect(transaction.method(:add_headers_if_nil)).to eq(transaction.method(:set_headers_if_nil)) + it "has a #add_custom_data alias" do + expect(transaction.method(:add_custom_data)).to eq(transaction.method(:set_custom_data)) end - context "when the headers are not set" do - it "adds the headers to the transaction" do - headers = { "PATH_INFO" => "value" } - transaction.add_headers_if_nil(headers) - - transaction._sample - expect(transaction).to include_environment(headers) + describe "adding a custom Hash data to the transaction" do + def perform + transaction.add_custom_data( + :user => { + :id => 123, + :locale => "abc" + }, + :organization => { + :slug => "appsignal", + :plan => "enterprise" + } + ) end - it "adds the headers to the transaction with a block" do - headers = { "PATH_INFO" => "value" } - transaction.add_headers_if_nil { headers } - - transaction._sample - expect(transaction).to include_environment(headers) + let(:expected) do + { + "user" => { + "id" => 123, + "locale" => "abc" + }, + "organization" => { + "slug" => "appsignal", + "plan" => "enterprise" + } + } end - it "adds the headers block value when both an argument and block are given" do - arg_data = { "PATH_INFO" => "/arg-path" } - block_data = { "PATH_INFO" => "/block-path" } - transaction.add_headers_if_nil(arg_data) { block_data } - + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform transaction._sample - expect(transaction).to include_environment(block_data) + + expect(transaction).to include_custom_data(expected) end - it "does not update the headers on the transaction if the given value is nil" do - headers = { "PATH_INFO" => "value" } - transaction.add_headers(headers) - transaction.add_headers_if_nil(nil) + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete - transaction._sample - expect(transaction).to include_environment(headers) + expect(JSON.parse(root_span.attributes["appsignal.custom_data"])).to eq(expected) end end - context "when the headers are set" do - it "does not update the headers on the transaction" do - preset_headers = { "PATH_INFO" => "/first-path" } - headers = { "PATH_INFO" => "/other-path" } - transaction.add_headers(preset_headers) - transaction.add_headers_if_nil(headers) - - transaction._sample - expect(transaction).to include_environment(preset_headers) + describe "adding a custom Array data to the transaction" do + def perform + transaction.add_custom_data([ + [123, "abc"], + ["appsignal", "enterprise"] + ]) end - it "does not update the headers with a block on the transaction" do - preset_headers = { "PATH_INFO" => "/first-path" } - headers = { "PATH_INFO" => "/other-path" } - transaction.add_headers(preset_headers) - transaction.add_headers_if_nil { headers } + let(:expected) { [[123, "abc"], ["appsignal", "enterprise"]] } + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform transaction._sample - expect(transaction).to include_environment(preset_headers) - end - end - end - - describe "#add_tags" do - let(:transaction) { new_transaction } - let(:long_string) { "a" * 10_001 } - it "stores tags on the transaction" do - transaction.add_tags( - :valid_key => "valid_value", - "valid_string_key" => "valid_value", - :both_symbols => :valid_value, - :integer_value => 1, - :hash_value => { "invalid" => "hash" }, - :array_value => %w[invalid array], - :object => Object.new, - :too_long_value => long_string, - long_string => "too_long_key", - :true_tag => true, - :false_tag => false - ) - transaction._sample - - expect(transaction).to include_tags( - "valid_key" => "valid_value", - "valid_string_key" => "valid_value", - "both_symbols" => "valid_value", - "integer_value" => 1, - "too_long_value" => "#{"a" * 10_000}...", - long_string => "too_long_key", - "true_tag" => true, - "false_tag" => false - ) - end + expect(transaction).to include_custom_data(expected) + end - it "merges the tags when called multiple times" do - transaction.add_tags(:key1 => "value1") - transaction.add_tags(:key2 => "value2") - transaction._sample + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete - expect(transaction).to include_tags( - "key1" => "value1", - "key2" => "value2" - ) + expect(JSON.parse(root_span.attributes["appsignal.custom_data"])).to eq(expected) + end end - context "with config default_tags" do - let(:options) do - { :default_tags => { "config_tag" => "config_value", "another_tag" => 123 } } + describe "storing non Hash or Array custom data" do + def perform + transaction.add_custom_data("abc") + transaction.add_custom_data(123) + transaction.add_custom_data(Object.new) end - it "includes default_tags from config" do - transaction._sample - - expect(transaction).to include_tags( - "config_tag" => "config_value", - "another_tag" => 123 + def expect_unsupported_type_logs(logs) + expect(logs).to contains_log( + :error, + %(Sample data 'custom_data': Unsupported data type 'String' received: "abc") ) - end - - it "transaction tags override default_tags" do - transaction.add_tags("config_tag" => "transaction_value") - transaction._sample - - expect(transaction).to include_tags( - "config_tag" => "transaction_value", - "another_tag" => 123 + expect(logs).to contains_log( + :error, + %(Sample data 'custom_data': Unsupported data type 'Integer' received: 123) ) - end - - it "merges default_tags with transaction tags" do - transaction.add_tags("transaction_tag" => "transaction_value") - transaction._sample - - expect(transaction).to include_tags( - "config_tag" => "config_value", - "another_tag" => 123, - "transaction_tag" => "transaction_value" + expect(logs).to contains_log( + :error, + %(Sample data 'custom_data': Unsupported data type 'Object' received: # { - :id => 123, - :locale => "abc" - }, - :organization => { - :slug => "appsignal", - :plan => "enterprise" - } - ) + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + logs = capture_logs do + perform + transaction._sample + end - transaction._sample - expect(transaction).to include_custom_data( - "user" => { - "id" => 123, - "locale" => "abc" - }, - "organization" => { - "slug" => "appsignal", - "plan" => "enterprise" - } - ) - end + expect(transaction).to_not include_custom_data + expect_unsupported_type_logs(logs) + end - it "adds a custom Array data to the transaction" do - transaction.add_custom_data([ - [123, "abc"], - ["appsignal", "enterprise"] - ]) + it "in collector mode", :collector_mode do + start_collector_agent + logs = capture_logs do + perform + transaction.complete + end - transaction._sample - expect(transaction).to include_custom_data([ - [123, "abc"], - ["appsignal", "enterprise"] - ]) + expect(root_span.attributes).to_not have_key("appsignal.custom_data") + expect_unsupported_type_logs(logs) + end end - it "does not store non Hash or Array custom data" do - logs = - capture_logs do - transaction.add_custom_data("abc") - transaction._sample - expect(transaction).to_not include_custom_data - - transaction.add_custom_data(123) - transaction._sample - expect(transaction).to_not include_custom_data + describe "merging the custom data if called multiple times" do + def perform + transaction.add_custom_data("abc" => "value") + transaction.add_custom_data("def" => "value") + end - transaction.add_custom_data(Object.new) - transaction._sample - expect(transaction).to_not include_custom_data - end + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + transaction._sample - expect(logs).to contains_log( - :error, - %(Sample data 'custom_data': Unsupported data type 'String' received: "abc") - ) - expect(logs).to contains_log( - :error, - %(Sample data 'custom_data': Unsupported data type 'Integer' received: 123) - ) - expect(logs).to contains_log( - :error, - %(Sample data 'custom_data': Unsupported data type 'Object' received: # "value", + "def" => "value" + ) + end - it "merges the custom data if called multiple times" do - transaction.add_custom_data("abc" => "value") - transaction.add_custom_data("def" => "value") + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete - transaction._sample - expect(transaction).to include_custom_data( - "abc" => "value", - "def" => "value" - ) + expect(JSON.parse(root_span.attributes["appsignal.custom_data"])).to eq( + "abc" => "value", + "def" => "value" + ) + end end end describe "#add_breadcrumb" do let(:transaction) { new_transaction } + # The OpenTelemetry `appsignal.breadcrumb` events recorded across all + # finished spans (breadcrumbs attach to the span that was current when they + # were added, which may be the root span or an event span). + def breadcrumb_events + span_exporter.finished_spans.flat_map { |span| Array(span.events) }.select do |event| + event.name == "appsignal.breadcrumb" + end + end + context "when over the limit" do - before do + def perform 22.times do |i| transaction.add_breadcrumb( "network", @@ -1357,10 +2871,13 @@ Time.parse("10-10-2010 10:00:00 UTC") ) end - transaction._sample end - it "stores last breadcrumbs on the transaction" do + it "stores last breadcrumbs on the transaction in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + transaction.complete + expect(transaction.to_h["sample_data"]["breadcrumbs"].length).to eql(20) expect(transaction.to_h["sample_data"]["breadcrumbs"][0]).to eq( "action" => "GET http://localhost", @@ -1377,13 +2894,58 @@ "time" => 1_286_704_800 ) end + + # Collector mode caps at the first rather than the last: streamed + # span events can't be retracted once emitted, so the agent-mode last-N + # trim can't be reproduced. + it "emits the first breadcrumb events in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + events = breadcrumb_events + expect(events.length).to eq(20) + expect(events.first.attributes).to include( + "category" => "network", + "action" => "GET http://localhost", + "message" => "User made external network request" + ) + expect(JSON.parse(events.first.attributes["metadata"])).to eq("code" => 1) + expect(JSON.parse(events.last.attributes["metadata"])).to eq("code" => 20) + end + end + + context "when added inside an instrumented event" do + def perform + transaction.start_event + transaction.add_breadcrumb("network", "GET http://localhost") + transaction.finish_event("sql.active_record", "User Load", "body", + Appsignal::EventFormatter::DEFAULT) + end + + it "emits the breadcrumb on the event span, not the root span", :collector_mode do + start_collector_agent + perform + transaction.complete + + event_span = event_spans.find do |span| + span.name == "sql.active_record (User Load)" + end + expect(event_span.events.map(&:name)).to include("appsignal.breadcrumb") + expect(Array(root_span.events).map(&:name)).to_not include("appsignal.breadcrumb") + end end context "with defaults" do - it "stores breadcrumb with defaults on transaction" do - timeframe_start = Time.now.utc.to_i + def perform transaction.add_breadcrumb("user_action", "clicked HOME") - transaction._sample + end + + it "stores breadcrumb with defaults on transaction in agent mode", :agent_mode do + start_agent(**start_agent_args) + timeframe_start = Time.now.utc.to_i + perform + transaction.complete timeframe_end = Time.now.utc.to_i expect(transaction).to include_breadcrumb( @@ -1394,15 +2956,32 @@ be_between(timeframe_start, timeframe_end) ) end + + it "emits a breadcrumb event with defaults on the span in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + events = breadcrumb_events + expect(events.length).to eq(1) + expect(events.first.attributes).to include( + "category" => "user_action", + "action" => "clicked HOME", + "message" => "", + "metadata" => "{}" + ) + end end context "with metadata argument that's not a Hash" do - it "does not add the breadcrumb and logs and error" do - logs = - capture_logs do - transaction.add_breadcrumb("category", "action", "message", "invalid metadata") - end - transaction._sample + def perform + transaction.add_breadcrumb("category", "action", "message", "invalid metadata") + end + + it "does not add the breadcrumb and logs an error in agent mode", :agent_mode do + start_agent(**start_agent_args) + logs = capture_logs { perform } + transaction.complete expect(transaction).to_not include_breadcrumbs expect(logs).to contains_log( @@ -1410,31 +2989,72 @@ "add_breadcrumb: Cannot add breadcrumb. The given metadata argument is not a Hash." ) end + + it "does not emit a breadcrumb event and logs an error in collector mode", :collector_mode do + start_collector_agent + logs = capture_logs { perform } + transaction.complete + + expect(breadcrumb_events).to be_empty + expect(logs).to contains_log( + :error, + "add_breadcrumb: Cannot add breadcrumb. The given metadata argument is not a Hash." + ) + end end end describe "#set_action" do let(:transaction) { new_transaction } + let(:action_name) { "PagesController#show" } context "when the action is set" do - it "updates the action name on the transaction" do - action_name = "PagesController#show" + def perform transaction.set_action(action_name) + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform expect(transaction.action).to eq(action_name) expect(transaction).to have_action(action_name) end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + + expect(transaction.action).to eq(action_name) + transaction.complete + expect(root_span.name).to eq(action_name) + expect(root_span.attributes["appsignal.action_name"]).to eq(action_name) + end end context "when the action is nil" do - it "does not update the action name on the transaction" do - action_name = "PagesController#show" + def perform transaction.set_action(action_name) transaction.set_action(nil) + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform expect(transaction.action).to eq(action_name) expect(transaction).to have_action(action_name) end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + + expect(transaction.action).to eq(action_name) + transaction.complete + expect(root_span.name).to eq(action_name) + expect(root_span.attributes["appsignal.action_name"]).to eq(action_name) + end end end @@ -1442,262 +3062,590 @@ let(:transaction) { new_transaction } context "when the action is not set" do - it "updates the action name on the transaction" do + let(:action_name) { "PagesController#show" } + + def perform + transaction.set_action_if_nil(action_name) + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) expect(transaction.action).to eq(nil) expect(transaction).to_not have_action - action_name = "PagesController#show" - transaction.set_action_if_nil(action_name) + perform expect(transaction.action).to eq(action_name) expect(transaction).to have_action(action_name) end + it "in collector mode", :collector_mode do + start_collector_agent + expect(transaction.action).to eq(nil) + + perform + + expect(transaction.action).to eq(action_name) + transaction.complete + expect(root_span.name).to eq(action_name) + expect(root_span.attributes["appsignal.action_name"]).to eq(action_name) + end + context "when the given action is nil" do - it "does not update the action name on the transaction" do - action_name = "something" - transaction.set_action("something") + let(:action_name) { "something" } + + def perform + transaction.set_action(action_name) transaction.set_action_if_nil(nil) + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform expect(transaction.action).to eq(action_name) expect(transaction).to have_action(action_name) end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + + expect(transaction.action).to eq(action_name) + transaction.complete + expect(root_span.attributes["appsignal.action_name"]).to eq(action_name) + end end end - context "when the action is set" do - it "does not update the action name on the transaction" do - action_name = "something" - transaction.set_action("something") - transaction.set_action_if_nil("something else") + context "when the action is set" do + let(:action_name) { "something" } + + def perform + transaction.set_action(action_name) + transaction.set_action_if_nil("something else") + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + + expect(transaction.action).to eq(action_name) + expect(transaction).to have_action(action_name) + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform expect(transaction.action).to eq(action_name) - expect(transaction).to have_action(action_name) + transaction.complete + expect(root_span.name).to eq(action_name) + expect(root_span.attributes["appsignal.action_name"]).to eq(action_name) end end end describe "#set_namespace" do let(:transaction) { new_transaction } + let(:namespace) { "custom" } context "when the namespace is not nil" do - it "updates the namespace on the transaction" do - namespace = "custom" + def perform transaction.set_namespace(namespace) + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform expect(transaction.namespace).to eq namespace expect(transaction).to have_namespace(namespace) end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + + expect(transaction.namespace).to eq namespace + transaction.complete + expect(root_span.attributes["appsignal.namespace"]).to eq(namespace) + end end context "when the namespace is nil" do - it "does not update the namespace on the transaction" do - namespace = "custom" + def perform transaction.set_namespace(namespace) transaction.set_namespace(nil) + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform expect(transaction.namespace).to eq(namespace) expect(transaction).to have_namespace(namespace) end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + + expect(transaction.namespace).to eq(namespace) + transaction.complete + expect(root_span.attributes["appsignal.namespace"]).to eq(namespace) + end + end + + context "when set_namespace is never called", :collector_mode do + it "carries the namespace from creation, converted to its canonical value" do + start_collector_agent + transaction = http_request_transaction + transaction.complete + + expect(root_span.attributes["appsignal.namespace"]).to eq("web") + end end end describe "#set_queue_start" do let(:transaction) { new_transaction } - it "sets the queue start in extension" do - transaction.set_queue_start(10) + describe "setting the queue start" do + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + transaction.set_queue_start(1_000_000_000_000) - expect(transaction).to have_queue_start(10) + expect(transaction).to have_queue_start(1_000_000_000_000) + end + + it "in collector mode", :collector_mode do + start_collector_agent + # An action is required for the aggregate metric to be emitted; an + # actionless transaction contributes to no metric in collector mode. + transaction.set_action("PagesController#show") + # An epoch-ms timestamp shortly before the transaction started, so the + # duration comes out to a known positive delta. + start_time = transaction.backend.instance_variable_get(:@start_time) + queue_start = ((start_time.to_f * 1000) - 5_000).round + transaction.set_queue_start(queue_start) + transaction.complete + + event = root_span.events.find { |e| e.name == "appsignal.queue_start" } + expect(event).not_to be_nil + expect(event.timestamp) + .to eq((Time.at(queue_start / 1000.0).to_r * 1_000_000_000).to_i) + + # The "http_request" namespace is emitted as "web". + snapshot = metric_snapshot("transaction_queue_duration") + expect(snapshot.data_points.map(&:attributes)).to contain_exactly( + { "namespace" => "web" }, + { "namespace" => "web", "hostname" => an_instance_of(String) } + ) + expect(snapshot.data_points.map(&:sum)).to all(be_within(1_000).of(5_000)) + end end - it "does not set the queue start in extension when value is nil" do - transaction.set_queue_start(nil) + describe "when the value is nil" do + def perform + transaction.set_queue_start(nil) + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + + expect(transaction).to_not have_queue_start + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete - expect(transaction).to_not have_queue_start + expect(Array(root_span.events).map(&:name)).to_not include("appsignal.queue_start") + expect(metric_snapshot("transaction_queue_duration")).to be_nil + end end - it "does not raise an error when the queue start is too big" do - expect(transaction.ext).to receive(:set_queue_start).and_raise(RangeError) + it_in_both_modes "does not raise an error when the queue start is too big" do + expect(transaction.backend).to receive(:set_queue_start).and_raise(RangeError) expect(Appsignal.internal_logger).to receive(:warn).with("Queue start value 10 is too big") transaction.set_queue_start(10) + # Complete so the collector-mode example detaches its OTel context rather + # than leaking it into later examples. + transaction.complete end end describe "#set_metadata" do let(:transaction) { new_transaction } - it "updates the metadata on the transaction" do - transaction.set_metadata("request_method", "GET") + describe "updating the metadata on the transaction" do + def perform + transaction.set_metadata("request_method", "GET") + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + + expect(transaction).to include_metadata("request_method" => "GET") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete - expect(transaction).to include_metadata("request_method" => "GET") + # Metadata has no dedicated OTel attribute; it is emitted as a tag. + expect(root_span.attributes["appsignal.tag.request_method"]).to eq("GET") + end end context "when filter_metadata includes metadata key" do let(:options) { { :filter_metadata => ["filter_key"] } } - it "does not set the metadata on the transaction" do - transaction.set_metadata(:filter_key, "filtered value") - transaction.set_metadata("filter_key", "filtered value") + describe "not setting the filtered metadata" do + def perform + transaction.set_metadata(:filter_key, "filtered value") + transaction.set_metadata("filter_key", "filtered value") + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform - expect(transaction).to_not include_metadata("filter_key" => anything) + expect(transaction).to_not include_metadata("filter_key" => anything) + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + expect(root_span.attributes).to_not have_key("appsignal.tag.filter_key") + end end end context "when the key is nil" do - it "does not update the metadata on the transaction" do - transaction.set_metadata(nil, "GET") + describe "not updating the metadata" do + def perform + transaction.set_metadata(nil, "GET") + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + + expect(transaction).to_not include_metadata + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete - expect(transaction).to_not include_metadata + expect(root_span.attributes.keys.grep(/appsignal\.tag\./)).to be_empty + end end end context "when the value is nil" do - it "does not update the metadata on the transaction" do - transaction.set_metadata("request_method", nil) + describe "not updating the metadata" do + def perform + transaction.set_metadata("request_method", nil) + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + + expect(transaction).to_not include_metadata + end - expect(transaction).to_not include_metadata + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + expect(root_span.attributes.keys.grep(/appsignal\.tag\./)).to be_empty + end end end end + describe "when metadata and a tag share a key (collector mode)" do + # In collector mode both metadata and tags are emitted as `appsignal.tag.*` + # span attributes, so a shared key collides on one attribute. `set_metadata` + # writes the attribute immediately, while tags are flushed at `complete` (via + # the sample data), so the tag is written last and wins -- regardless of the + # order the two were set in. (In agent mode they are stored separately and + # never collide, so this is collector-specific.) + it "the tag value wins", :collector_mode do + start_collector_agent + transaction = http_request_transaction + transaction.add_tags("shared" => "from_tag") + transaction.set_metadata("shared", "from_metadata") + transaction.complete + + expect(root_span.attributes["appsignal.tag.shared"]).to eq("from_tag") + end + + it "the tag value wins even when the tag is added after the metadata", :collector_mode do + start_collector_agent + transaction = http_request_transaction + transaction.set_metadata("shared", "from_metadata") + transaction.add_tags("shared" => "from_tag") + transaction.complete + + expect(root_span.attributes["appsignal.tag.shared"]).to eq("from_tag") + end + end + describe "storing sample data" do let(:transaction) { new_transaction } - it "stores sample data on the transaction" do - transaction.set_params( - "string_param" => "string_value", - :symbol_param => "symbol_value", - "integer" => 123, - "float" => 123.45, - "array" => ["abc", 456, { "option" => true }], - "hash" => { "hash_key" => "hash_value" } - ) + describe "storing sample data on the transaction" do + def perform + transaction.set_params( + "string_param" => "string_value", + :symbol_param => "symbol_value", + "integer" => 123, + "float" => 123.45, + "array" => ["abc", 456, { "option" => true }], + "hash" => { "hash_key" => "hash_value" } + ) + end - transaction._sample - expect(transaction).to include_params( - "string_param" => "string_value", - "symbol_param" => "symbol_value", - "integer" => 123, - "float" => 123.45, - "array" => ["abc", 456, { "option" => true }], - "hash" => { "hash_key" => "hash_value" } - ) + let(:expected) do + { + "string_param" => "string_value", + "symbol_param" => "symbol_value", + "integer" => 123, + "float" => 123.45, + "array" => ["abc", 456, { "option" => true }], + "hash" => { "hash_key" => "hash_value" } + } + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + transaction._sample + + expect(transaction).to include_params(expected) + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + expect(JSON.parse(root_span.attributes["appsignal.request.payload"])).to eq(expected) + end end - it "does not store non-Array and non-Hash data" do - logs = - capture_logs do - transaction.set_params("some string") - transaction._sample - expect(transaction).to_not include_params + describe "storing non-Array and non-Hash data" do + def perform + transaction.set_params("some string") + transaction.set_params(123) + transaction.set_params(Class.new) + set = Set.new + set.add("abc") + transaction.set_params(set) + end - transaction.set_params(123) - transaction._sample - expect(transaction).to_not include_params + # The log names the bucket the params were stored in. Legacy `set_params` + # goes to the single `params` bucket in agent mode and to the + # `request_payload` bucket in collector mode, so the key differs per mode. + def expect_unsupported_type_logs(logs, key) + expect(logs).to contains_log( + :error, + %(Sample data '#{key}': Unsupported data type 'String' received: "some string") + ) + expect(logs).to contains_log( + :error, + %(Sample data '#{key}': Unsupported data type 'Integer' received: 123) + ) + expect(logs).to contains_log( + :error, + %(Sample data '#{key}': Unsupported data type 'Class' received: #|\])/ + ) + end - transaction.set_params(Class.new) + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + logs = capture_logs do + perform transaction._sample - expect(transaction).to_not include_params + end - set = Set.new - set.add("abc") - transaction.set_params(set) - transaction._sample - expect(transaction).to_not include_params + expect(transaction).to_not include_params + expect_unsupported_type_logs(logs, "params") + end + + it "in collector mode", :collector_mode do + start_collector_agent + logs = capture_logs do + perform + transaction.complete end - expect(logs).to contains_log( - :error, - %(Sample data 'params': Unsupported data type 'String' received: "some string") - ) - expect(logs).to contains_log( - :error, - %(Sample data 'params': Unsupported data type 'Integer' received: 123) - ) - expect(logs).to contains_log( - :error, - %(Sample data 'params': Unsupported data type 'Class' received: #|\])/ - ) + expect(root_span.attributes).to_not have_key("appsignal.request.payload") + expect_unsupported_type_logs(logs, "request_payload") + end end - it "does not store data that can't be converted to JSON" do - klass = Class.new do - def initialize - @calls = 0 - end + describe "storing data that can't be serialized" do + let(:unserializable) do + Class.new do + def initialize + @calls = 0 + end - def to_s - raise "foo" if @calls > 0 # Cause a deliberate error + def to_s + raise "foo" if @calls > 0 # Cause a deliberate error - @calls += 1 + @calls += 1 + end end end - transaction.set_params(klass.new => 1) - logs = capture_logs { transaction._sample } + def perform + transaction.set_params(unserializable.new => 1) + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + logs = capture_logs { transaction._sample } + + expect(transaction).to_not include_params + expect(logs).to contains_log :error, + "Error generating data (RuntimeError: foo) for" + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + logs = capture_logs { transaction.complete } - expect(transaction).to_not include_params - expect(logs).to contains_log :error, - "Error generating data (RuntimeError: foo) for" + expect(root_span.attributes).to_not have_key("appsignal.request.payload") + expect(logs).to contains_log :error, + "Error generating data (RuntimeError: foo) for" + end end end describe "#set_sample_data" do let(:transaction) { new_transaction } - it "updates the sample data on the transaction" do - silence do - transaction.send( - :set_sample_data, - "params", - :controller => "blog_posts", - :action => "show", - :id => "1" - ) + describe "updating the sample data on the transaction" do + def perform + silence do + transaction.send( + :set_sample_data, + "params", + :controller => "blog_posts", + :action => "show", + :id => "1" + ) + end end - expect(transaction).to include_params( - "action" => "show", - "controller" => "blog_posts", - "id" => "1" - ) + let(:expected) do + { "action" => "show", "controller" => "blog_posts", "id" => "1" } + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + + expect(transaction).to include_params(expected) + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + # A raw `params` key is not one of the collector's known channels, so it + # passes through as `appsignal.params`. The request payload and function + # parameters channels are covered by their own specs above. + expect(JSON.parse(root_span.attributes["appsignal.params"])).to eq(expected) + end end context "when the data is no Array or Hash" do - it "does not update the sample data on the transaction" do - logs = - capture_logs do - silence { transaction.send(:set_sample_data, "params", "string") } - end + describe "not updating the sample data" do + def perform + silence { transaction.send(:set_sample_data, "params", "string") } + end - expect(transaction.to_h["sample_data"]).to eq({}) - expect(logs).to contains_log :error, - %(Invalid sample data for 'params'. Value is not an Array or Hash: '"string"') + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + logs = capture_logs { perform } + + expect(transaction.to_h["sample_data"]).to eq({}) + expect(logs).to contains_log :error, + %(Invalid sample data for 'params'. Value is not an Array or Hash: '"string"') + end + + it "in collector mode", :collector_mode do + start_collector_agent + logs = capture_logs { perform } + transaction.complete + + expect(root_span.attributes).to_not have_key("appsignal.request.payload") + expect(logs).to contains_log :error, + %(Invalid sample data for 'params'. Value is not an Array or Hash: '"string"') + end end end - context "when the data cannot be converted to JSON" do - it "does not update the sample data on the transaction" do - klass = Class.new do - def to_s - raise "foo" # Cause a deliberate error + context "when the data cannot be converted" do + # The direct call skips sanitization, so the raw object reaches the + # backend serializer (`Data.generate` in agent mode, `JSON.generate` in + # collector mode); both call `to_s` and rescue the resulting error. + describe "not updating the sample data" do + let(:unserializable) do + Class.new do + def to_s + raise "foo" # Cause a deliberate error + end end end - logs = - capture_logs do - silence { transaction.send(:set_sample_data, "params", klass.new => 1) } - end - expect(transaction).to_not include_params - expect(logs).to contains_log :error, - "Error generating data (RuntimeError: foo) for" + def perform + silence { transaction.send(:set_sample_data, "params", unserializable.new => 1) } + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + logs = capture_logs { perform } + + expect(transaction).to_not include_params + expect(logs).to contains_log :error, + "Error generating data (RuntimeError: foo) for" + end + + it "in collector mode", :collector_mode do + start_collector_agent + logs = capture_logs { perform } + transaction.complete + + expect(root_span.attributes).to_not have_key("appsignal.request.payload") + expect(logs).to contains_log :error, + "Error generating data (RuntimeError: foo) for" + end end end end @@ -1771,36 +3719,192 @@ def to_s transaction2.add_error(error.cause) expect(transaction).to_not have_error end - end + end + + context "when a block is given" do + it "stores the block, wrapped, in the error blocks" do + called_with = nil + transaction.add_error(error) { |t| called_with = t } + + stored = transaction.error_blocks[error] + expect(stored.size).to eq(1) + + # The block is wrapped when it is added, so what is stored is not the + # given block itself but a wrapper that calls through to it. + stored.each { |block| block.call(transaction) } + expect(called_with).to eq(transaction) + end + end + + context "when no error is set in the transaction" do + it "sets the error on the transaction" do + transaction.add_error(error) + + expect(transaction).to have_error( + "ExampleStandardError", + "test message", + ["line 1"] + ) + end + + it "does store the error in the errors" do + transaction.add_error(error) + + expect(transaction.error_blocks).to eq({ error => [] }) + end + end + + describe "recording the error on the span" do + def perform + transaction.add_error(error) + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + + expect(transaction).to have_error( + "ExampleStandardError", + "test message", + ["line 1"] + ) + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + event = root_span.events.find { |e| e.name == "exception" } + expect(event).not_to be_nil + expect(event.attributes["exception.type"]).to eq("ExampleStandardError") + expect(event.attributes["exception.message"]).to eq("test message") + expect(event.attributes["exception.stacktrace"]).to eq("line 1") + expect(event.attributes).not_to have_key("appsignal.error_causes") + expect(root_span.status.code).to eq(::OpenTelemetry::Trace::Status::ERROR) + end + end + + describe "recording an error that has causes" do + let(:error) do + cause = ExampleStandardError.new("cause message").tap do |e| + e.set_backtrace(["/path/cause.rb:1:in `cause_method'"]) + end + ExampleException.new("wrapper message").tap do |e| + e.set_backtrace(["/path/wrapper.rb:2:in `wrapper_method'"]) + allow(e).to receive(:cause).and_return(cause) + end + end + + def perform + # Hide Rails so the backtrace isn't run through its cleaner, keeping the + # asserted lines deterministic (mirrors the error-causes sample-data spec). + hide_const("Rails") + transaction.add_error(error) + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + + expect(transaction).to have_error("ExampleException", "wrapper message") + expect(transaction).to include_error_causes( + [hash_including("name" => "ExampleStandardError", "message" => "cause message")] + ) + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + event = root_span.events.find { |e| e.name == "exception" } + expect(event.attributes["exception.type"]).to eq("ExampleException") + # `appsignal.error_causes` matches the processor's ErrorSubCause shape: + # name / message / lines (full cleaned backtrace per cause). + expect(JSON.parse(event.attributes["appsignal.error_causes"])).to eq( + [ + { + "name" => "ExampleStandardError", + "message" => "cause message", + "lines" => ["/path/cause.rb:1:in `cause_method'"] + } + ] + ) + end + end + + describe "recording multiple errors" do + let(:other_error) do + ExampleStandardError.new("other message").tap { |e| e.set_backtrace(["line 2"]) } + end + + def perform + transaction.add_error(error) + transaction.add_error(other_error) + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + # The extension holds one error per transaction, so the extra error is + # reported as a duplicate transaction. + expect { transaction.complete }.to change { created_transactions.count }.by(1) + + original_transaction, duplicate_transaction = created_transactions + expect(original_transaction).to have_error( + "ExampleStandardError", "test message", ["line 1"] + ) + expect(duplicate_transaction).to have_error( + "ExampleStandardError", "other message", ["line 2"] + ) + end - context "when a block is given" do - it "stores the block in the error blocks" do - block = proc { "block" } + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete - transaction.add_error(error, &block) + # One trace: a single root span carrying one exception event per error. + root_spans = span_exporter.finished_spans.select do |span| + [:server, :consumer].include?(span.kind) + end + expect(root_spans.size).to eq(1) - expect(transaction.error_blocks).to eq({ - error => [block] - }) + events = root_spans.first.events.select { |e| e.name == "exception" } + expect(events.map { |e| e.attributes["exception.type"] }) + .to contain_exactly("ExampleStandardError", "ExampleStandardError") + expect(events.map { |e| e.attributes["exception.message"] }) + .to contain_exactly("test message", "other message") end end - context "when no error is set in the transaction" do - it "sets the error on the transaction" do - transaction.add_error(error) + # Collector-mode-specific behavior (no agent-mode analog): the error is + # recorded on the span that is current when `add_error` is called. + it "records the error on the current event span", :collector_mode do + start_collector_agent + transaction.start_event + transaction.add_error(error) + transaction.finish_event("query", "title", "body", Appsignal::EventFormatter::DEFAULT) + transaction.complete - expect(transaction).to have_error( - "ExampleStandardError", - "test message", - ["line 1"] - ) - end + event_span = event_spans.find { |span| span.name == "query (title)" } + expect(event_span.events.map(&:name)).to include("exception") + expect(Array(root_span.events).map(&:name)).not_to include("exception") + end - it "does store the error in the errors" do - transaction.add_error(error) + # Collector-mode-specific: errors collapse onto one trace, so error blocks + # merge onto the transaction in order -- the last-added error wins on a + # shared key. + it "applies error blocks in order, last-added error wins", :collector_mode do + start_collector_agent + second_error = ExampleStandardError.new("second message") + transaction.add_error(error) { |t| t.set_action("FirstAction") } + transaction.add_error(second_error) { |t| t.set_action("SecondAction") } + transaction.complete - expect(transaction.error_blocks).to eq({ error => [] }) - end + expect(root_span.name).to eq("SecondAction") + expect(root_span.attributes["appsignal.action_name"]).to eq("SecondAction") end context "when an error is already set in the transaction" do @@ -1844,12 +3948,15 @@ def to_s end context "when a block is given" do - it "adds the block to the error blocks" do - block = proc { "block" } + it "adds the block, wrapped, to the error blocks" do + called = false + transaction.add_error(error) { called = true } - transaction.add_error(error, &block) + stored = transaction.error_blocks[error] + expect(stored.size).to eq(1) - expect(transaction.error_blocks).to eq({ error => [block] }) + stored.each { |block| block.call(transaction) } + expect(called).to be(true) end end end @@ -1895,12 +4002,15 @@ def to_s expect(transaction.error_blocks.length).to eq(10) end - it "does add the block to the error blocks" do - block = proc { "block" } + it "does add the block, wrapped, to the error blocks" do + called = false + transaction.add_error(seen_error) { called = true } - transaction.add_error(seen_error, &block) + stored = transaction.error_blocks[seen_error] + expect(stored.size).to eq(1) - expect(transaction.error_blocks[seen_error]).to eq([block]) + stored.each { |block| block.call(transaction) } + expect(called).to be(true) end it "does not log a debug message" do @@ -1922,17 +4032,31 @@ def to_s "\"index_users_on_email\" DETAIL: Key (email)=(test@test.com) already exists." ) end - before do - stub_const("PG::UniqueViolation", Class.new(StandardError)) + let(:sanitized_message) do + "ERROR: duplicate key value violates unique constraint " \ + "\"index_users_on_email\" DETAIL: Key (email)=(?) already exists." + end + before { stub_const("PG::UniqueViolation", Class.new(StandardError)) } + + def perform transaction.add_error(error) end - it "returns a sanizited error message" do - expect(transaction).to have_error( - "PG::UniqueViolation", - "ERROR: duplicate key value violates unique constraint " \ - "\"index_users_on_email\" DETAIL: Key (email)=(?) already exists." - ) + it "returns a sanizited error message in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + + expect(transaction).to have_error("PG::UniqueViolation", sanitized_message) + end + + it "records a sanitized error message in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + event = exception_event + expect(event.attributes["exception.type"]).to eq("PG::UniqueViolation") + expect(event.attributes["exception.message"]).to eq(sanitized_message) end end @@ -1943,26 +4067,45 @@ def to_s "\"example_constraint\"\nDETAIL: Key (email)=(foo@example.com) already exists." ) end - before do - stub_const("ActiveRecord::RecordNotUnique", Class.new(StandardError)) + let(:sanitized_message) do + "PG::UniqueViolation: ERROR: duplicate key value violates unique constraint " \ + "\"example_constraint\"\nDETAIL: Key (email)=(?) already exists." + end + before { stub_const("ActiveRecord::RecordNotUnique", Class.new(StandardError)) } + + def perform transaction.add_error(error) end - it "returns a sanizited error message" do - expect(transaction).to have_error( - "ActiveRecord::RecordNotUnique", - "PG::UniqueViolation: ERROR: duplicate key value violates unique constraint " \ - "\"example_constraint\"\nDETAIL: Key (email)=(?) already exists." - ) + it "returns a sanizited error message in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + + expect(transaction).to have_error("ActiveRecord::RecordNotUnique", sanitized_message) + end + + it "records a sanitized error message in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + event = exception_event + expect(event.attributes["exception.type"]).to eq("ActiveRecord::RecordNotUnique") + expect(event.attributes["exception.message"]).to eq(sanitized_message) end end context "with Rails module but without backtrace_cleaner method" do - it "returns the backtrace uncleaned" do + def perform stub_const("Rails", Module.new) error = ExampleStandardError.new("error message") error.set_backtrace(["line 1", "line 2"]) transaction.add_error(error) + end + + it "returns the backtrace uncleaned in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform expect(last_transaction).to have_error( "ExampleStandardError", @@ -1970,6 +4113,15 @@ def to_s ["line 1", "line 2"] ) end + + it "records the backtrace uncleaned in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + event = exception_event + expect(event.attributes["exception.stacktrace"]).to eq("line 1\nline 2") + end end if rails_present? @@ -1988,18 +4140,40 @@ def to_s ::Rails.backtrace_cleaner.add_filter(&test_filter) end - it "cleans the backtrace with the Rails backtrace cleaner" do + def perform error = ExampleStandardError.new("error message") error.set_backtrace(["line 1", "line 2"]) transaction.add_error(error) + end + + it "cleans the backtrace with the Rails backtrace cleaner in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + expect(last_transaction).to have_error( "ExampleStandardError", "error message", ["line 1", "line ?"] ) end + + it "cleans the backtrace with the Rails backtrace cleaner in collector mode", + :collector_mode do + start_collector_agent + perform + transaction.complete + + event = exception_event + expect(event.attributes["exception.stacktrace"]).to eq("line 1\nline ?") + end end end + + # The completed root span's sole `exception` span-event, for asserting + # collector-mode error attributes. + def exception_event + root_span.events.find { |event| event.name == "exception" } + end end describe "#_set_error" do @@ -2011,6 +4185,11 @@ def to_s end end + # The completed root span's sole `exception` span-event. + def exception_event + root_span.events.find { |event| event.name == "exception" } + end + it "responds to add_exception for backwards compatibility" do expect(transaction).to respond_to(:add_exception) end @@ -2034,11 +4213,20 @@ def to_s end context "when the error has no causes" do - it "should set an empty causes array as sample data" do + it "should set an empty causes array as sample data", :agent_mode do + start_agent(**start_agent_args) transaction.send(:_set_error, error) expect(transaction).to include_error_causes([]) end + + it "sets no error causes attribute in collector mode", :collector_mode do + start_collector_agent + transaction.send(:_set_error, error) + transaction.complete + + expect(exception_event.attributes).not_to have_key("appsignal.error_causes") + end end context "when the error has multiple causes" do @@ -2076,7 +4264,8 @@ def to_s end let(:options) { { :revision => "my_revision" } } - it "sends the error causes information as sample data" do + it "sends the error causes information as sample data", :agent_mode do + start_agent(**start_agent_args) # Hide Rails so we can test the normal Ruby behavior. The Rails # behavior is tested in another spec. hide_const("Rails") @@ -2127,7 +4316,47 @@ def to_s ) end - it "does not keep error causes from previously set errors" do + # The collector-mode cause channel is `appsignal.error_causes`, which + # carries the full cleaned backtrace per cause (`lines`) rather than the + # agent's `first_line`-only projection. + it "records the error causes on the exception event in collector mode", :collector_mode do + start_collector_agent + hide_const("Rails") + + transaction.send(:_set_error, error) + transaction.complete + + expect(JSON.parse(exception_event.attributes["appsignal.error_causes"])).to eq( + [ + { + "name" => "RuntimeError", + "message" => "cause message", + "lines" => [ + "my_gem (1.2.3) /absolute/path/example.rb:123:in `my_method'", + "other_gem (4.5.6) /absolute/path/context.rb:456:in `context_method'", + "other_gem (4.5.6) /absolute/path/suite.rb:789:in `suite_method'" + ] + }, + { + "name" => "StandardError", + "message" => "cause message 2", + "lines" => [ + "src/example.rb:123:in `my_method'", + "context.rb:456:in `context_method'", + "suite.rb:789:in `suite_method'" + ] + }, + { + "name" => "StandardError", + "message" => "cause message 3", + "lines" => [] + } + ] + ) + end + + it "does not keep error causes from previously set errors", :agent_mode do + start_agent(**start_agent_args) transaction.send(:_set_error, error) transaction.send(:_set_error, error_without_cause) @@ -2140,6 +4369,24 @@ def to_s expect(transaction).to include_error_causes([]) end + it "records no causes for a later cause-less error in collector mode", + :collector_mode do + start_collector_agent + transaction.send(:_set_error, error) + transaction.send(:_set_error, error_without_cause) + transaction.complete + + # Collector mode records each error as its own exception event rather + # than overwriting, so the later cause-less error's event carries no + # causes; the earlier error's causes do not leak onto it. + events = Array(root_span.events).select { |event| event.name == "exception" } + without_cause = events.find do |event| + event.attributes["exception.message"] == "error without cause" + end + expect(without_cause).not_to be_nil + expect(without_cause.attributes).not_to have_key("appsignal.error_causes") + end + describe "with app paths" do let(:root_path) { project_fixture_path } let(:error) do @@ -2298,7 +4545,8 @@ def to_s e end - it "sends only the first causes as sample data" do + it "sends only the first causes as sample data", :agent_mode do + start_agent(**start_agent_args) expected_error_causes = Array.new(10) do |i| { @@ -2324,6 +4572,33 @@ def to_s "will be reported." ) end + + it "records only the first causes on the exception event in collector mode", + :collector_mode do + start_collector_agent + expected_error_causes = + Array.new(10) do |i| + { + "name" => "ExampleStandardError", + "message" => "wrapper error #{9 - i}", + "lines" => [] + } + end + + logs = capture_logs do + transaction.send(:_set_error, error) + transaction.complete + end + + expect(JSON.parse(exception_event.attributes["appsignal.error_causes"])) + .to eq(expected_error_causes) + expect(logs).to contains_log( + :debug, + "Appsignal::Transaction#add_error: Error has more " \ + "than 10 error causes. Only the first 10 " \ + "will be reported." + ) + end end context "when error message is nil" do @@ -2338,7 +4613,8 @@ def to_s transaction.send(:_set_error, error) end - it "sets an error on the transaction without an error message" do + it "sets an error on the transaction without an error message", :agent_mode do + start_agent(**start_agent_args) transaction.send(:_set_error, error) expect(transaction).to have_error( @@ -2347,6 +4623,16 @@ def to_s ["line 1"] ) end + + it "records an empty error message on the exception event in collector mode", + :collector_mode do + start_collector_agent + transaction.send(:_set_error, error) + transaction.complete + + expect(exception_event.attributes["exception.type"]).to eq("ExampleStandardError") + expect(exception_event.attributes["exception.message"]).to eq("") + end end end @@ -2487,7 +4773,8 @@ def to_s end context "when the transaction has several errors" do - it "calls the given hook for each of the duplicate error transactions" do + it "calls the given hook for each of the duplicate error transactions", :agent_mode do + start_agent(**start_agent_args) block = proc do |transaction, error| transaction.set_action(error.message) end @@ -2512,6 +4799,26 @@ def to_s have_action("hook_error_second") ) end + + it "calls the hook once with the first error in collector mode", :collector_mode do + start_collector_agent + block = proc do |transaction, error| + transaction.set_action(error.message) + end + + Appsignal::Transaction.before_complete(&block) + + transaction = new_transaction + transaction.set_error(ExampleStandardError.new("hook_error_first")) + transaction.set_error(ExampleStandardError.new("hook_error_second")) + + expect(block).to receive(:call).once.and_call_original + + transaction.complete + + # One trace, so the hook runs once with the first error. + expect(root_span.name).to eq("hook_error_first") + end end context "when the transaction does not have an error" do @@ -2561,15 +4868,31 @@ def to_s let(:transaction) { new_transaction } it "starts the event in the extension" do - expect(transaction.ext).to receive(:start_event).with(0).and_call_original + expect(transaction.backend).to receive(:start_event) + .with(:opentelemetry_kind => nil, :opentelemetry_scope => nil).and_call_original transaction.start_event end + it "passes the opentelemetry_kind to the backend" do + expect(transaction.backend).to receive(:start_event) + .with(:opentelemetry_kind => :client, :opentelemetry_scope => nil).and_call_original + + transaction.start_event(:opentelemetry_kind => :client) + end + + it "passes the opentelemetry_scope to the backend" do + expect(transaction.backend).to receive(:start_event) + .with(:opentelemetry_kind => nil, :opentelemetry_scope => ["appsignal-ruby/redis", "1.0"]) + .and_call_original + + transaction.start_event(:opentelemetry_scope => ["appsignal-ruby/redis", "1.0"]) + end + context "when transaction is paused" do it "does not start the event" do transaction.pause! - expect(transaction.ext).to_not receive(:start_event) + expect(transaction.backend).to_not receive(:start_event) transaction.start_event end @@ -2578,15 +4901,13 @@ def to_s describe "#finish_event" do let(:transaction) { new_transaction } - let(:fake_gc_time) { 0 } it "should finish the event in the extension" do - expect(transaction.ext).to receive(:finish_event).with( + expect(transaction.backend).to receive(:finish_event).with( "name", "title", "body", - 1, - fake_gc_time + 1 ).and_call_original transaction.finish_event( @@ -2598,12 +4919,11 @@ def to_s end it "should finish the event in the extension with nil arguments" do - expect(transaction.ext).to receive(:finish_event).with( + expect(transaction.backend).to receive(:finish_event).with( "name", "", "", - 0, - fake_gc_time + 0 ).and_call_original transaction.finish_event( @@ -2617,7 +4937,7 @@ def to_s context "when transaction is paused" do it "does not finish the event" do transaction.pause! - expect(transaction.ext).to_not receive(:finish_event) + expect(transaction.backend).to_not receive(:finish_event) transaction.start_event end @@ -2626,16 +4946,17 @@ def to_s describe "#record_event" do let(:transaction) { new_transaction } - let(:fake_gc_time) { 0 } it "should record the event in the extension" do - expect(transaction.ext).to receive(:record_event).with( + expect(transaction.backend).to receive(:record_event).with( "name", "title", "body", 1, 1000, - fake_gc_time + :opentelemetry_kind => nil, + :opentelemetry_scope => nil, + :opentelemetry_attributes => nil ).and_call_original transaction.record_event( @@ -2647,14 +4968,60 @@ def to_s ) end + it "passes the opentelemetry_scope to the backend" do + expect(transaction.backend).to receive(:record_event).with( + "name", + "title", + "body", + 1, + 1000, + :opentelemetry_kind => nil, + :opentelemetry_scope => ["appsignal-ruby/data_mapper", "1.0"], + :opentelemetry_attributes => nil + ).and_call_original + + transaction.record_event( + "name", + "title", + "body", + 1000, + 1, + :opentelemetry_scope => ["appsignal-ruby/data_mapper", "1.0"] + ) + end + + it "passes the opentelemetry_attributes to the backend" do + expect(transaction.backend).to receive(:record_event).with( + "name", + "title", + "body", + 1, + 1000, + :opentelemetry_kind => nil, + :opentelemetry_scope => nil, + :opentelemetry_attributes => { "db.system.name" => "postgresql" } + ).and_call_original + + transaction.record_event( + "name", + "title", + "body", + 1000, + 1, + :opentelemetry_attributes => { "db.system.name" => "postgresql" } + ) + end + it "should finish the event in the extension with nil arguments" do - expect(transaction.ext).to receive(:record_event).with( + expect(transaction.backend).to receive(:record_event).with( "name", "", "", 0, 1000, - fake_gc_time + :opentelemetry_kind => nil, + :opentelemetry_scope => nil, + :opentelemetry_attributes => nil ).and_call_original transaction.record_event( @@ -2669,7 +5036,7 @@ def to_s context "when transaction is paused" do it "does not record the event" do transaction.pause! - expect(transaction.ext).to_not receive(:record_event) + expect(transaction.backend).to_not receive(:record_event) transaction.record_event( "name", @@ -2680,6 +5047,42 @@ def to_s ) end end + + describe "recording an event with the given duration" do + let(:duration_ns) { 1_000_000_000 } + + def perform(transaction) + transaction.record_event("custom.event", "T", "B", duration_ns, + Appsignal::EventFormatter::DEFAULT) + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + transaction = create_transaction(Appsignal::Transaction::HTTP_REQUEST) + perform(transaction) + Appsignal::Transaction.complete_current! + + expect(transaction).to include_event( + "name" => "custom.event", + "title" => "T", + "body" => "B" + ) + end + + it "in collector mode", :collector_mode do + start_collector_agent + transaction = create_transaction(Appsignal::Transaction::HTTP_REQUEST) + perform(transaction) + Appsignal::Transaction.complete_current! + + span = event_spans.first + expect(span.name).to eq("custom.event (T)") + expect(span.attributes).not_to have_key("appsignal.category") + expect(span.parent_span_id).to eq(root_span.span_id) + observed = span.end_timestamp - span.start_timestamp + expect(observed).to be_within(50_000_000).of(duration_ns) + end + end end describe "#instrument" do @@ -2687,6 +5090,185 @@ def to_s let(:transaction) { new_transaction } let(:instrumenter) { transaction } end + + describe "block return value" do + it_in_both_modes do + transaction = create_transaction(Appsignal::Transaction::HTTP_REQUEST) + result = transaction.instrument("sql.active_record", "Query", "SELECT 1", + Appsignal::EventFormatter::SQL_BODY_FORMAT) { 42 } + + expect(result).to eq(42) + end + end + + describe "block raising an exception" do + it_in_both_modes do + transaction = create_transaction(Appsignal::Transaction::HTTP_REQUEST) + + expect do + transaction.instrument("x.y", nil, nil, Appsignal::EventFormatter::DEFAULT) do + raise "boom" + end + end.to raise_error("boom") + end + end + + describe "instrumenting a SQL event" do + def perform(transaction) + transaction.instrument("sql.active_record", "Query", "SELECT 1", + Appsignal::EventFormatter::SQL_BODY_FORMAT) { nil } + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + transaction = create_transaction(Appsignal::Transaction::HTTP_REQUEST) + perform(transaction) + Appsignal::Transaction.complete_current! + + expect(transaction).to include_event( + "name" => "sql.active_record", + "title" => "Query", + "body" => "SELECT 1", + "body_format" => Appsignal::EventFormatter::SQL_BODY_FORMAT + ) + end + + it "in collector mode", :collector_mode do + start_collector_agent + transaction = create_transaction(Appsignal::Transaction::HTTP_REQUEST) + perform(transaction) + Appsignal::Transaction.complete_current! + + span = event_spans.first + expect(span.name).to eq("sql.active_record (Query)") + expect(span.parent_span_id).to eq(root_span.span_id) + expect(span.attributes).to include( + "db.query.text" => "SELECT 1", + "db.system.name" => "other_sql" + ) + expect(span.attributes).not_to have_key("appsignal.category") + expect(span.attributes).not_to have_key("appsignal.body") + end + end + + describe "instrumenting a default-format event" do + def perform(transaction) + transaction.instrument("custom.event", "Title", "Body", + Appsignal::EventFormatter::DEFAULT) { nil } + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + transaction = create_transaction(Appsignal::Transaction::HTTP_REQUEST) + perform(transaction) + Appsignal::Transaction.complete_current! + + expect(transaction).to include_event( + "name" => "custom.event", + "title" => "Title", + "body" => "Body", + "body_format" => Appsignal::EventFormatter::DEFAULT + ) + end + + it "in collector mode", :collector_mode do + start_collector_agent + transaction = create_transaction(Appsignal::Transaction::HTTP_REQUEST) + perform(transaction) + Appsignal::Transaction.complete_current! + + span = event_spans.first + expect(span.name).to eq("custom.event (Title)") + expect(span.attributes).to include( + "appsignal.body" => "Body" + ) + expect(span.attributes).not_to have_key("appsignal.category") + expect(span.attributes).not_to have_key("db.query.text") + expect(span.attributes).not_to have_key("db.system.name") + end + end + + describe "nesting instrumented events" do + def perform(transaction) + transaction.instrument("outer.event", "Outer", "outer body", + Appsignal::EventFormatter::DEFAULT) do + transaction.instrument("inner.event", "Inner", "inner body", + Appsignal::EventFormatter::DEFAULT) { nil } + end + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + transaction = create_transaction(Appsignal::Transaction::HTTP_REQUEST) + perform(transaction) + Appsignal::Transaction.complete_current! + + expect(transaction).to include_event( + "name" => "outer.event", "title" => "Outer", "body" => "outer body" + ) + expect(transaction).to include_event( + "name" => "inner.event", "title" => "Inner", "body" => "inner body" + ) + end + + it "in collector mode", :collector_mode do + start_collector_agent + transaction = create_transaction(Appsignal::Transaction::HTTP_REQUEST) + perform(transaction) + Appsignal::Transaction.complete_current! + + outer = event_spans.find { |s| s.name == "outer.event (Outer)" } + inner = event_spans.find { |s| s.name == "inner.event (Inner)" } + + expect(inner.parent_span_id).to eq(outer.span_id) + expect(outer.parent_span_id).to eq(root_span.span_id) + end + end + + describe "with an empty title" do + it "names the span after the event name and omits appsignal.title", :collector_mode do + start_collector_agent + transaction = create_transaction(Appsignal::Transaction::HTTP_REQUEST) + transaction.instrument("custom.event", nil, "Body", + Appsignal::EventFormatter::DEFAULT) { nil } + Appsignal::Transaction.complete_current! + + span = event_spans.first + expect(span.name).to eq("custom.event") + expect(span.attributes).not_to have_key("appsignal.category") + expect(span.attributes).not_to have_key("appsignal.title") + end + end + + describe "with an empty body" do + it "omits the body attribute on the span", :collector_mode do + start_collector_agent + transaction = create_transaction(Appsignal::Transaction::HTTP_REQUEST) + transaction.instrument("custom.event", "Title", nil, + Appsignal::EventFormatter::DEFAULT) { nil } + Appsignal::Transaction.complete_current! + + attrs = event_spans.first.attributes + expect(attrs).not_to have_key("appsignal.body") + expect(attrs).not_to have_key("db.query.text") + end + end + + describe "OpenTelemetry current context during the block" do + it "in collector mode", :collector_mode do + start_collector_agent + transaction = create_transaction(Appsignal::Transaction::HTTP_REQUEST) + root_span_id = ::OpenTelemetry::Trace.current_span.context.span_id + + event_span_id_during_block = nil + transaction.instrument("custom.event", "T", "B", Appsignal::EventFormatter::DEFAULT) do + event_span_id_during_block = ::OpenTelemetry::Trace.current_span.context.span_id + end + + expect(event_span_id_during_block).not_to eq(root_span_id) + expect(::OpenTelemetry::Trace.current_span.context.span_id).to eq(root_span_id) + end + end end # private @@ -2711,7 +5293,7 @@ def to_s context "when the extension returns invalid serialized JSON" do before do - expect(transaction.ext).to receive(:to_json).and_return("foo") + expect(transaction.backend).to receive(:to_json).and_return("foo") end it "raises a JSON parse error" do diff --git a/spec/lib/appsignal_spec.rb b/spec/lib/appsignal_spec.rb index 423ee99ed..9f1bde867 100644 --- a/spec/lib/appsignal_spec.rb +++ b/spec/lib/appsignal_spec.rb @@ -867,6 +867,39 @@ def on_start expect(Appsignal.internal_logger.level).to eq Logger::DEBUG end end + + if DependencyHelper.opentelemetry_present? + context "when collector_endpoint is set but the OpenTelemetry SDK fails to boot" do + let(:err_stream) { std_stream } + let(:stdout_stream) { std_stream } + + before do + # Simulate a failure inside `Appsignal::OpenTelemetry.configure` — + # e.g. one of the OTel gems can't be loaded. The rescue inside + # `configure` should set `started?` to false instead of letting the + # error bubble out. + allow(Appsignal::OpenTelemetry).to receive(:require) + .with("opentelemetry/sdk") + .and_raise(LoadError, "fake load failure") + end + + it "falls back to the agent backend rather than silently dropping telemetry" do + capture_std_streams(stdout_stream, err_stream) do + start_agent(:options => { :collector_endpoint => "http://127.0.0.1:9090" }) + end + + # Config still records the user's intent. + expect(Appsignal.config.collector_mode_configured?).to be(true) + # But the active predicate is false because the SDK never booted. + expect(Appsignal.config.collector_mode?).to be(false) + expect(Appsignal::OpenTelemetry.started?).to be(false) + + # Backends fall through to the extension implementations. + expect(Appsignal::Backends.metrics).to eq(Appsignal::Metrics::ExtensionBackend) + expect(Appsignal::Backends.logger).to eq(Appsignal::Logger::ExtensionBackend) + end + end + end end describe ".load" do @@ -897,9 +930,25 @@ def on_start Appsignal.start end - it "starts the logger and extension" do - expect(Appsignal).to receive(:_start_logger) - expect(Appsignal::Extension).to receive(:start) + it "starts the logger before restarting the extension" do + expect(Appsignal).to receive(:_start_logger).ordered + expect(Appsignal::Extension).to receive(:start).ordered + + expect(Appsignal.forked).to be_nil + end + + it "does not stop the extension before restarting it" do + allow(Appsignal).to receive(:_start_logger) + allow(Appsignal::Extension).to receive(:start) + expect(Appsignal::Extension).to_not receive(:stop) + + Appsignal.forked + end + + it "does not restart minutely probes (probe thread dies on fork by design)" do + allow(Appsignal).to receive(:_start_logger) + allow(Appsignal::Extension).to receive(:start) + expect(Appsignal::Probes).to_not receive(:start) Appsignal.forked end @@ -934,6 +983,33 @@ def on_start expect(Appsignal::CheckIn.scheduler).to receive(:stop) Appsignal.stop end + + if DependencyHelper.opentelemetry_present? + context "in collector mode" do + before do + Appsignal.clear! + start_agent(:options => { :collector_endpoint => "http://127.0.0.1:9090" }) + end + + it "shuts down the OpenTelemetry providers so buffered telemetry flushes" do + expect(::OpenTelemetry.tracer_provider).to receive(:shutdown) + expect(::OpenTelemetry.meter_provider).to receive(:shutdown) + expect(::OpenTelemetry.logger_provider).to receive(:shutdown) + Appsignal.stop + end + end + end + + context "when not in collector mode" do + it "calls Appsignal::OpenTelemetry.shutdown, which short-circuits as a no-op" do + # `configure` was not called in this spec, so `started?` is false + # and `shutdown` returns immediately without touching the API gem's + # proxy providers (whose `shutdown` isn't defined until an SDK is + # wired up). + expect(Appsignal::OpenTelemetry.started?).to be(false) + expect { Appsignal.stop }.not_to raise_error + end + end end describe ".started?" do @@ -1064,94 +1140,305 @@ def on_start end context "with config and started" do - before { start_agent } + # Only auto-start for non-mode examples. Mode-tagged examples + # (`:agent_mode`/`:collector_mode`) start the agent in their own body (the + # dual-mode start principle), so starting it here too would clobber the + # collector-mode setup. + before do |example| + start_agent unless example.metadata[:agent_mode] || example.metadata[:collector_mode] + end around { |example| keep_transactions { example.run } } describe ".monitor" do - it "creates a transaction" do - expect do + describe "creating a transaction" do + def perform Appsignal.monitor(:action => "MyAction") - end.to(change { created_transactions.count }.by(1)) + end - transaction = last_transaction - expect(transaction).to have_namespace(Appsignal::Transaction::HTTP_REQUEST) - expect(transaction).to have_action("MyAction") - expect(transaction).to_not have_error - expect(transaction).to_not include_events - expect(transaction).to_not have_queue_start - expect(transaction).to be_completed + it "in agent mode", :agent_mode do + start_agent + expect { perform }.to(change { created_transactions.count }.by(1)) + + transaction = last_transaction + expect(transaction).to have_namespace(Appsignal::Transaction::HTTP_REQUEST) + expect(transaction).to have_action("MyAction") + expect(transaction).to_not have_error + expect(transaction).to_not include_events + expect(transaction).to_not have_queue_start + expect(transaction).to be_completed + end + + it "in collector mode", :collector_mode do + start_collector_agent + expect { perform }.to(change { created_transactions.count }.by(1)) + + # HTTP_REQUEST maps to a SERVER span (a subtrace root). + expect(root_span.kind).to eq(:server) + expect(root_span.attributes["appsignal.namespace"]) + .to eq("web") + expect(root_span.name).to eq("MyAction") + expect(root_span.attributes["appsignal.action_name"]).to eq("MyAction") + expect(exception_events).to be_empty + expect(event_spans).to be_empty + expect(last_transaction).to be_completed + # No scope given, so the transaction uses the default scope. + expect(scope_of(root_span)).to eq(["appsignal-ruby", Appsignal::VERSION]) + end + end + + describe "passing an OpenTelemetry scope" do + let(:scope) { ["appsignal-ruby/custom", "1.2.3"] } + + it "monitor records the transaction under that scope", :collector_mode do + start_collector_agent + Appsignal.monitor(:action => "MyAction", :opentelemetry_scope => scope) + + expect(scope_of(root_span)).to eq(scope) + end + + it "send_error records the error transaction under that scope", :collector_mode do + start_collector_agent + Appsignal.send_error( + ExampleException.new("error"), + :opentelemetry_scope => scope + ) + + expect(scope_of(root_span)).to eq(scope) + end + + it "report_error records the error transaction under that scope", :collector_mode do + start_collector_agent + Appsignal.report_error( + ExampleException.new("error"), + :opentelemetry_scope => scope + ) { |t| t.set_action("MyAction") } + + expect(scope_of(root_span)).to eq(scope) + end end - it "returns the block's return value" do + it_in_both_modes "returns the block's return value" do expect(Appsignal.monitor(:action => nil) { :return_value }).to eq(:return_value) end - it "sets a custom namespace via the namespace argument" do - Appsignal.monitor(:namespace => "custom", :action => nil) + describe "OpenTelemetry attributes" do + it "threads the OpenTelemetry attributes to the created transaction" do + start_agent + + otel_context = "some-otel-context" + expect(Appsignal::Transaction).to receive(:create).with( + Appsignal::Transaction::BACKGROUND_JOB, + :opentelemetry_context => otel_context, + :opentelemetry_scope => nil, + :opentelemetry_kind => :consumer, + :opentelemetry_relationship => :both + ).and_call_original + + Appsignal.monitor( + :action => "MyAction", + :namespace => Appsignal::Transaction::BACKGROUND_JOB, + :opentelemetry_context => otel_context, + :opentelemetry_kind => :consumer, + :opentelemetry_relationship => :both + ) + end + + it "uses the given opentelemetry_kind for the span", :collector_mode do + start_collector_agent + + Appsignal.monitor(:action => "MyAction", :opentelemetry_kind => :consumer) - expect(last_transaction).to have_namespace("custom") + expect(root_span.kind).to eq(:consumer) + end end - it "doesn't overwrite custom namespace set in the block" do - Appsignal.monitor(:namespace => "custom", :action => nil) do - Appsignal.set_namespace("more custom") + describe "setting a custom namespace via the namespace argument" do + def perform + Appsignal.monitor(:namespace => "custom", :action => nil) + end + + it "in agent mode", :agent_mode do + start_agent + perform + + expect(last_transaction).to have_namespace("custom") end - expect(last_transaction).to have_namespace("more custom") + it "in collector mode", :collector_mode do + start_collector_agent + perform + + expect(root_span.attributes["appsignal.namespace"]).to eq("custom") + end end - it "sets the action via the action argument using a string" do - Appsignal.monitor(:action => "custom") + describe "not overwriting a custom namespace set in the block" do + def perform + Appsignal.monitor(:namespace => "custom", :action => nil) do + Appsignal.set_namespace("more custom") + end + end + + it "in agent mode", :agent_mode do + start_agent + perform + + expect(last_transaction).to have_namespace("more custom") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform - expect(last_transaction).to have_action("custom") + expect(root_span.attributes["appsignal.namespace"]).to eq("more custom") + end end - it "sets the action via the action argument using a symbol" do - Appsignal.monitor(:action => :custom) + describe "setting the action via the action argument" do + def perform(action) + Appsignal.monitor(:action => action) + end + + it "in agent mode using a string", :agent_mode do + start_agent + perform("custom") + + expect(last_transaction).to have_action("custom") + end + + it "in collector mode using a string", :collector_mode do + start_collector_agent + perform("custom") - expect(last_transaction).to have_action("custom") + expect(root_span.name).to eq("custom") + expect(root_span.attributes["appsignal.action_name"]).to eq("custom") + end + + it "in agent mode using a symbol", :agent_mode do + start_agent + perform(:custom) + + expect(last_transaction).to have_action("custom") + end + + it "in collector mode using a symbol", :collector_mode do + start_collector_agent + perform(:custom) + + expect(root_span.name).to eq("custom") + expect(root_span.attributes["appsignal.action_name"]).to eq("custom") + end end - it "doesn't overwrite custom action set in the block" do - Appsignal.monitor(:action => "custom") do - Appsignal.set_action("more custom") + describe "not overwriting a custom action set in the block" do + def perform + Appsignal.monitor(:action => "custom") do + Appsignal.set_action("more custom") + end + end + + it "in agent mode", :agent_mode do + start_agent + perform + + expect(last_transaction).to have_action("more custom") end - expect(last_transaction).to have_action("more custom") + it "in collector mode", :collector_mode do + start_collector_agent + perform + + expect(root_span.name).to eq("more custom") + expect(root_span.attributes["appsignal.action_name"]).to eq("more custom") + end end - it "doesn't set the action when value is nil" do - Appsignal.monitor(:action => nil) + describe "not setting the action when the value is nil" do + def perform + Appsignal.monitor(:action => nil) + end + + it "in agent mode", :agent_mode do + start_agent + perform + + expect(last_transaction).to_not have_action + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform - expect(last_transaction).to_not have_action + expect(root_span.attributes).to_not have_key("appsignal.action_name") + end end - it "doesn't set the action when value is :set_later" do - Appsignal.monitor(:action => :set_later) + describe "not setting the action when the value is :set_later" do + def perform + Appsignal.monitor(:action => :set_later) + end + + it "in agent mode", :agent_mode do + start_agent + perform + + expect(last_transaction).to_not have_action + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform - expect(last_transaction).to_not have_action + expect(root_span.attributes).to_not have_key("appsignal.action_name") + end end - it "reports exceptions that occur in the block" do - expect do - Appsignal.monitor :action => nil do - raise ExampleException, "error message" - end - end.to raise_error(ExampleException, "error message") + describe "reporting exceptions that occur in the block" do + def perform + expect do + Appsignal.monitor :action => nil do + raise ExampleException, "error message" + end + end.to raise_error(ExampleException, "error message") + end + + it "in agent mode", :agent_mode do + start_agent + perform + + expect(last_transaction).to have_error("ExampleException", "error message") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform - expect(last_transaction).to have_error("ExampleException", "error message") + event = root_span.events.find { |e| e.name == "exception" } + expect(event).not_to be_nil + expect(event.attributes["exception.type"]).to eq("ExampleException") + expect(event.attributes["exception.message"]).to eq("error message") + expect(event.attributes["exception.stacktrace"]).to be_a(String) + expect(event.attributes["appsignal.alert_this_error"]).to eq(true) + expect(root_span.status.code).to eq(::OpenTelemetry::Trace::Status::ERROR) + end end - context "with already active transction" do + context "with an already active transaction" do let(:err_stream) { std_stream } let(:stderr) { err_stream.read } let(:transaction) { http_request_transaction } - before do + + # The parent transaction is built lazily inside each example body (after + # the mode context has started the agent), per the dual-mode start + # principle -- building it in a `before` would create it against the + # wrong backend. + def activate_parent_transaction set_current_transaction(transaction) transaction.set_action("My action") end - it "doesn't create a new transaction" do + it_in_both_modes "doesn't create a new transaction" do + activate_parent_transaction logs = nil expect do logs = @@ -1167,19 +1454,54 @@ def on_start expect(stderr).to include("appsignal WARNING: #{warning}") end - it "does not overwrite the parent transaction's namespace" do - silence { Appsignal.monitor(:namespace => "custom", :action => nil) } + describe "not overwriting the parent transaction's namespace" do + def perform + activate_parent_transaction + silence { Appsignal.monitor(:namespace => "custom", :action => nil) } + end + + it "in agent mode", :agent_mode do + start_agent + perform - expect(transaction).to have_namespace(Appsignal::Transaction::HTTP_REQUEST) + expect(transaction).to have_namespace(Appsignal::Transaction::HTTP_REQUEST) + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + expect(root_span.attributes["appsignal.namespace"]) + .to eq("web") + end end - it "does not overwrite the parent transaction's action" do - silence { Appsignal.monitor(:action => "custom") } + describe "not overwriting the parent transaction's action" do + def perform + activate_parent_transaction + silence { Appsignal.monitor(:action => "custom") } + end + + it "in agent mode", :agent_mode do + start_agent + perform + + expect(transaction).to have_action("My action") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete - expect(transaction).to have_action("My action") + expect(root_span.name).to eq("My action") + expect(root_span.attributes["appsignal.action_name"]).to eq("My action") + end end - it "doesn't complete the parent transaction" do + it_in_both_modes "doesn't complete the parent transaction" do + activate_parent_transaction silence { Appsignal.monitor(:action => nil) } expect(transaction).to_not be_completed @@ -1216,20 +1538,77 @@ def on_start end end.to yield_control end + + describe "OpenTelemetry attributes" do + it "threads the OpenTelemetry attributes to the created transaction" do + allow(Appsignal).to receive(:stop) + + otel_context = "some-otel-context" + expect(Appsignal::Transaction).to receive(:create).with( + Appsignal::Transaction::BACKGROUND_JOB, + :opentelemetry_context => otel_context, + :opentelemetry_scope => ["appsignal-ruby/custom", "1.2.3"], + :opentelemetry_kind => :consumer, + :opentelemetry_relationship => :both + ).and_call_original + + silence do + Appsignal.monitor_and_stop( + :action => "MyAction", + :namespace => Appsignal::Transaction::BACKGROUND_JOB, + :opentelemetry_context => otel_context, + :opentelemetry_scope => ["appsignal-ruby/custom", "1.2.3"], + :opentelemetry_kind => :consumer, + :opentelemetry_relationship => :both + ) + end + end + + it "uses the given opentelemetry_kind for the span", :collector_mode do + start_collector_agent + allow(Appsignal).to receive(:stop) + + silence do + Appsignal.monitor_and_stop( + :action => "MyAction", + :opentelemetry_kind => :consumer + ) + end + + expect(root_span.kind).to eq(:consumer) + end + end end describe ".tag_request" do - before { start_agent } + before do |example| + start_agent unless example.metadata[:agent_mode] || example.metadata[:collector_mode] + end context "with transaction" do let(:transaction) { http_request_transaction } - before { set_current_transaction(transaction) } - it "sets tags on the current transaction" do - Appsignal.tag_request("a" => "b") + describe "setting tags on the current transaction" do + def perform + set_current_transaction(transaction) + Appsignal.tag_request("a" => "b") + end - transaction._sample - expect(transaction).to include_tags("a" => "b") + it "in agent mode", :agent_mode do + start_agent + perform + + transaction._sample + expect(transaction).to include_tags("a" => "b") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + expect(root_span.attributes["appsignal.tag.a"]).to eq("b") + end end end @@ -1254,39 +1633,93 @@ def on_start end describe ".add_params" do - before { start_agent } + before do |example| + start_agent unless example.metadata[:agent_mode] || example.metadata[:collector_mode] + end it "has a .set_params alias" do expect(Appsignal.method(:add_params)).to eq(Appsignal.method(:set_params)) end - context "with transaction" do + describe "adding parameters through the public API" do let(:transaction) { http_request_transaction } - before { set_current_transaction(transaction) } - it "adds parameters to the transaction" do + def perform + set_current_transaction(transaction) Appsignal.add_params("param1" => "value1") + end + + it "in agent mode", :agent_mode do + start_agent + perform transaction._sample expect(transaction).to include_params("param1" => "value1") end - it "merges the params if called multiple times" do - Appsignal.add_params("param1" => "value1") - Appsignal.add_params("param2" => "value2") + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete - transaction._sample - expect(transaction).to include_params( - "param1" => "value1", - "param2" => "value2" - ) + expect(JSON.parse(root_span.attributes["appsignal.request.payload"])) + .to eq("param1" => "value1") end + end - it "adds parameters with a block to the transaction" do - Appsignal.add_params { { "param1" => "value1" } } + context "with transaction" do + let(:transaction) { http_request_transaction } - transaction._sample - expect(transaction).to include_params("param1" => "value1") + describe "merging the params if called multiple times" do + def perform + set_current_transaction(transaction) + Appsignal.add_params("param1" => "value1") + Appsignal.add_params("param2" => "value2") + end + + it "in agent mode", :agent_mode do + start_agent + perform + + transaction._sample + expect(transaction).to include_params( + "param1" => "value1", + "param2" => "value2" + ) + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + expect(JSON.parse(root_span.attributes["appsignal.request.payload"])) + .to eq("param1" => "value1", "param2" => "value2") + end + end + + describe "adding parameters with a block" do + def perform + set_current_transaction(transaction) + Appsignal.add_params { { "param1" => "value1" } } + end + + it "in agent mode", :agent_mode do + start_agent + perform + + transaction._sample + expect(transaction).to include_params("param1" => "value1") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + expect(JSON.parse(root_span.attributes["appsignal.request.payload"])) + .to eq("param1" => "value1") + end end end @@ -1299,295 +1732,496 @@ def on_start end end - describe ".set_empty_params!" do - before { start_agent } + describe ".add_request_payload" do + before do |example| + start_agent unless example.metadata[:agent_mode] || example.metadata[:collector_mode] + end - context "with transaction" do + describe "setting the request payload through the public API" do let(:transaction) { http_request_transaction } - before { set_current_transaction(transaction) } - it "marks parameters to be sent as an empty value" do - Appsignal.add_params("key1" => "value") - Appsignal.set_empty_params! + def perform + set_current_transaction(transaction) + Appsignal.add_request_payload("param1" => "value1") + end + + it "in agent mode", :agent_mode do + start_agent + perform transaction._sample - expect(transaction).to_not include_params + expect(transaction).to include_params("param1" => "value1") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + expect(JSON.parse(root_span.attributes["appsignal.request.payload"])) + .to eq("param1" => "value1") end end - end - describe ".add_session_data" do - before { start_agent } + context "without transaction" do + it "does not set the request payload on any transaction" do + expect_any_instance_of(Appsignal::Transaction).to_not receive(:add_request_payload) - it "has a .set_session_data alias" do - expect(Appsignal.method(:add_session_data)).to eq(Appsignal.method(:set_session_data)) + Appsignal.add_request_payload("a" => "b") + end end + end - context "with transaction" do - let(:transaction) { http_request_transaction } - before { set_current_transaction(transaction) } + describe ".add_function_parameters" do + before do |example| + start_agent unless example.metadata[:agent_mode] || example.metadata[:collector_mode] + end - it "adds session data to the transaction" do - Appsignal.add_session_data("data" => "value1") + describe "setting the function parameters through the public API" do + let(:transaction) { background_job_transaction } - transaction._sample - expect(transaction).to include_session_data("data" => "value1") + def perform + set_current_transaction(transaction) + Appsignal.add_function_parameters("param1" => "value1") end - it "merges the session data if called multiple times" do - Appsignal.set_session_data("data1" => "value1") - Appsignal.set_session_data("data2" => "value2") + it "in agent mode", :agent_mode do + start_agent + perform transaction._sample - expect(transaction).to include_session_data( - "data1" => "value1", - "data2" => "value2" - ) + expect(transaction).to include_params("param1" => "value1") end - it "adds session data with a block to the transaction" do - Appsignal.set_session_data { { "data" => "value1" } } + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete - transaction._sample - expect(transaction).to include_session_data("data" => "value1") + expect(JSON.parse(root_span.attributes["appsignal.function.parameters"])) + .to eq("param1" => "value1") end end context "without transaction" do - it "does not add session data to any transaction" do - Appsignal.set_session_data("a" => "b") + it "does not set the function parameters on any transaction" do + expect_any_instance_of(Appsignal::Transaction).to_not receive(:add_function_parameters) - expect_any_instance_of(Appsignal::Transaction).to_not receive(:add_session_data) + Appsignal.add_function_parameters("a" => "b") end end end - describe ".add_headers" do - before { start_agent } - - it "has a .set_headers alias" do - expect(Appsignal.method(:add_headers)).to eq(Appsignal.method(:set_headers)) + describe ".add_query_parameters" do + before do |example| + start_agent unless example.metadata[:agent_mode] || example.metadata[:collector_mode] end - context "with transaction" do + describe "setting the query parameters through the public API" do let(:transaction) { http_request_transaction } - before { set_current_transaction(transaction) } - - it "adds request headers to the transaction" do - Appsignal.add_headers("PATH_INFO" => "/some-path") - transaction._sample - expect(transaction).to include_environment("PATH_INFO" => "/some-path") + def perform + set_current_transaction(transaction) + Appsignal.add_query_parameters("param1" => "value1") end - it "merges the request headers if called multiple times" do - Appsignal.add_headers("PATH_INFO" => "/some-path") - Appsignal.add_headers("REQUEST_METHOD" => "GET") + it "in agent mode", :agent_mode do + start_agent + perform transaction._sample - expect(transaction).to include_environment( - "PATH_INFO" => "/some-path", - "REQUEST_METHOD" => "GET" - ) + expect(transaction).to include_params("param1" => "value1") end - it "adds request headers with a block to the transaction" do - Appsignal.add_headers { { "PATH_INFO" => "/some-path" } } + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete - transaction._sample - expect(transaction).to include_environment("PATH_INFO" => "/some-path") + expect(JSON.parse(root_span.attributes["appsignal.request.query_parameters"])) + .to eq("param1" => "value1") end end context "without transaction" do - it "does not add request headers to any transaction" do - Appsignal.add_headers("PATH_INFO" => "/some-path") + it "does not set the query parameters on any transaction" do + expect_any_instance_of(Appsignal::Transaction).to_not receive(:add_query_parameters) - expect_any_instance_of(Appsignal::Transaction).to_not receive(:add_headers) + Appsignal.add_query_parameters("a" => "b") end end end - describe ".add_custom_data" do - before { start_agent } - - it "has a .set_custom_data alias" do - expect(Appsignal.method(:add_custom_data)).to eq(Appsignal.method(:set_custom_data)) - end - - context "with transaction" do + describe ".set_empty_params!" do + describe "marking parameters to be sent as an empty value" do let(:transaction) { http_request_transaction } - before { set_current_transaction transaction } - it "adds custom data to the current transaction" do - Appsignal.add_custom_data( - :user => { :id => 123 }, - :organization => { :slug => "appsignal" } - ) - - transaction._sample - expect(transaction).to include_custom_data( - "user" => { "id" => 123 }, - "organization" => { "slug" => "appsignal" } - ) + def perform + set_current_transaction(transaction) + Appsignal.add_params("key1" => "value") + Appsignal.set_empty_params! end - it "merges the custom data if called multiple times" do - Appsignal.add_custom_data(:abc => "value") - Appsignal.add_custom_data(:def => "value") + it "in agent mode", :agent_mode do + start_agent + perform transaction._sample - expect(transaction).to include_custom_data( - "abc" => "value", - "def" => "value" - ) + expect(transaction).to_not include_params end - end - context "without transaction" do - it "does not add tags any the transaction" do - Appsignal.add_custom_data( - :user => { :id => 123 }, - :organization => { :slug => "appsignal" } - ) + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete - expect_any_instance_of(Appsignal::Transaction).to_not receive(:add_custom_data) + expect(root_span.attributes).to_not have_key("appsignal.request.payload") end end end - describe ".add_breadcrumb" do - before { start_agent } + describe ".add_session_data" do + before do |example| + start_agent unless example.metadata[:agent_mode] || example.metadata[:collector_mode] + end - context "with transaction" do + it "has a .set_session_data alias" do + expect(Appsignal.method(:add_session_data)).to eq(Appsignal.method(:set_session_data)) + end + + describe "adding session data through the public API" do let(:transaction) { http_request_transaction } - before { set_current_transaction(transaction) } - it "adds the breadcrumb to the transaction" do - Appsignal.add_breadcrumb( - "Network", - "http", - "User made network request", - { :response => 200 }, - fixed_time - ) + def perform + set_current_transaction(transaction) + Appsignal.add_session_data("data" => "value1") + end + + it "in agent mode", :agent_mode do + start_agent + perform transaction._sample - expect(transaction).to include_breadcrumb( - "http", - "Network", - "User made network request", - { "response" => 200 }, - fixed_time - ) + expect(transaction).to include_session_data("data" => "value1") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + expect(JSON.parse(root_span.attributes["appsignal.request.session_data"])) + .to eq("data" => "value1") + end + end + + context "with transaction" do + let(:transaction) { http_request_transaction } + + describe "merging the session data if called multiple times" do + def perform + set_current_transaction(transaction) + Appsignal.set_session_data("data1" => "value1") + Appsignal.set_session_data("data2" => "value2") + end + + it "in agent mode", :agent_mode do + start_agent + perform + + transaction._sample + expect(transaction).to include_session_data( + "data1" => "value1", + "data2" => "value2" + ) + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + expect(JSON.parse(root_span.attributes["appsignal.request.session_data"])) + .to eq("data1" => "value1", "data2" => "value2") + end + end + + describe "adding session data with a block" do + def perform + set_current_transaction(transaction) + Appsignal.set_session_data { { "data" => "value1" } } + end + + it "in agent mode", :agent_mode do + start_agent + perform + + transaction._sample + expect(transaction).to include_session_data("data" => "value1") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + expect(JSON.parse(root_span.attributes["appsignal.request.session_data"])) + .to eq("data" => "value1") + end end end context "without transaction" do - let(:transaction) { nil } + it "does not add session data to any transaction" do + Appsignal.set_session_data("a" => "b") - it "does not add a breadcrumb to any transaction" do - expect(Appsignal.add_breadcrumb("Network", "http")).to be_falsy + expect_any_instance_of(Appsignal::Transaction).to_not receive(:add_session_data) end end end - describe "custom metrics" do - let(:tags) { { :foo => "bar" } } + describe ".add_headers" do + before do |example| + start_agent unless example.metadata[:agent_mode] || example.metadata[:collector_mode] + end - describe ".set_gauge" do - it "should call set_gauge on the extension with a string key and float" do - expect(Appsignal::Extension).to receive(:set_gauge) - .with("key", 0.1, Appsignal::Extension.data_map_new) - Appsignal.set_gauge("key", 0.1) + it "has a .set_headers alias" do + expect(Appsignal.method(:add_headers)).to eq(Appsignal.method(:set_headers)) + end + + describe "adding request headers through the public API" do + let(:transaction) { http_request_transaction } + + def perform + set_current_transaction(transaction) + Appsignal.add_headers("HTTP_ACCEPT" => "text/html") end - it "should call set_gauge with tags" do - expect(Appsignal::Extension).to receive(:set_gauge) - .with("key", 0.1, Appsignal::Utils::Data.generate(tags)) - Appsignal.set_gauge("key", 0.1, tags) + it "in agent mode", :agent_mode do + start_agent + perform + + transaction._sample + expect(transaction).to include_environment("HTTP_ACCEPT" => "text/html") end - it "should call set_gauge on the extension with a symbol key and int" do - expect(Appsignal::Extension).to receive(:set_gauge) - .with("key", 1.0, Appsignal::Extension.data_map_new) - Appsignal.set_gauge(:key, 1) + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + # True headers are normalized to the OTel http.request.header.* convention. + expect(root_span.attributes["http.request.header.accept"]).to eq("text/html") end + end - it "should not raise an exception when out of range" do - expect(Appsignal::Extension).to receive(:set_gauge).with( - "key", - 10, - Appsignal::Extension.data_map_new - ).and_raise(RangeError) - expect(Appsignal.internal_logger).to receive(:warn) - .with("The gauge value '10' for metric 'key' is too big") + context "with transaction" do + let(:transaction) { http_request_transaction } - Appsignal.set_gauge("key", 10) + # Uses true HTTP headers (rather than CGI vars like PATH_INFO) because + # collector mode only emits the HTTP_*/CONTENT_* headers as + # http.request.header.* attributes and drops the rest. + describe "merging the request headers if called multiple times" do + def perform + set_current_transaction(transaction) + Appsignal.add_headers("HTTP_ACCEPT" => "text/html") + Appsignal.add_headers("HTTP_ACCEPT_CHARSET" => "utf-8") + end + + it "in agent mode", :agent_mode do + start_agent + perform + + transaction._sample + expect(transaction).to include_environment( + "HTTP_ACCEPT" => "text/html", + "HTTP_ACCEPT_CHARSET" => "utf-8" + ) + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + expect(root_span.attributes["http.request.header.accept"]).to eq("text/html") + expect(root_span.attributes["http.request.header.accept-charset"]).to eq("utf-8") + end + end + + describe "adding request headers with a block" do + def perform + set_current_transaction(transaction) + Appsignal.add_headers { { "HTTP_ACCEPT" => "text/html" } } + end + + it "in agent mode", :agent_mode do + start_agent + perform + + transaction._sample + expect(transaction).to include_environment("HTTP_ACCEPT" => "text/html") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + expect(root_span.attributes["http.request.header.accept"]).to eq("text/html") + end end end - describe ".increment_counter" do - it "should call increment_counter on the extension with a string key" do - expect(Appsignal::Extension).to receive(:increment_counter) - .with("key", 1, Appsignal::Extension.data_map_new) - Appsignal.increment_counter("key") + context "without transaction" do + it "does not add request headers to any transaction" do + Appsignal.add_headers("PATH_INFO" => "/some-path") + + expect_any_instance_of(Appsignal::Transaction).to_not receive(:add_headers) end + end + end - it "should call increment_counter with tags" do - expect(Appsignal::Extension).to receive(:increment_counter) - .with("key", 1, Appsignal::Utils::Data.generate(tags)) - Appsignal.increment_counter("key", 1, tags) + describe ".add_custom_data" do + before do |example| + start_agent unless example.metadata[:agent_mode] || example.metadata[:collector_mode] + end + + it "has a .set_custom_data alias" do + expect(Appsignal.method(:add_custom_data)).to eq(Appsignal.method(:set_custom_data)) + end + + describe "adding custom data through the public API" do + let(:transaction) { http_request_transaction } + + def perform + set_current_transaction(transaction) + Appsignal.add_custom_data( + :user => { :id => 123 }, + :organization => { :slug => "appsignal" } + ) end - it "should call increment_counter on the extension with a symbol key" do - expect(Appsignal::Extension).to receive(:increment_counter) - .with("key", 1, Appsignal::Extension.data_map_new) - Appsignal.increment_counter(:key) + it "in agent mode", :agent_mode do + start_agent + perform + + transaction._sample + expect(transaction).to include_custom_data( + "user" => { "id" => 123 }, + "organization" => { "slug" => "appsignal" } + ) end - it "should call increment_counter on the extension with a count" do - expect(Appsignal::Extension).to receive(:increment_counter) - .with("key", 5, Appsignal::Extension.data_map_new) - Appsignal.increment_counter("key", 5) + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + expect(JSON.parse(root_span.attributes["appsignal.custom_data"])).to eq( + "user" => { "id" => 123 }, + "organization" => { "slug" => "appsignal" } + ) end + end - it "should not raise an exception when out of range" do - expect(Appsignal::Extension).to receive(:increment_counter) - .with("key", 10, Appsignal::Extension.data_map_new).and_raise(RangeError) - expect(Appsignal.internal_logger).to receive(:warn) - .with("The counter value '10' for metric 'key' is too big") + context "with transaction" do + let(:transaction) { http_request_transaction } - Appsignal.increment_counter("key", 10) + describe "merging the custom data if called multiple times" do + def perform + set_current_transaction(transaction) + Appsignal.add_custom_data(:abc => "value") + Appsignal.add_custom_data(:def => "value") + end + + it "in agent mode", :agent_mode do + start_agent + perform + + transaction._sample + expect(transaction).to include_custom_data( + "abc" => "value", + "def" => "value" + ) + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + expect(JSON.parse(root_span.attributes["appsignal.custom_data"])) + .to eq("abc" => "value", "def" => "value") + end end end - describe ".add_distribution_value" do - it "should call add_distribution_value on the extension with a string key and float" do - expect(Appsignal::Extension).to receive(:add_distribution_value) - .with("key", 0.1, Appsignal::Extension.data_map_new) - Appsignal.add_distribution_value("key", 0.1) + context "without transaction" do + it "does not add tags any the transaction" do + Appsignal.add_custom_data( + :user => { :id => 123 }, + :organization => { :slug => "appsignal" } + ) + + expect_any_instance_of(Appsignal::Transaction).to_not receive(:add_custom_data) end + end + end - it "should call add_distribution_value with tags" do - expect(Appsignal::Extension).to receive(:add_distribution_value) - .with("key", 0.1, Appsignal::Utils::Data.generate(tags)) - Appsignal.add_distribution_value("key", 0.1, tags) + describe ".add_breadcrumb" do + before do |example| + start_agent unless example.metadata[:agent_mode] || example.metadata[:collector_mode] + end + + describe "adding a breadcrumb through the public API" do + let(:transaction) { http_request_transaction } + + def perform + set_current_transaction(transaction) + Appsignal.add_breadcrumb( + "Network", + "http", + "User made network request", + { :response => 200 }, + fixed_time + ) end - it "should call add_distribution_value on the extension with a symbol key and int" do - expect(Appsignal::Extension).to receive(:add_distribution_value) - .with("key", 1.0, Appsignal::Extension.data_map_new) - Appsignal.add_distribution_value(:key, 1) + it "in agent mode", :agent_mode do + start_agent + perform + + transaction.complete + expect(transaction).to include_breadcrumb( + "http", + "Network", + "User made network request", + { "response" => 200 }, + fixed_time + ) end - it "should not raise an exception when out of range" do - expect(Appsignal::Extension).to receive(:add_distribution_value) - .with("key", 10, Appsignal::Extension.data_map_new).and_raise(RangeError) - expect(Appsignal.internal_logger).to receive(:warn) - .with("The distribution value '10' for metric 'key' is too big") + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete - Appsignal.add_distribution_value("key", 10) + # Breadcrumbs are emitted as `appsignal.breadcrumb` span events. + breadcrumb = root_span.events.find { |e| e.name == "appsignal.breadcrumb" } + expect(breadcrumb).not_to be_nil + expect(breadcrumb.attributes["category"]).to eq("Network") + expect(breadcrumb.attributes["action"]).to eq("http") + expect(breadcrumb.attributes["message"]).to eq("User made network request") + expect(JSON.parse(breadcrumb.attributes["metadata"])).to eq("response" => 200) + end + end + + context "without transaction" do + let(:transaction) { nil } + + it "does not add a breadcrumb to any transaction" do + expect(Appsignal.add_breadcrumb("Network", "http")).to be_falsy end end end @@ -1634,15 +2268,75 @@ def on_start keep_transactions { example.run } end - it "sends the error to AppSignal" do - expect { Appsignal.send_error(error) }.to(change { created_transactions.count }.by(1)) + describe "OpenTelemetry attributes" do + it "threads the OpenTelemetry attributes to the created transaction" do + start_agent + allow(Appsignal::Transaction).to receive(:new).and_call_original - transaction = last_transaction - expect(transaction).to have_namespace(Appsignal::Transaction::HTTP_REQUEST) - expect(transaction).to_not have_action - expect(transaction).to have_error("ExampleException", "error message") - expect(transaction).to_not include_tags - expect(transaction).to be_completed + otel_context = "some-otel-context" + Appsignal.send_error( + error, + :opentelemetry_context => otel_context, + :opentelemetry_kind => :consumer, + :opentelemetry_relationship => :both + ) + + expect(Appsignal::Transaction).to have_received(:new).with( + Appsignal::Transaction::HTTP_REQUEST, + :opentelemetry_context => otel_context, + :opentelemetry_scope => nil, + :opentelemetry_kind => :consumer, + :opentelemetry_relationship => :both + ) + end + + it "uses the given opentelemetry_kind for the span", :collector_mode do + start_collector_agent + + Appsignal.send_error(error, :opentelemetry_kind => :consumer) + + expect(root_span.kind).to eq(:consumer) + end + end + + describe "sending the error" do + def perform + Appsignal.send_error(error) + end + + it "in agent mode", :agent_mode do + start_agent + expect { perform }.to(change { created_transactions.count }.by(1)) + + transaction = last_transaction + expect(transaction).to have_namespace(Appsignal::Transaction::HTTP_REQUEST) + expect(transaction).to_not have_action + expect(transaction).to have_error("ExampleException", "error message") + expect(transaction).to_not include_tags + expect(transaction).to be_completed + end + + it "in collector mode", :collector_mode do + start_collector_agent + # send_error completes its throwaway transaction inline, so the root + # span is already finished and exported. + perform + + expect(root_span).not_to be_nil + # HTTP_REQUEST maps to a SERVER span (a subtrace root). + expect(root_span.kind).to eq(:server) + expect(root_span.attributes["appsignal.namespace"]) + .to eq("web") + expect(root_span.attributes).not_to have_key("appsignal.action_name") + + event = root_span.events.find { |e| e.name == "exception" } + expect(event).not_to be_nil + expect(event.attributes["exception.type"]).to eq("ExampleException") + expect(event.attributes["exception.message"]).to eq("error message") + expect(event.attributes["exception.stacktrace"]).to be_a(String) + expect(event.attributes["appsignal.alert_this_error"]).to eq(true) + expect(root_span.status.code).to eq(::OpenTelemetry::Trace::Status::ERROR) + end end context "when given error is not an Exception" do @@ -1663,51 +2357,131 @@ def on_start end context "when given a block" do - it "yields the transaction and allows additional metadata to be set" do - Appsignal.send_error(StandardError.new("my_error")) do |transaction| - transaction.set_action("my_action") - transaction.set_namespace("my_namespace") + describe "yielding the transaction to set metadata" do + def perform + Appsignal.send_error(StandardError.new("my_error")) do |transaction| + transaction.set_action("my_action") + transaction.set_namespace("my_namespace") + end + end + + it "in agent mode", :agent_mode do + start_agent + perform + + expect(last_transaction).to have_namespace("my_namespace") + expect(last_transaction).to have_action("my_action") + expect(last_transaction).to have_error("StandardError", "my_error") end - expect(last_transaction).to have_namespace("my_namespace") - expect(last_transaction).to have_action("my_action") - expect(last_transaction).to have_error("StandardError", "my_error") + it "in collector mode", :collector_mode do + start_collector_agent + perform + + expect(root_span.name).to eq("my_action") + expect(root_span.attributes["appsignal.action_name"]).to eq("my_action") + expect(root_span.attributes["appsignal.namespace"]).to eq("my_namespace") + + event = root_span.events.find { |e| e.name == "exception" } + expect(event).not_to be_nil + expect(event.attributes["exception.type"]).to eq("StandardError") + expect(event.attributes["exception.message"]).to eq("my_error") + expect(event.attributes["appsignal.alert_this_error"]).to eq(true) + expect(root_span.status.code).to eq(::OpenTelemetry::Trace::Status::ERROR) + end end - it "yields and allows additional metadata to be set with global helpers" do - Appsignal.send_error(StandardError.new("my_error")) do - Appsignal.set_action("my_action") - Appsignal.set_namespace("my_namespace") + it "logs a raising block without raising, naming the helper" do + logs = capture_logs do + expect do + Appsignal.send_error(StandardError.new("my_error")) do + raise ExampleStandardError, "metadata boom" + end + end.to_not raise_error end - expect(last_transaction).to have_namespace("my_namespace") - expect(last_transaction).to have_action("my_action") - expect(last_transaction).to have_error("StandardError", "my_error") + expect(logs).to contains_log( + :error, + /Error in the block passed to Appsignal\.send_error, defined at .+metadata boom/ + ) end - it "yields to set metadata and doesn't modify the active transaction" do - active_transaction = http_request_transaction - active_transaction.set_action("active action") - active_transaction.set_namespace("active namespace") - set_current_transaction(active_transaction) - expect(current_transaction).to eq(active_transaction) + # The block uses the global helpers, which act on the current + # transaction. send_error runs the block against its own transaction, so + # the block's metadata and the error must land there, not on the + # transaction that was active when send_error was called. That active + # transaction must be restored afterwards and left untouched. + describe "yielding to set metadata with global helpers, " \ + "with an active transaction" do + def perform + active_transaction = create_transaction(Appsignal::Transaction::HTTP_REQUEST) + active_transaction.set_action("active action") + active_transaction.set_namespace("active namespace") + + Appsignal.send_error(StandardError.new("my_error")) do + Appsignal.set_action("my_action") + Appsignal.set_namespace("my_namespace") + Appsignal.add_tags(:block_tag => "value") + end - Appsignal.send_error(StandardError.new("my_error")) do - Appsignal.set_action("my_action") - Appsignal.set_namespace("my_namespace") + active_transaction end - # Restores the active_transaction as the current transaction - expect(current_transaction).to eq(active_transaction) + it "in agent mode", :agent_mode do + start_agent + active_transaction = perform - expect(last_transaction).to have_namespace("my_namespace") - expect(last_transaction).to have_action("my_action") - expect(last_transaction).to have_error("StandardError", "my_error") - expect(last_transaction).to be_completed + # The active transaction is restored as the current transaction. + expect(current_transaction).to eq(active_transaction) + + # The block ran against send_error's own transaction. + expect(last_transaction).to have_namespace("my_namespace") + expect(last_transaction).to have_action("my_action") + expect(last_transaction).to include_tags("block_tag" => "value") + expect(last_transaction).to have_error("StandardError", "my_error") + expect(last_transaction).to be_completed - expect(active_transaction).to have_namespace("active namespace") - expect(active_transaction).to have_action("active action") - expect(active_transaction).to_not be_completed + # The active transaction is untouched. + expect(active_transaction).to have_namespace("active namespace") + expect(active_transaction).to have_action("active action") + expect(active_transaction).to_not include_tags + expect(active_transaction).to_not be_completed + end + + it "in collector mode", :collector_mode do + start_collector_agent + active_transaction = perform + + # send_error restores the active transaction as both the current + # AppSignal transaction and the current OpenTelemetry span, so the + # active trace continues rather than being dropped or left open. + expect(current_transaction).to eq(active_transaction) + active_trace_id = ::OpenTelemetry::Trace.current_span.context.trace_id + + # Finish the active transaction so its root span is exported too. + Appsignal::Transaction.complete_current! + + server_spans = span_exporter.finished_spans.select { |s| s.kind == :server } + error_span = server_spans.find { |s| s.name == "my_action" } + active_span = server_spans.find { |s| s.name == "active action" } + expect(error_span).not_to be_nil + expect(active_span).not_to be_nil + + # send_error reports on its own transaction, a separate trace from + # the active one, which continues on the trace that stayed current. + expect(active_span.trace_id).to eq(active_trace_id) + expect(error_span.trace_id).not_to eq(active_trace_id) + + # The block's metadata and the error land on send_error's trace. + expect(error_span.attributes["appsignal.namespace"]).to eq("my_namespace") + expect(error_span.attributes["appsignal.tag.block_tag"]).to eq("value") + expect(Array(error_span.events).map(&:name)).to include("exception") + + # The active trace is untouched: no block metadata, no error. + expect(active_span.attributes["appsignal.namespace"]).to eq("active namespace") + expect(active_span.attributes).to_not have_key("appsignal.tag.block_tag") + expect(Array(active_span.events).map(&:name)).to_not include("exception") + end end end end @@ -1719,11 +2493,18 @@ def on_start let(:transaction) { http_request_transaction } around { |example| keep_transactions { example.run } } - context "when there is an active transaction" do - before { set_current_transaction(transaction) } - - it "adds the error to the active transaction" do + describe "adding the error to the active transaction" do + # `set_current_transaction` (which builds the transaction's root span) + # happens in the body, not a `before`, so in collector mode it uses the + # in-memory provider that `start_collector_agent` swaps in. + def perform + set_current_transaction(transaction) Appsignal.set_error(error) + end + + it "in agent mode", :agent_mode do + start_agent + perform transaction._sample expect(transaction).to have_namespace(Appsignal::Transaction::HTTP_REQUEST) @@ -1731,6 +2512,31 @@ def on_start expect(transaction).to_not include_tags end + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + event = root_span.events.find { |e| e.name == "exception" } + expect(event).not_to be_nil + expect(event.attributes["exception.type"]).to eq("ExampleException") + expect(event.attributes["exception.message"]).to eq("I am an exception") + expect(event.attributes["exception.stacktrace"]).to be_a(String) + expect(event.attributes["appsignal.alert_this_error"]).to eq(true) + expect(root_span.status.code).to eq(::OpenTelemetry::Trace::Status::ERROR) + end + end + + context "when there is an active transaction" do + # Mode-tagged examples set the current transaction in their own body, + # after starting the agent, so collector mode builds the root span + # against the in-memory provider. Untagged examples keep the hook. + before do |example| + unless example.metadata[:agent_mode] || example.metadata[:collector_mode] + set_current_transaction(transaction) + end + end + context "when the error is not an Exception" do let(:error) { Object.new } @@ -1752,17 +2558,42 @@ def on_start end end - context "when given a block" do - it "yields the transaction and allows additional metadata to be set" do + describe "when given a block" do + # The set_error helper yields the current transaction and runs the + # block synchronously, so the block's metadata lands on the active + # transaction in both modes. + def perform + set_current_transaction(transaction) Appsignal.set_error(StandardError.new("my_error")) do |t| t.set_action("my_action") t.set_namespace("my_namespace") end + end + + it "in agent mode", :agent_mode do + start_agent + perform expect(transaction).to have_namespace("my_namespace") expect(transaction).to have_action("my_action") expect(transaction).to have_error("StandardError", "my_error") end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + expect(root_span.name).to eq("my_action") + expect(root_span.attributes["appsignal.action_name"]).to eq("my_action") + expect(root_span.attributes["appsignal.namespace"]).to eq("my_namespace") + + event = root_span.events.find { |e| e.name == "exception" } + expect(event).not_to be_nil + expect(event.attributes["exception.type"]).to eq("StandardError") + expect(event.attributes["exception.message"]).to eq("my_error") + expect(root_span.status.code).to eq(::OpenTelemetry::Trace::Status::ERROR) + end end end @@ -1779,9 +2610,40 @@ def on_start let(:err_stream) { std_stream } let(:stderr) { err_stream.read } let(:error) { ExampleException.new("error message") } - before { start_agent } around { |example| keep_transactions { example.run } } + describe "OpenTelemetry attributes" do + it "threads the OpenTelemetry attributes to a newly created transaction" do + start_agent + allow(Appsignal::Transaction).to receive(:new).and_call_original + + otel_context = "some-otel-context" + Appsignal.report_error( + error, + :opentelemetry_context => otel_context, + :opentelemetry_kind => :consumer, + :opentelemetry_relationship => :both + ) + + expect(Appsignal::Transaction).to have_received(:new).with( + Appsignal::Transaction::HTTP_REQUEST, + :opentelemetry_context => otel_context, + :opentelemetry_scope => nil, + :opentelemetry_kind => :consumer, + :opentelemetry_relationship => :both + ) + end + + it "uses the given opentelemetry_kind for the span", :collector_mode do + start_collector_agent + + Appsignal.report_error(error, :opentelemetry_kind => :consumer) + Appsignal::Transaction.complete_current! + + expect(root_span.kind).to eq(:consumer) + end + end + context "when the error is not an Exception" do let(:error) { Object.new } @@ -1802,62 +2664,189 @@ def on_start end context "when there is no active transaction" do - it "creates a new transaction" do - expect do + describe "reporting the error" do + def perform Appsignal.report_error(error) - end.to(change { created_transactions.count }.by(1)) - end + end - it "completes the transaction" do - Appsignal.report_error(error) + it "in agent mode", :agent_mode do + start_agent + expect { perform }.to(change { created_transactions.count }.by(1)) - expect(last_transaction).to be_completed + expect(last_transaction).to have_error("ExampleException", "error message") + expect(last_transaction).to be_completed + end + + it "in collector mode", :collector_mode do + start_collector_agent + # With no active transaction, report_error creates and completes its + # own transaction, so the root span is exported. + perform + + expect(root_span).not_to be_nil + event = root_span.events.find { |e| e.name == "exception" } + expect(event).not_to be_nil + expect(event.attributes["exception.type"]).to eq("ExampleException") + expect(event.attributes["exception.message"]).to eq("error message") + expect(event.attributes["exception.stacktrace"]).to be_a(String) + expect(event.attributes["appsignal.alert_this_error"]).to eq(true) + expect(root_span.status.code).to eq(::OpenTelemetry::Trace::Status::ERROR) + end end context "when given a block" do - it "yields the transaction and allows additional metadata to be set" do - Appsignal.report_error(error) do |t| - t.set_action("my_action") - t.set_namespace("my_namespace") - t.set_tags(:tag1 => "value1") + # With no active transaction, report_error creates its own + # transaction and completes it, so the block's metadata and the error + # land on that transaction in both modes. + shared_examples "reports the metadata on its own transaction" do + it "in agent mode", :agent_mode do + start_agent + perform + + transaction = last_transaction + expect(transaction).to have_namespace("my_namespace") + expect(transaction).to have_action("my_action") + expect(transaction).to have_error("ExampleException", "error message") + expect(transaction).to include_tags("tag1" => "value1") + expect(transaction).to be_completed end - transaction = last_transaction - expect(transaction).to have_namespace("my_namespace") - expect(transaction).to have_action("my_action") - expect(transaction).to have_error("ExampleException", "error message") - expect(transaction).to include_tags("tag1" => "value1") - expect(transaction).to be_completed + it "in collector mode", :collector_mode do + start_collector_agent + perform + + expect(root_span.name).to eq("my_action") + expect(root_span.attributes["appsignal.action_name"]).to eq("my_action") + expect(root_span.attributes["appsignal.namespace"]).to eq("my_namespace") + expect(root_span.attributes["appsignal.tag.tag1"]).to eq("value1") + + event = root_span.events.find { |e| e.name == "exception" } + expect(event).not_to be_nil + expect(event.attributes["exception.type"]).to eq("ExampleException") + expect(event.attributes["exception.message"]).to eq("error message") + expect(root_span.status.code).to eq(::OpenTelemetry::Trace::Status::ERROR) + end end - it "yields and allows additional metadata to be set with the global helpers" do - Appsignal.report_error(error) do - Appsignal.set_action("my_action") - Appsignal.set_namespace("my_namespace") - Appsignal.set_tags(:tag1 => "value1") + describe "yielding the transaction" do + def perform + Appsignal.report_error(error) do |t| + t.set_action("my_action") + t.set_namespace("my_namespace") + t.set_tags(:tag1 => "value1") + end end - transaction = last_transaction - expect(transaction).to have_namespace("my_namespace") - expect(transaction).to have_action("my_action") + include_examples "reports the metadata on its own transaction" + end + + describe "with the global helpers" do + def perform + Appsignal.report_error(error) do + Appsignal.set_action("my_action") + Appsignal.set_namespace("my_namespace") + Appsignal.set_tags(:tag1 => "value1") + end + end + + include_examples "reports the metadata on its own transaction" + end + + it "logs a raising block without raising, naming the helper" do + logs = capture_logs do + expect do + Appsignal.report_error(error) do + raise ExampleStandardError, "metadata boom" + end + end.to_not raise_error + end + + expect(logs).to contains_log( + :error, + /Error in the block passed to Appsignal\.report_error, defined at .+metadata boom/ + ) + end + end + end + + context "when there is an active transaction" do + let(:transaction) { http_request_transaction } + # Only for non-mode examples. Mode-tagged examples set the current + # transaction in their own body, after starting the agent (collector + # mode swaps in the in-memory providers there). + before do |example| + unless example.metadata[:agent_mode] || example.metadata[:collector_mode] + set_current_transaction(transaction) + end + end + + describe "reporting the error onto it" do + def perform + set_current_transaction(transaction) + Appsignal.report_error(error) + end + + it "in agent mode", :agent_mode do + start_agent + perform + + expect(last_transaction).to eq(transaction) + transaction._sample + expect(transaction).to have_namespace(Appsignal::Transaction::HTTP_REQUEST) expect(transaction).to have_error("ExampleException", "error message") - expect(transaction).to include_tags("tag1" => "value1") - expect(transaction).to be_completed + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + event = root_span.events.find { |e| e.name == "exception" } + expect(event).not_to be_nil + expect(event.attributes["exception.type"]).to eq("ExampleException") + expect(event.attributes["exception.message"]).to eq("error message") + expect(event.attributes["exception.stacktrace"]).to be_a(String) + expect(event.attributes["appsignal.alert_this_error"]).to eq(true) + expect(root_span.status.code).to eq(::OpenTelemetry::Trace::Status::ERROR) end end - end - context "when there is an active transaction" do - let(:transaction) { http_request_transaction } - before { set_current_transaction(transaction) } + describe "with multiple reported errors" do + let(:other_error) do + ExampleStandardError.new("other message").tap { |e| e.set_backtrace(["line 2"]) } + end - it "sets the error in the active transaction" do - Appsignal.report_error(error) + def perform + set_current_transaction(transaction) + Appsignal.report_error(error) + Appsignal.report_error(other_error) + end - expect(last_transaction).to eq(transaction) - transaction._sample - expect(transaction).to have_namespace(Appsignal::Transaction::HTTP_REQUEST) - expect(transaction).to have_error("ExampleException", "error message") + it "in agent mode", :agent_mode do + start_agent + perform + # The extension holds one error per transaction, so the extra error + # is reported as a duplicate transaction. + expect { transaction.complete }.to(change { created_transactions.count }.by(1)) + + expect(created_transactions.map { |t| t.to_h["error"]["message"] }) + .to contain_exactly("error message", "other message") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + # One trace: a single root span carrying one exception event per error. + root_spans = span_exporter.finished_spans.select do |span| + [:server, :consumer].include?(span.kind) + end + expect(root_spans.size).to eq(1) + events = root_spans.first.events.select { |e| e.name == "exception" } + expect(events.map { |e| e.attributes["exception.message"] }) + .to contain_exactly("error message", "other message") + end end context "when the active transaction already has an error" do @@ -1895,14 +2884,20 @@ def on_start end end - it "does not complete the transaction" do + it_in_both_modes "does not complete the transaction" do + set_current_transaction(transaction) Appsignal.report_error(error) - expect(last_transaction).to_not be_completed + expect(transaction).to_not be_completed end context "when given a block" do - before do + # Agent mode defers the block to completion; the collector example + # below starts the agent and reports the error itself, so it skips + # this setup. + before do |example| + next if example.metadata[:collector_mode] + Appsignal.report_error(error) do |t| t.set_action("my_action") t.set_namespace("my_namespace") @@ -1964,6 +2959,32 @@ def on_start expect(transaction).to have_error("ExampleException", "error message") expect(transaction).to include_tags("tag1" => "value1") end + + it "applies the block to the transaction eagerly", :collector_mode do + start_collector_agent + set_current_transaction(transaction) + Appsignal.report_error(error) do |t| + t.set_action("my_action") + t.set_namespace("my_namespace") + t.set_tags(:tag1 => "value1") + end + + # Collector mode records the error and runs its block when the error + # is added, not at completion as agent mode does. So the action name + # is on the active transaction's span right away. Tags flush with the + # other sample data at completion. + expect(::OpenTelemetry::Trace.current_span.name).to eq("my_action") + + transaction.complete + expect(root_span.name).to eq("my_action") + expect(root_span.attributes["appsignal.action_name"]).to eq("my_action") + expect(root_span.attributes["appsignal.namespace"]).to eq("my_namespace") + expect(root_span.attributes["appsignal.tag.tag1"]).to eq("value1") + + event = root_span.events.find { |e| e.name == "exception" } + expect(event).not_to be_nil + expect(event.attributes["exception.message"]).to eq("error message") + end end end end @@ -1971,15 +2992,34 @@ def on_start describe ".set_action" do around { |example| keep_transactions { example.run } } - context "with current transaction" do - before { set_current_transaction(transaction) } - - it "sets the namespace on the current transaction" do + describe "setting the action on the current transaction" do + def perform + set_current_transaction(transaction) Appsignal.set_action("custom") + end + + it "in agent mode", :agent_mode do + start_agent + perform expect(transaction).to have_action("custom") end + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + expect(root_span.name).to eq("custom") + expect(root_span.attributes["appsignal.action_name"]).to eq("custom") + end + end + + # The nil and no-current-transaction cases are guarded in the helper + # before any backend, so they behave the same in both modes. + context "with current transaction" do + before { set_current_transaction(transaction) } + it "does not set the action if the action is nil" do Appsignal.set_action(nil) @@ -1988,7 +3028,7 @@ def on_start end context "without current transaction" do - it "does not set ther action" do + it "does not set the action" do Appsignal.set_action("custom") expect(transaction).to_not have_action @@ -1999,15 +3039,33 @@ def on_start describe ".set_namespace" do around { |example| keep_transactions { example.run } } - context "with current transaction" do - before { set_current_transaction(transaction) } - - it "should set the namespace to the current transaction" do + describe "setting the namespace on the current transaction" do + def perform + set_current_transaction(transaction) Appsignal.set_namespace("custom") + end + + it "in agent mode", :agent_mode do + start_agent + perform expect(transaction).to have_namespace("custom") end + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + expect(root_span.attributes["appsignal.namespace"]).to eq("custom") + end + end + + # The nil and no-current-transaction cases are guarded in the helper + # before any backend, so they behave the same in both modes. + context "with current transaction" do + before { set_current_transaction(transaction) } + it "does not update the namespace if the namespace is nil" do Appsignal.set_namespace(nil) @@ -2025,61 +3083,448 @@ def on_start end end end + end - describe ".instrument" do - it_behaves_like "instrument helper" do - let(:instrumenter) { Appsignal } - before { set_current_transaction(transaction) } + describe "custom metrics" do + let(:tags) { { :foo => "bar" } } + + describe ".set_gauge" do + describe "with a string key and float value" do + it "in agent mode", :agent_mode do + start_agent + expect(Appsignal::Extension).to receive(:set_gauge) + .with("key", 0.1, Appsignal::Extension.data_map_new) + Appsignal.set_gauge("key", 0.1) + end + end + + describe "with tags" do + def perform + Appsignal.set_gauge("key", 0.1, tags) + end + + it "in agent mode", :agent_mode do + start_agent + expect(Appsignal::Extension).to receive(:set_gauge) + .with("key", 0.1, Appsignal::Utils::Data.generate(tags)) + perform + end + + it "in collector mode", :collector_mode do + start_collector_agent + allow(Appsignal::Metrics::OpenTelemetryBackend).to receive(:set_gauge) + expect(Appsignal::Extension).not_to receive(:set_gauge) + perform + expect(Appsignal::Metrics::OpenTelemetryBackend).to have_received(:set_gauge) + .with("key", 0.1, tags) + end + end + + describe "with a symbol key and int value" do + it "in agent mode", :agent_mode do + start_agent + expect(Appsignal::Extension).to receive(:set_gauge) + .with("key", 1.0, Appsignal::Extension.data_map_new) + Appsignal.set_gauge(:key, 1) + end + end + + describe "when the value is out of range" do + it "in agent mode", :agent_mode do + start_agent + expect(Appsignal::Extension).to receive(:set_gauge).with( + "key", + 10, + Appsignal::Extension.data_map_new + ).and_raise(RangeError) + expect(Appsignal.internal_logger).to receive(:warn) + .with("The gauge value '10' for metric 'key' is too big") + + Appsignal.set_gauge("key", 10) + end end end - describe ".instrument_sql" do - around { |example| keep_transactions { example.run } } - before { set_current_transaction(transaction) } + describe ".increment_counter" do + describe "with a string key" do + it "in agent mode", :agent_mode do + start_agent + expect(Appsignal::Extension).to receive(:increment_counter) + .with("key", 1, Appsignal::Extension.data_map_new) + Appsignal.increment_counter("key") + end + end - it "creates an SQL event on the transaction" do - result = - Appsignal.instrument_sql "name", "title", "body" do - "return value" - end + describe "with tags" do + def perform + Appsignal.increment_counter("key", 5, tags) + end + + it "in agent mode", :agent_mode do + start_agent + expect(Appsignal::Extension).to receive(:increment_counter) + .with("key", 5, Appsignal::Utils::Data.generate(tags)) + perform + end + + it "in collector mode", :collector_mode do + start_collector_agent + allow(Appsignal::Metrics::OpenTelemetryBackend).to receive(:increment_counter) + expect(Appsignal::Extension).not_to receive(:increment_counter) + perform + expect(Appsignal::Metrics::OpenTelemetryBackend).to have_received(:increment_counter) + .with("key", 5, tags) + end + end + + describe "with a symbol key" do + it "in agent mode", :agent_mode do + start_agent + expect(Appsignal::Extension).to receive(:increment_counter) + .with("key", 1, Appsignal::Extension.data_map_new) + Appsignal.increment_counter(:key) + end + end + + describe "with a count" do + it "in agent mode", :agent_mode do + start_agent + expect(Appsignal::Extension).to receive(:increment_counter) + .with("key", 5, Appsignal::Extension.data_map_new) + Appsignal.increment_counter("key", 5) + end + end + + describe "when the value is out of range" do + it "in agent mode", :agent_mode do + start_agent + expect(Appsignal::Extension).to receive(:increment_counter) + .with("key", 10, Appsignal::Extension.data_map_new).and_raise(RangeError) + expect(Appsignal.internal_logger).to receive(:warn) + .with("The counter value '10' for metric 'key' is too big") + + Appsignal.increment_counter("key", 10) + end + end + end + + describe ".add_distribution_value" do + describe "with a string key and float value" do + it "in agent mode", :agent_mode do + start_agent + expect(Appsignal::Extension).to receive(:add_distribution_value) + .with("key", 0.1, Appsignal::Extension.data_map_new) + Appsignal.add_distribution_value("key", 0.1) + end + end + + describe "with tags" do + def perform + Appsignal.add_distribution_value("key", 0.1, tags) + end + + it "in agent mode", :agent_mode do + start_agent + expect(Appsignal::Extension).to receive(:add_distribution_value) + .with("key", 0.1, Appsignal::Utils::Data.generate(tags)) + perform + end + + it "in collector mode", :collector_mode do + start_collector_agent + allow(Appsignal::Metrics::OpenTelemetryBackend).to receive(:add_distribution_value) + expect(Appsignal::Extension).not_to receive(:add_distribution_value) + perform + expect(Appsignal::Metrics::OpenTelemetryBackend).to have_received(:add_distribution_value) + .with("key", 0.1, tags) + end + end + + describe "with a symbol key and int value" do + it "in agent mode", :agent_mode do + start_agent + expect(Appsignal::Extension).to receive(:add_distribution_value) + .with("key", 1.0, Appsignal::Extension.data_map_new) + Appsignal.add_distribution_value(:key, 1) + end + end + + describe "when the value is out of range" do + it "in agent mode", :agent_mode do + start_agent + expect(Appsignal::Extension).to receive(:add_distribution_value) + .with("key", 10, Appsignal::Extension.data_map_new).and_raise(RangeError) + expect(Appsignal.internal_logger).to receive(:warn) + .with("The distribution value '10' for metric 'key' is too big") + + Appsignal.add_distribution_value("key", 10) + end + end + end + end + + describe ".instrument" do + describe "block return value" do + it_in_both_modes do + set_current_transaction(transaction) + + result = Appsignal.instrument("name", "title", "body") { "return value" } + + expect(result).to eq("return value") + end + end + + describe "recording an event around the block" do + def perform + Appsignal.instrument("name", "title", "body") { :do_nothing } + end - expect(result).to eq "return value" + it "in agent mode", :agent_mode do + start_agent + set_current_transaction(transaction) + perform expect(transaction).to include_event( "name" => "name", "title" => "title", "body" => "body", - "body_format" => Appsignal::EventFormatter::SQL_BODY_FORMAT + "body_format" => Appsignal::EventFormatter::DEFAULT ) end + + it "in collector mode", :collector_mode do + start_collector_agent + set_current_transaction(transaction) + perform + Appsignal::Transaction.complete_current! + + expect(event_spans.size).to eq(1) + span = event_spans.first + expect(span.name).to eq("name (title)") + expect(span.parent_span_id).to eq(root_span.span_id) + expect(span.attributes).not_to have_key("appsignal.category") + expect(span.attributes["appsignal.body"]).to eq("body") + expect(span.attributes).not_to have_key("db.query.text") + expect(span.attributes).not_to have_key("db.system.name") + expect(span.attributes).not_to have_key("error.type") + end end - describe ".ignore_instrumentation_events" do - around { |example| keep_transactions { example.run } } - let(:transaction) { http_request_transaction } + describe "when an error is raised in the block" do + def perform + expect do + Appsignal.instrument("name", "title", "body") { raise ExampleException, "foo" } + end.to raise_error(ExampleException, "foo") + end - context "with current transaction" do - before { set_current_transaction(transaction) } + it "in agent mode", :agent_mode do + start_agent + set_current_transaction(transaction) + perform + expect(transaction).to include_event( + "name" => "name", "title" => "title", "body" => "body" + ) + end + + it "in collector mode", :collector_mode do + start_collector_agent + set_current_transaction(transaction) + perform + Appsignal::Transaction.complete_current! - it "does not record events on the transaction" do - expect(transaction).to receive(:pause!).and_call_original - expect(transaction).to receive(:resume!).and_call_original + expect(event_spans.size).to eq(1) + span = event_spans.first + expect(span.name).to eq("name (title)") + expect(span.attributes).not_to have_key("appsignal.category") + expect(span.attributes["appsignal.body"]).to eq("body") + # The block raised, so the operation the span describes failed. + expect(span.attributes["error.type"]).to eq("ExampleException") + end + end - Appsignal.instrument("register.this.event") { :do_nothing } + describe "when an error is raised in the block of an ignored event" do + def perform + expect do Appsignal.ignore_instrumentation_events do - Appsignal.instrument("dont.register.this.event") { :do_nothing } + Appsignal.instrument("name", "title", "body") { raise ExampleException, "foo" } end + end.to raise_error(ExampleException, "foo") + end - expect(transaction).to include_event("name" => "register.this.event") - expect(transaction).to_not include_event("name" => "dont.register.this.event") + it "in agent mode", :agent_mode do + start_agent + set_current_transaction(transaction) + perform + + expect(transaction).to_not include_event("name" => "name") + end + + it "in collector mode", :collector_mode do + start_collector_agent + set_current_transaction(transaction) + perform + Appsignal::Transaction.complete_current! + + # No event span was started, so there is no span of this event's to + # describe. The failure must not end up on the transaction's own span + # instead. + expect(event_spans).to be_empty + expect(root_span.attributes).not_to have_key("error.type") + end + end + + describe "when a symbol is thrown in the block" do + def perform + expect do + Appsignal.instrument("name", "title", "body") { throw :foo } + end.to throw_symbol(:foo) + end + + it "in agent mode", :agent_mode do + start_agent + set_current_transaction(transaction) + perform + expect(transaction).to include_event( + "name" => "name", "title" => "title", "body" => "body" + ) + end + + it "in collector mode", :collector_mode do + start_collector_agent + set_current_transaction(transaction) + perform + Appsignal::Transaction.complete_current! + + expect(event_spans.size).to eq(1) + span = event_spans.first + expect(span.name).to eq("name (title)") + expect(span.attributes).not_to have_key("appsignal.category") + expect(span.attributes["appsignal.body"]).to eq("body") + # Throwing a symbol is control flow rather than a failure, so there is + # no kind of failure to report. + expect(span.attributes).not_to have_key("error.type") + end + end + end + + describe ".instrument_sql" do + describe "recording a SQL event around the block" do + def perform + Appsignal.instrument_sql("name", "title", "body") { "return value" } + end + + it "in agent mode", :agent_mode do + start_agent + set_current_transaction(transaction) + + expect(perform).to eq("return value") + expect(transaction).to include_event( + "name" => "name", + "title" => "title", + "body" => "body", + "body_format" => Appsignal::EventFormatter::SQL_BODY_FORMAT + ) + end + + it "in collector mode", :collector_mode do + start_collector_agent + set_current_transaction(transaction) + + expect(perform).to eq("return value") + Appsignal::Transaction.complete_current! + + expect(event_spans.size).to eq(1) + span = event_spans.first + expect(span.name).to eq("name (title)") + expect(span.parent_span_id).to eq(root_span.span_id) + expect(span.attributes).not_to have_key("appsignal.category") + expect(span.attributes["db.query.text"]).to eq("body") + expect(span.attributes["db.system.name"]).to eq("other_sql") + expect(span.attributes).not_to have_key("appsignal.body") + end + end + + describe "the OpenTelemetry span kind" do + it "is client when no kind is given", :collector_mode do + start_collector_agent + set_current_transaction(transaction) + + Appsignal.instrument_sql("name", "title", "body") { :do_nothing } + Appsignal::Transaction.complete_current! + + expect(event_spans.size).to eq(1) + expect(event_spans.first.kind).to eq(:client) + end + + it "is the given kind when one is given", :collector_mode do + start_collector_agent + set_current_transaction(transaction) + + Appsignal.instrument_sql( + "name", "title", "body", + :opentelemetry_kind => :internal + ) { :do_nothing } + Appsignal::Transaction.complete_current! + + expect(event_spans.size).to eq(1) + expect(event_spans.first.kind).to eq(:internal) + end + + it "is internal when the kind is explicitly nil", :collector_mode do + start_collector_agent + set_current_transaction(transaction) + + Appsignal.instrument_sql( + "name", "title", "body", + :opentelemetry_kind => nil + ) { :do_nothing } + Appsignal::Transaction.complete_current! + + # The default only applies when the argument is omitted. An explicit + # `nil` means "unspecified", which uses the OpenTelemetry default. + expect(event_spans.size).to eq(1) + expect(event_spans.first.kind).to eq(:internal) + end + end + end + + describe ".ignore_instrumentation_events" do + describe "with a current transaction" do + it "in agent mode", :agent_mode do + start_agent + set_current_transaction(transaction) + expect(transaction).to receive(:pause!).and_call_original + expect(transaction).to receive(:resume!).and_call_original + + Appsignal.instrument("register.this.event") { :do_nothing } + Appsignal.ignore_instrumentation_events do + Appsignal.instrument("dont.register.this.event") { :do_nothing } end + + expect(transaction).to include_event("name" => "register.this.event") + expect(transaction).to_not include_event("name" => "dont.register.this.event") end - context "without current transaction" do - let(:transaction) { nil } + it "in collector mode", :collector_mode do + start_collector_agent + set_current_transaction(transaction) - it "does not crash" do - Appsignal.ignore_instrumentation_events { :do_nothing } + Appsignal.instrument("register.this.event") { :do_nothing } + Appsignal.ignore_instrumentation_events do + Appsignal.instrument("dont.register.this.event") { :do_nothing } end + Appsignal::Transaction.complete_current! + + names = event_spans.map(&:name) + expect(names).to include("register.this.event") + expect(names).not_to include("dont.register.this.event") + end + end + + describe "without a current transaction" do + it_in_both_modes do + expect do + Appsignal.ignore_instrumentation_events { :do_nothing } + end.not_to raise_error end end end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index d4708cadf..b3ebcbcae 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -29,6 +29,9 @@ Dir[File.join(APPSIGNAL_SPEC_DIR, "support/shared_examples", "*.rb")].sort.each do |f| require f end +Dir[File.join(APPSIGNAL_SPEC_DIR, "support/shared_contexts", "*.rb")].sort.each do |f| + require f +end if DependencyHelper.rails_present? require File.join(ConfigHelpers.rails_project_fixture_path, "config/application.rb") end @@ -78,7 +81,11 @@ def clear_subscribers config.exclude_pattern = "spec/integration/diagnose/**/*_spec.rb" config.filter_run_excluding( :extension_installation_failure => true, - :jruby => !DependencyHelper.running_jruby? + :jruby => !DependencyHelper.running_jruby?, + # The OpenTelemetry gems are optional. When they're not installed (e.g. on + # Ruby 2.7, or any non-`-collector` gemfile), skip every collector-mode + # example rather than crashing on missing constants. + :collector_mode => !DependencyHelper.opentelemetry_present? ) config.mock_with :rspec do |mocks| mocks.syntax = :expect @@ -92,7 +99,23 @@ def spec_system_tmp_dir end config.before :suite do - WebMock.disable_net_connect! + # The OTLP mock server is only needed by collector-mode specs, which only + # run when the OpenTelemetry gems are installed. Always disable real network + # connections; when those specs run, relax the rule just enough to reach the + # mock server bound by `OTLPCollectorServer`. + if DependencyHelper.opentelemetry_present? + # Boot first: the server binds an OS-assigned port, so its address is only + # known afterwards. + OTLPCollectorServer.boot! + WebMock.disable_net_connect!(:allow => "127.0.0.1:#{OTLPCollectorServer.port}") + else + WebMock.disable_net_connect! + end + end + + config.after do + OTLPCollectorServer.clear if defined?(OTLPCollectorServer) + Appsignal::OpenTelemetry.reset! end config.before :context do diff --git a/spec/support/helpers/dependency_helper.rb b/spec/support/helpers/dependency_helper.rb index 8384aa44c..e7c786b5c 100644 --- a/spec/support/helpers/dependency_helper.rb +++ b/spec/support/helpers/dependency_helper.rb @@ -25,6 +25,24 @@ def running_jruby? Appsignal::System.jruby? end + # Whether the optional OpenTelemetry gems collector mode needs are + # installable in this bundle. They're no longer gemspec dependencies, so + # the OTel specs only run under the `-collector` gemfiles (Ruby 3.1+). This + # actually requires the gems (idempotent) so the guarded specs can use them. + def opentelemetry_present? + return @opentelemetry_present if defined?(@opentelemetry_present) + + @opentelemetry_present = + begin + require "opentelemetry/sdk" + require "opentelemetry-metrics-sdk" + require "opentelemetry-logs-sdk" + true + rescue LoadError + false + end + end + def rails_present? dependency_present? "rails" end diff --git a/spec/support/helpers/mode_helpers.rb b/spec/support/helpers/mode_helpers.rb new file mode 100644 index 000000000..837b27856 --- /dev/null +++ b/spec/support/helpers/mode_helpers.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module ModeHelpers + # Defines the same example in both agent and collector mode. Pass an optional + # description; it is suffixed with " in agent mode" / " in collector mode". + # + # Per the dual-mode start principle, each generated example starts its own + # agent in the body: agent mode via `start_agent`, collector mode via + # `start_collector_agent`, before running the shared block. So the block must + # NOT start the agent itself, and any mode-dependent arrangement (e.g. + # `set_current_transaction`, building a transaction) belongs inside the block + # — which runs after the start — rather than in a `before` hook. + def it_in_both_modes(description = nil, &block) + it([description, "in agent mode"].compact.join(" "), :agent_mode) do + start_agent(**(defined?(start_agent_args) ? start_agent_args : {})) + instance_exec(&block) + end + it([description, "in collector mode"].compact.join(" "), :collector_mode) do + start_collector_agent + instance_exec(&block) + end + end +end + +RSpec.configure { |config| config.extend ModeHelpers } diff --git a/spec/support/helpers/otlp_collector_server.rb b/spec/support/helpers/otlp_collector_server.rb new file mode 100644 index 000000000..db4ef05ff --- /dev/null +++ b/spec/support/helpers/otlp_collector_server.rb @@ -0,0 +1,148 @@ +# frozen_string_literal: true + +require "socket" +require "stringio" +require "timeout" +require "zlib" + +# A mock OTLP/HTTP collector used by the collector-mode integration spec. +# +# Accepts POSTs to `/v1/traces`, `/v1/metrics` and `/v1/logs` and stores the +# raw protobuf-encoded request body in a per-path Queue. Tests call +# `OTLPCollectorServer.listen_to("/v1/traces")` to block until a request +# arrives and then decode it with the proto stubs that ship inside the +# `opentelemetry-exporter-otlp` gem. +# +# Hand-rolled on top of `TCPServer` rather than Sinatra/WEBrick so the spec +# suite doesn't drag those gems into every framework gemfile via the gemspec. +module OTLPCollectorServer + PATHS = %w[/v1/traces /v1/metrics /v1/logs].freeze + + @received = Hash.new { |h, k| h[k] = Queue.new } + @booted = false + @port = nil + + class << self + attr_reader :received + + # The port the mock server is bound to. Assigned by `boot!`, which binds to + # an OS-assigned free port rather than a fixed one, so concurrent suite runs + # on the same machine don't collide. `nil` until booted. + attr_reader :port + + def endpoint + "http://127.0.0.1:#{port}" + end + + # Env vars that put a spawned runner into collector mode, pointed at this + # mock server. Returns a plain Hash so callers can merge in other env + # vars, e.g. `OTLPCollectorServer.env.merge("OTEL_..." => "...")`. + def env + { "APPSIGNAL_COLLECTOR_ENDPOINT" => endpoint } + end + + def listen_to(path, timeout: 10) + Timeout.timeout(timeout) { received[path].pop } + rescue Timeout::Error + raise "Timed out after #{timeout}s waiting for OTLP request to #{path}. " \ + "Other received paths so far: " \ + "#{received.transform_values(&:size).reject { |_, s| s.zero? }.inspect}" + end + + def clear + received.each_value(&:clear) + end + + def boot! + return if @booted + + # Port 0 lets the OS pick a free port; read the assigned one back so + # `endpoint`/`env` can hand it to the spawned runners. + @server = TCPServer.new("127.0.0.1", 0) + @port = @server.addr[1] + @booted = true + @thread = Thread.new do + Thread.current.abort_on_exception = false + accept_loop + end + end + + private + + def accept_loop + loop do + client = @server.accept + Thread.new(client) { |c| handle(c) } + end + rescue IOError, Errno::EBADF + # Server socket was closed; exit the loop. + end + + def handle(client) + request_line = client.gets + return unless request_line + + method, path, _ = request_line.strip.split(" ", 3) + headers = read_headers(client) + + length = headers["content-length"].to_i + raw_body = length.positive? ? client.read(length) : "" + body = + if headers["content-encoding"] == "gzip" + Zlib::GzipReader.new(StringIO.new(raw_body)).read + else + raw_body + end + + if method == "POST" && PATHS.include?(path) + received[path] << { :headers => rack_style_headers(headers), :body => body } + write_response(client, 200, "application/x-protobuf", "") + else + write_response(client, 404, "text/plain", "") + end + rescue StandardError + # Swallow per-connection errors so a malformed request doesn't bring + # down the accept loop for the rest of the suite. + ensure + begin + client&.close + rescue StandardError + # ignore + end + end + + def read_headers(client) + headers = {} + while (line = client.gets) && line != "\r\n" + key, _, value = line.strip.partition(":") + headers[key.downcase] = value.strip + end + headers + end + + # Mimic the rack env header keys the previous Sinatra-based server + # exposed so any future spec that introspects `:headers` finds the + # same shape. + def rack_style_headers(headers) + headers.each_with_object({}) do |(k, v), h| + env_key = + if k == "content-type" + "CONTENT_TYPE" + else + "HTTP_#{k.upcase.tr("-", "_")}" + end + h[env_key] = v + end + end + + def write_response(client, status, content_type, body) + reason = status == 200 ? "OK" : "Not Found" + client.write("HTTP/1.1 #{status} #{reason}\r\n") + client.write("Content-Type: #{content_type}\r\n") + client.write("Content-Length: #{body.bytesize}\r\n") + client.write("Connection: close\r\n") + client.write("\r\n") + client.write(body) + end + end +end diff --git a/spec/support/helpers/transaction_helpers.rb b/spec/support/helpers/transaction_helpers.rb index 79191d2db..28098c052 100644 --- a/spec/support/helpers/transaction_helpers.rb +++ b/spec/support/helpers/transaction_helpers.rb @@ -33,8 +33,8 @@ def create_transaction(namespace = default_namespace) Appsignal::Transaction.create(namespace) end - def new_transaction(namespace = default_namespace, ext: nil) - Appsignal::Transaction.new(namespace, :ext => ext) + def new_transaction(namespace = default_namespace, backend: nil) + Appsignal::Transaction.new(namespace, :backend => backend) end def rack_request(env) diff --git a/spec/support/matchers/transaction.rb b/spec/support/matchers/transaction.rb index ce1ba959c..0d26b6b15 100644 --- a/spec/support/matchers/transaction.rb +++ b/spec/support/matchers/transaction.rb @@ -63,7 +63,7 @@ def define_transaction_sample_matcher_for(matcher_key, value_key = matcher_key) RSpec::Matchers.define :be_completed do match(:notify_expectation_failures => true) do |transaction| - values_match? transaction.ext._completed?, true + values_match? transaction.backend._completed?, true end end @@ -108,7 +108,13 @@ def define_transaction_sample_matcher_for(matcher_key, value_key = matcher_key) match_when_negated(:notify_expectation_failures => true) do |transaction| events = transaction.to_h["events"] if event - expect(events).to_not include(format_event(event)) + # Match on the given keys alone, rather than through `format_event`. Every + # key the caller leaves out is given a default there, and an event that + # differs from that default in any of them does not match. So an event + # ruled out by name alone still matched nothing as soon as it had a title, + # which nearly every event has, and the expectation passed while the event + # it ruled out was there all along. + expect(events).to_not include(hash_including(event.transform_keys(&:to_s))) else expect(events).to be_empty end @@ -163,7 +169,8 @@ def format_event(event) breadcrumb = format_breadcrumb(action, category, message, metadata, time) expect(breadcrumbs).to_not include(breadcrumb) else - expect(breadcrumbs).to_not be_any + # No breadcrumbs added means no breadcrumbs sample data at all (nil). + expect(breadcrumbs || []).to_not be_any end end @@ -181,7 +188,7 @@ def format_breadcrumb(action, category, message, metadata, time) RSpec::Matchers.define :have_queue_start do |queue_start_time| match(:notify_expectation_failures => true) do |transaction| - actual_start = transaction.ext.queue_start + actual_start = transaction.backend.queue_start if queue_start_time expect(actual_start).to eq(queue_start_time) else @@ -190,7 +197,7 @@ def format_breadcrumb(action, category, message, metadata, time) end match_when_negated(:notify_expectation_failures => true) do |transaction| - actual_start = transaction.ext.queue_start + actual_start = transaction.backend.queue_start if queue_start_time expect(actual_start).to_not eq(queue_start_time) else diff --git a/spec/support/shared_contexts/agent_mode.rb b/spec/support/shared_contexts/agent_mode.rb new file mode 100644 index 000000000..10d412d88 --- /dev/null +++ b/spec/support/shared_contexts/agent_mode.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +RSpec.shared_context "agent mode", :agent_mode do + # Dual-mode start principle (see also collector_mode.rb): mode is global + # state, so the agent is NOT started in a `before` here -- that fought with + # ad-hoc `start_agent` calls elsewhere (last writer wins, order is fragile). + # Each `:agent_mode` example starts the agent itself in its body with + # `start_agent` (the `it_in_both_modes` helper does this for its shared body). + # This context just makes completed transactions readable via `to_h` so the + # agent-mode matchers can assert on `include_event` / `include_tags` etc. + # after the transaction has been completed. Harmless when the example doesn't + # complete the transaction inside the body -- it just sets and unsets a flag. + around { |example| keep_transactions { example.run } } +end + +RSpec.configure do |config| + config.include_context "agent mode", :agent_mode +end diff --git a/spec/support/shared_contexts/collector_mode.rb b/spec/support/shared_contexts/collector_mode.rb new file mode 100644 index 000000000..23079dd72 --- /dev/null +++ b/spec/support/shared_contexts/collector_mode.rb @@ -0,0 +1,160 @@ +# frozen_string_literal: true + +# The OpenTelemetry gems are optional (not gemspec dependencies), so only +# require them when present. This shared context is auto-loaded for every run, +# but its OTel references live in lazy `let`/`before`/`after` blocks that only +# run for `:collector_mode`-tagged examples — and those specs are themselves +# guarded on `opentelemetry_present?`, so they don't load without the gems. +if DependencyHelper.opentelemetry_present? + require "opentelemetry/sdk" + require "opentelemetry-metrics-sdk" + require "opentelemetry-logs-sdk" +end + +RSpec.shared_context "collector mode", :collector_mode do + let(:span_exporter) { ::OpenTelemetry::SDK::Trace::Export::InMemorySpanExporter.new } + let(:tracer_provider) do + provider = ::OpenTelemetry::SDK::Trace::TracerProvider.new + provider.add_span_processor( + ::OpenTelemetry::SDK::Trace::Export::SimpleSpanProcessor.new(span_exporter) + ) + provider + end + + let(:metric_exporter) { ::OpenTelemetry::SDK::Metrics::Export::InMemoryMetricPullExporter.new } + let(:meter_provider) do + provider = ::OpenTelemetry::SDK::Metrics::MeterProvider.new + provider.add_metric_reader(metric_exporter) + provider + end + + let(:log_exporter) { ::OpenTelemetry::SDK::Logs::Export::InMemoryLogRecordExporter.new } + let(:logger_provider) do + provider = ::OpenTelemetry::SDK::Logs::LoggerProvider.new + provider.add_log_record_processor( + ::OpenTelemetry::SDK::Logs::Export::SimpleLogRecordProcessor.new(log_exporter) + ) + provider + end + + # Dual-mode start principle: mode is global state, so the agent is NOT + # started in a `before` here -- that fought with ad-hoc `start_agent` calls + # with fragile ordering. Each `:collector_mode` example calls + # `start_collector_agent` itself in its body (the `it_in_both_modes` helper + # does this for its shared body). This context provides the in-memory + # providers, the `start_collector_agent` helper, the read-back helpers, and + # the teardown below. + after do + # `clear_current_transaction!` in spec_helper clears the thread-local but + # not the attached OTel context. `complete_current!` does both. + Appsignal::Transaction.complete_current! + # Shut down whatever OTel SDK is current at teardown. Usually that's + # the threadless in-memory providers (a near no-op), but examples that + # boot AppSignal again themselves leave real providers behind, whose + # background threads would otherwise accumulate across the suite. The + # targeted shutdown, not `Appsignal.stop`: stop's `Extension.stop` + # takes ~2 seconds per call, which across every collector-mode example + # adds minutes to the suite. Runs before the global + # `Appsignal::OpenTelemetry.reset!` hook, so the `started?` gate inside + # the shutdown still passes. + Appsignal::OpenTelemetry.shutdown + # Booting the SDK installs the global W3C propagator as a side effect, and + # nothing ever resets it. Left in place it leaks to every later example, so + # an unrelated spec can silently pass on a propagator this example happened + # to install. Reset it to the API default so collector-mode examples can't + # leak trace propagation into the rest of the suite. + ::OpenTelemetry.propagation = + ::OpenTelemetry::Context::Propagation::NoopTextMapPropagator.new + end + + # Boots the agent in collector mode and swaps in the in-memory OTel providers. + # Called explicitly from each collector-mode example body. + # + # Examples can define a `start_agent_args` `let` to pass `:env`/`:options`; the + # `collector_endpoint` is always merged into the options so collector mode + # stays enabled. Guarded with `defined?` rather than a default `let`, because + # an included shared context's `let` would take precedence over the example + # group's own `let` override. + def start_collector_agent + args = (defined?(start_agent_args) ? start_agent_args : {}).dup + args[:options] = { :collector_endpoint => OTLPCollectorServer.endpoint } + .merge(args[:options] || {}) + start_agent(**args) + # `Appsignal.start` booted a full OTel SDK whose providers each carry a + # background export thread (batch span and log processors, periodic + # metric reader). Shut it down before the swaps below: after the swap + # the booted providers are unreachable and their threads would leak + # across examples. + Appsignal::OpenTelemetry.shutdown + # Swap in the in-memory providers so the test can read spans/metrics/ + # logs back, and reset the metrics backend so its cached instruments + # re-resolve against this meter provider on the next emit. + ::OpenTelemetry.tracer_provider = tracer_provider + ::OpenTelemetry.meter_provider = meter_provider + ::OpenTelemetry.logger_provider = logger_provider + Appsignal::Metrics::OpenTelemetryBackend.reset! + end + + def root_span + span_exporter.finished_spans.find { |s| [:server, :consumer].include?(s.kind) } + end + + def event_spans + span_exporter.finished_spans.reject { |s| [:server, :consumer].include?(s.kind) } + end + + # The `[name, version]` instrumentation scope a finished span was recorded + # under. Integrations tag their spans with their own scope in collector mode. + def scope_of(span) + [span.instrumentation_scope.name, span.instrumentation_scope.version] + end + + # In collector mode the event's category (its AppSignal event name, such as + # "sql.active_record") is no longer emitted as an attribute. It leads the + # span name instead, either on its own or followed by the human-readable + # title as "category (title)". This returns the category back out of an + # event span's name, ignoring any title, so specs can match on it. + def event_category(span) + span.name.sub(/ \(.*\)\z/, "") + end + + # The event spans whose category matches, regardless of their title. + def event_spans_for(category) + event_spans.select { |span| event_category(span) == category } + end + + # The first event span whose category matches, regardless of its title. + def event_span_for(category) + event_spans_for(category).first + end + + # The OpenTelemetry `exception` events recorded across all finished spans + # (errors attach to the span that was current when they were set, which may + # be the root span or an event span). + def exception_events + span_exporter.finished_spans.flat_map { |span| Array(span.events) }.select do |event| + event.name == "exception" + end + end + + # Pull the current metric snapshots from the in-memory reader. The OTLP + # exporter is also a reader, so a `pull` collects everything recorded so far. + def metric_snapshots + metric_exporter.pull + snapshots = metric_exporter.metric_snapshots.dup + metric_exporter.reset + snapshots + end + + def metric_snapshot(name) + metric_snapshots.find { |snapshot| snapshot.name == name } + end + + def log_records + log_exporter.emitted_log_records + end +end + +RSpec.configure do |config| + config.include_context "collector mode", :collector_mode +end diff --git a/spec/support/testing.rb b/spec/support/testing.rb index 8a3899998..1ad9dd589 100644 --- a/spec/support/testing.rb +++ b/spec/support/testing.rb @@ -174,7 +174,7 @@ module AppsignalTest module Transaction module ClassMethods def self.extended(base) - base.attr_reader :ext, :error_blocks + base.attr_reader :backend, :error_blocks end # Override the {Appsignal::Transaction.new} method so we can track which @@ -194,7 +194,32 @@ def _sample end end end + + # Test-only introspection for the transaction backends. These let matchers and + # specs read back internal state; they are not part of the production backend + # contract (see `Appsignal::Transaction::BaseBackend`). + module ExtensionBackend + def queue_start + @handle.queue_start + end + + def _completed? + @handle._completed? + end + end + + module OpenTelemetryBackend + def queue_start + nil + end + + def _completed? + @completed + end + end end Appsignal::Transaction.extend(AppsignalTest::Transaction::ClassMethods) Appsignal::Transaction.prepend(AppsignalTest::Transaction::InstanceMethods) +Appsignal::Transaction::ExtensionBackend.prepend(AppsignalTest::ExtensionBackend) +Appsignal::Transaction::OpenTelemetryBackend.prepend(AppsignalTest::OpenTelemetryBackend)