diff --git a/.changesets/add-collector-attribute-options.md b/.changesets/add-collector-attribute-options.md new file mode 100644 index 000000000..3e3c0c0ba --- /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 under 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`. These tell the AppSignal Collector how to filter and forward telemetry data. + +When collector mode is active, existing configuration options (`name`, environment, `hostname`, `revision`, `ignore_actions`, `ignore_errors`, `ignore_namespaces`, `request_headers`, `filter_session_data`, `send_session_data`) are now passed to the collector as OpenTelemetry resource attributes. + +Setting any of these options without `collector_endpoint`, or `filter_parameters`/`filter_metadata`/`send_params` with `collector_endpoint`, now 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..b10465b5f --- /dev/null +++ b/.changesets/add-collector-mode.md @@ -0,0 +1,19 @@ +--- +bump: minor +type: add +--- + +Add a new `collector_endpoint` configuration option (`APPSIGNAL_COLLECTOR_ENDPOINT` environment variable) that puts the integration in _collector mode_. When set, AppSignal additionally configures an OpenTelemetry SDK that exports OTLP/HTTP protobuf traces, metrics, and logs to the configured endpoint. The existing AppSignal agent continues to run unchanged; no AppSignal-collected data flows through the OpenTelemetry SDK yet. + +Collector mode requires Ruby 3.1 or newer and the OpenTelemetry gems, which are optional and not installed by default. To use it, add them to your application's `Gemfile`: + +```ruby +gem "opentelemetry-sdk", ">= 1.8.0" +gem "opentelemetry-metrics-sdk", ">= 0.7.1" +gem "opentelemetry-logs-sdk", ">= 0.2.0" +gem "opentelemetry-exporter-otlp", ">= 0.30.0" +gem "opentelemetry-exporter-otlp-metrics", ">= 0.4.0" +gem "opentelemetry-exporter-otlp-logs", ">= 0.2.0" +``` + +If these gems are missing or older than the minimum versions, AppSignal logs a warning and falls back to the bundled agent. diff --git a/.changesets/add-faraday-integration.md b/.changesets/add-faraday-integration.md index a4e537141..0ac3fe85f 100644 --- a/.changesets/add-faraday-integration.md +++ b/.changesets/add-faraday-integration.md @@ -3,4 +3,4 @@ bump: minor type: add --- -Improve Faraday support. AppSignal now instruments Faraday requests automatically, without double-instrumenting the underlying HTTP client. Turn it off with the `instrument_faraday` option. +Improve Faraday support. AppSignal now instruments Faraday requests automatically, without double-instrumenting the underlying HTTP client, and, in collector mode, propagates trace context to the called service so it joins the same distributed trace. Turn it off with the `instrument_faraday` option. diff --git a/.changesets/add-que-distributed-tracing.md b/.changesets/add-que-distributed-tracing.md new file mode 100644 index 000000000..db8ced36d --- /dev/null +++ b/.changesets/add-que-distributed-tracing.md @@ -0,0 +1,6 @@ +--- +bump: minor +type: add +--- + +Improve Que support. In collector mode, AppSignal now propagates trace context when enqueuing Que jobs, so each job links back to the trace that enqueued it. This covers single and bulk enqueues, on both Que 1 and Que 2. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 70baeb3b5..1ccb1178e 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: 234 +# Generated job count: 408 --- name: Ruby gem CI 'on': @@ -123,7 +123,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" @@ -132,8 +133,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: @@ -152,15 +153,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: @@ -179,15 +179,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: @@ -206,15 +207,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__dry-monitor_ubuntu-latest: - name: Ruby 4.0.0 - dry-monitor + 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: @@ -233,15 +233,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/dry-monitor.gemfile - ruby_4-0-0__faraday-1_ubuntu-latest: - name: Ruby 4.0.0 - faraday-1 + 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: @@ -260,15 +261,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/faraday-1.gemfile - ruby_4-0-0__faraday-2_ubuntu-latest: - name: Ruby 4.0.0 - faraday-2 + 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: @@ -287,15 +287,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/faraday-2.gemfile - ruby_4-0-0__grape_ubuntu-latest: - name: Ruby 4.0.0 - grape + 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: @@ -314,15 +315,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/grape.gemfile - ruby_4-0-0__http5_ubuntu-latest: - name: Ruby 4.0.0 - http5 + BUNDLE_GEMFILE: gemfiles/code_ownership-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: @@ -341,15 +341,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/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: @@ -368,15 +369,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/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: @@ -395,15 +395,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/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: @@ -422,15 +423,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/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: @@ -449,15 +449,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/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: @@ -476,15 +477,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/psych-4.gemfile - ruby_4-0-0__que-1_ubuntu-latest: - name: Ruby 4.0.0 - que-1 + 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: @@ -503,15 +503,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/que-1.gemfile - ruby_4-0-0__que-2_ubuntu-latest: - name: Ruby 4.0.0 - que-2 + 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: @@ -530,15 +531,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/que-2.gemfile - ruby_4-0-0__rails-7-0_ubuntu-latest: - name: Ruby 4.0.0 - rails-7.0 + 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: @@ -557,15 +557,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/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.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: @@ -584,15 +585,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/rails-7.1.gemfile - ruby_4-0-0__rails-7-2_ubuntu-latest: - name: Ruby 4.0.0 - rails-7.2 + 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: @@ -611,15 +611,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/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.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: @@ -638,15 +639,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-8.0.gemfile - ruby_4-0-0__resque-2_ubuntu-latest: - name: Ruby 4.0.0 - resque-2 + 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: @@ -665,15 +665,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/resque-2.gemfile - ruby_4-0-0__resque-3_ubuntu-latest: - name: Ruby 4.0.0 - resque-3 + 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: @@ -692,15 +693,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/resque-3.gemfile - ruby_4-0-0__sequel_ubuntu-latest: - name: Ruby 4.0.0 - sequel + 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: @@ -719,15 +719,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/sequel.gemfile - ruby_4-0-0__shoryuken-7_ubuntu-latest: - name: Ruby 4.0.0 - shoryuken-7 + 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: @@ -746,15 +747,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/shoryuken-7.gemfile - ruby_4-0-0__sinatra_ubuntu-latest: - name: Ruby 4.0.0 - sinatra + 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: @@ -773,15 +773,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/sinatra.gemfile - ruby_4-0-0__webmachine2_ubuntu-latest: - name: Ruby 4.0.0 - webmachine2 + 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: @@ -800,15 +801,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/webmachine2.gemfile - ruby_4-0-0__redis-4_ubuntu-latest: - name: Ruby 4.0.0 - redis-4 + 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: @@ -827,15 +827,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/redis-4.gemfile - ruby_4-0-0__redis-5_ubuntu-latest: - name: Ruby 4.0.0 - redis-5 + 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: @@ -854,15 +855,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/redis-5.gemfile - ruby_4-0-0__sidekiq-7_ubuntu-latest: - name: Ruby 4.0.0 - sidekiq-7 + 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: @@ -881,15 +881,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/sidekiq-7.gemfile - ruby_4-0-0__sidekiq-8_ubuntu-latest: - name: Ruby 4.0.0 - sidekiq-8 + 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: @@ -908,17 +909,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 - run: "./script/bundler_wrapper exec rake test" + - *15 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/psych-4-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 uses: actions/checkout@v4 @@ -935,18 +935,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 + - &16 + 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_ubuntu-latest: - name: Ruby 3.4.1 - needs: validation + 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 @@ -954,7 +953,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" @@ -964,18 +963,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" + - *16 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-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 @@ -983,7 +979,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" @@ -993,16 +989,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 + - &17 + 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-4-1__capistrano3_ubuntu-latest: - name: Ruby 3.4.1 - capistrano3 - 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 @@ -1010,7 +1007,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" @@ -1020,16 +1017,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" + - *17 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-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 @@ -1037,7 +1033,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" @@ -1047,16 +1043,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" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/code_ownership.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 @@ -1064,7 +1061,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" @@ -1074,16 +1071,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/dry-monitor.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.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 @@ -1091,7 +1087,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" @@ -1101,16 +1097,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/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.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 @@ -1118,7 +1115,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" @@ -1128,16 +1125,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/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.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 @@ -1145,7 +1141,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" @@ -1155,16 +1151,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/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.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 @@ -1172,7 +1169,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" @@ -1182,16 +1179,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/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-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 @@ -1199,7 +1195,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" @@ -1209,16 +1205,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/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.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 @@ -1226,7 +1223,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" @@ -1236,16 +1233,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/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/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 @@ -1253,7 +1249,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" @@ -1263,16 +1259,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/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.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 @@ -1280,7 +1277,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" @@ -1290,16 +1287,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/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-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 @@ -1307,7 +1303,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" @@ -1317,16 +1313,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/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.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 @@ -1334,7 +1331,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" @@ -1344,16 +1341,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/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/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 @@ -1361,7 +1357,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" @@ -1371,16 +1367,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/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.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 @@ -1388,7 +1385,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" @@ -1398,16 +1395,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/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/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 @@ -1415,7 +1411,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" @@ -1425,16 +1421,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/psych-4.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/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 @@ -1442,7 +1439,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" @@ -1452,16 +1449,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/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/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 @@ -1469,7 +1465,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" @@ -1479,16 +1475,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/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/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 @@ -1496,7 +1493,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" @@ -1506,16 +1503,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/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/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 @@ -1523,7 +1519,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" @@ -1533,16 +1529,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/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/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 @@ -1550,7 +1547,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" @@ -1560,16 +1557,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/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/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 @@ -1577,7 +1573,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" @@ -1587,16 +1583,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/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-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 @@ -1604,7 +1601,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" @@ -1614,16 +1611,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/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-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 @@ -1631,7 +1627,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" @@ -1641,16 +1637,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/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/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 @@ -1658,7 +1655,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" @@ -1668,16 +1665,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/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/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 @@ -1685,7 +1681,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" @@ -1695,16 +1691,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/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-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 @@ -1712,7 +1709,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" @@ -1722,16 +1719,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/shoryuken-7.gemfile - ruby_3-4-1__sinatra_ubuntu-latest: - name: Ruby 3.4.1 - sinatra - 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 @@ -1739,7 +1735,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" @@ -1749,16 +1745,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/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/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 @@ -1766,7 +1763,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" @@ -1776,24 +1773,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" + - *31 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 - 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" @@ -1805,15 +1801,17 @@ 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/redis-4.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 + 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 uses: actions/checkout@v4 @@ -1830,15 +1828,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 + - &32 + 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/redis-5.gemfile - ruby_3-4-1__sidekiq-7_ubuntu-latest: - name: Ruby 3.4.1 - sidekiq-7 + 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: @@ -1857,15 +1858,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" + - *32 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/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: @@ -1884,17 +1884,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 + - &33 + 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/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: - name: Check out repository uses: actions/checkout@v4 @@ -1911,18 +1912,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" + - *33 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/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: - name: Check out repository @@ -1930,7 +1928,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" @@ -1940,18 +1938,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 + - &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/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/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 @@ -1959,7 +1956,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" @@ -1969,16 +1966,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" + - *34 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/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 @@ -1986,7 +1982,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" @@ -1996,16 +1992,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 + - &35 + 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/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 @@ -2013,7 +2010,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" @@ -2023,16 +2020,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" + - *35 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/code_ownership.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/code_ownership-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 @@ -2040,7 +2036,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" @@ -2050,16 +2046,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 + - &36 + 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__faraday-1_ubuntu-latest: - name: Ruby 3.3.4 - faraday-1 - needs: ruby_3-3-4_ubuntu-latest + 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 @@ -2067,7 +2064,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" @@ -2077,16 +2074,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" + - *36 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/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 @@ -2094,7 +2090,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" @@ -2104,16 +2100,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" 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/excon.gemfile + 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 @@ -2121,7 +2118,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" @@ -2131,16 +2128,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/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/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 @@ -2148,7 +2144,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" @@ -2158,16 +2154,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/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-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 @@ -2175,7 +2172,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" @@ -2185,16 +2182,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/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-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 @@ -2202,7 +2198,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" @@ -2212,16 +2208,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/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/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 @@ -2229,7 +2226,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" @@ -2239,16 +2236,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/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/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 @@ -2256,7 +2252,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" @@ -2266,16 +2262,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/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/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 @@ -2283,7 +2280,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" @@ -2293,16 +2290,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/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/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 @@ -2310,7 +2306,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" @@ -2320,16 +2316,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/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.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 @@ -2337,7 +2334,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" @@ -2347,16 +2344,43 @@ 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 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + 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 + 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" + - 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'" + - &42 + 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__psych-3_ubuntu-latest: - name: Ruby 3.3.4 - psych-3 - 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 @@ -2364,7 +2388,4631 @@ 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" + - 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'" + - *42 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + 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 + 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" + - 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'" + - &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-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 + 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" + - 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'" + - *43 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + 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 + 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" + - 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'" + - &44 + 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-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 + 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" + - 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'" + - *44 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + 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 + 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" + - 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'" + - &45 + 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__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 + 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" + - 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'" + - *45 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + 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 + 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" + - 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'" + - &46 + 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-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 + 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" + - 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'" + - *46 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + 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 + 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" + - 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'" + - &47 + 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__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 + 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" + - 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'" + - *47 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + 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 + 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" + - 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'" + - &48 + 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-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 + 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" + - 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'" + - *48 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + 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 + 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" + - 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'" + - &49 + 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-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 + 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" + - 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'" + - *49 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + 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 + 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" + - 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'" + - &50 + 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-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 + 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" + - 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'" + - *50 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/psych-4-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 + 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" + - 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'" + - &51 + 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-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.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'" + - *51 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + 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 + 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" + - 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'" + - &52 + 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__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 + 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" + - 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'" + - *52 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + 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 + 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" + - 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'" + - &53 + 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-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 + 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" + - 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'" + - *53 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + 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 + 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" + - 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'" + - &54 + 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-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 + 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" + - 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'" + - *54 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + 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 + 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" + - 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'" + - &55 + 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-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 + 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" + - 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'" + - *55 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + 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 + 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" + - 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'" + - &56 + 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-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 + 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" + - 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'" + - *56 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + 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 + 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" + - 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'" + - &57 + 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-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 + 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" + - 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'" + - *57 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + 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 + 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" + - 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'" + - &58 + 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-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 + 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" + - 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'" + - *58 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + 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 + 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" + - 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'" + - &59 + 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-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 + 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" + - 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'" + - *59 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + 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 + 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" + - 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'" + - &60 + 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__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 + 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" + - 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'" + - *60 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + 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 + 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" + - 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'" + - &61 + 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-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 + 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" + - 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'" + - *61 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + 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 + 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" + - 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'" + - &62 + 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-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 + 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" + - 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'" + - *62 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + 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 + 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" + - 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'" + - &63 + 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-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 + 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" + - 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'" + - *63 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + 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 + 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" + - 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'" + - &64 + 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-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 + 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" + - 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'" + - *64 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + 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 + 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" + - 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'" + - &65 + 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__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 + 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" + - 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'" + - *65 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + 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 + 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" + - 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'" + - &66 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + 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 + 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" + - 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'" + - *66 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + 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 + 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" + - 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'" + - &67 + 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__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 + 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" + - 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'" + - *67 + 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" + - 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-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'" + - &68 + 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'" + - *68 + 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'" + - &69 + 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'" + - *69 + 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'" + - &70 + 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'" + - *70 + 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'" + - &71 + 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'" + - *71 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/code_ownership-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'" + - &72 + 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'" + - *72 + 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'" + - &73 + 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'" + - *73 + 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'" + - &74 + 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'" + - *74 + 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'" + - &75 + 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'" + - *75 + 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'" + - &76 + 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'" + - *76 + 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'" + - &77 + 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'" + - *77 + 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'" + - &78 + 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'" + - *78 + 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'" + - &79 + 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'" + - *79 + 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'" + - &80 + 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'" + - *80 + 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'" + - &81 + 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'" + - *81 + 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'" + - &82 + 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'" + - *82 + 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'" + - &83 + 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'" + - *83 + 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'" + - &84 + 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'" + - *84 + 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'" + - &85 + 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'" + - *85 + 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'" + - &86 + 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'" + - *86 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/psych-4-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'" + - &87 + 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'" + - *87 + 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'" + - &88 + 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'" + - *88 + 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'" + - &89 + 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'" + - *89 + 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'" + - &90 + 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'" + - *90 + 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'" + - &91 + 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'" + - *91 + 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'" + - &92 + 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'" + - *92 + 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'" + - &93 + 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'" + - *93 + 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'" + - &94 + 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'" + - *94 + 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'" + - &95 + 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'" + - *95 + 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'" + - &96 + 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'" + - *96 + 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'" + - &97 + 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'" + - *97 + 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'" + - &98 + 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'" + - *98 + 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'" + - &99 + 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'" + - *99 + 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'" + - &100 + 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'" + - *100 + 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'" + - &101 + 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'" + - *101 + 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'" + - &102 + 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'" + - *102 + 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'" + - &103 + 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'" + - *103 + 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'" + - &104 + 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'" + - *104 + 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'" + - &105 + 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'" + - *105 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/capistrano3-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'" + - &106 + 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'" + - *106 + 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'" + - &107 + 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'" + - *107 + 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'" + - &108 + 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'" + - *108 + 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'" + - &109 + 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'" + - *109 + 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'" + - &110 + 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'" + - *110 + 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'" + - &111 + 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'" + - *111 + 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'" + - &112 + 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'" + - *112 + 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'" + - &113 + 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'" + - *113 + 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'" + - &114 + 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'" + - *114 + 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'" + - &115 + 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'" + - *115 + 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'" + - &116 + 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'" + - *116 + 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'" + - &117 + 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'" + - *117 + 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'" + - &118 + 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'" + - *118 + 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'" + - &119 + 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'" + - *119 + 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'" + - &120 + 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'" + - *120 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/psych-4-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'" + - &121 + 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'" + - *121 + 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'" + - &122 + 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'" + - *122 + 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'" + - &123 + 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'" + - *123 + 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'" + - &124 + 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'" + - *124 + 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'" + - &125 + 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'" + - *125 + 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'" + - &126 + 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'" + - *126 + 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" @@ -2374,16 +7022,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 + - &127 + 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/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 @@ -2391,7 +7040,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.3.4 + ruby-version: 3.2.5 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -2401,16 +7050,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" + - *127 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/psych-4.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/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 @@ -2418,7 +7066,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.3.4 + ruby-version: 3.2.5 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -2428,16 +7076,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 + - &128 + 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-2_ubuntu-latest: - name: Ruby 3.3.4 - que-2 - needs: ruby_3-3-4_ubuntu-latest + 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 @@ -2445,7 +7094,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.3.4 + ruby-version: 3.2.5 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -2455,16 +7104,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" + - *128 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/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 @@ -2472,7 +7120,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.3.4 + ruby-version: 3.2.5 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -2482,16 +7130,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 + - &129 + 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-7-0_ubuntu-latest: - name: Ruby 3.3.4 - rails-7.0 - needs: ruby_3-3-4_ubuntu-latest + 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 @@ -2499,7 +7148,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.3.4 + ruby-version: 3.2.5 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -2509,16 +7158,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" + - *129 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/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 @@ -2526,7 +7174,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.3.4 + ruby-version: 3.2.5 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -2536,16 +7184,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 + - &130 + 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-2_ubuntu-latest: - name: Ruby 3.3.4 - rails-7.2 - needs: ruby_3-3-4_ubuntu-latest + 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 @@ -2553,7 +7202,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.3.4 + ruby-version: 3.2.5 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -2563,16 +7212,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" + - *130 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/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 @@ -2580,7 +7228,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.3.4 + ruby-version: 3.2.5 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -2590,16 +7238,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 + - &131 + 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-1_ubuntu-latest: - name: Ruby 3.3.4 - rails-8.1 - needs: ruby_3-3-4_ubuntu-latest + 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 @@ -2607,7 +7256,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.3.4 + ruby-version: 3.2.5 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -2617,16 +7266,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" + - *131 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/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 @@ -2634,7 +7282,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.3.4 + ruby-version: 3.2.5 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -2644,16 +7292,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 + - &132 + 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-3_ubuntu-latest: - name: Ruby 3.3.4 - resque-3 - needs: ruby_3-3-4_ubuntu-latest + 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 @@ -2661,7 +7310,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.3.4 + ruby-version: 3.2.5 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -2671,16 +7320,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" + - *132 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/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 @@ -2688,7 +7336,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.3.4 + ruby-version: 3.2.5 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -2698,16 +7346,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 + - &133 + 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__shoryuken-7_ubuntu-latest: - name: Ruby 3.3.4 - shoryuken-7 - needs: ruby_3-3-4_ubuntu-latest + 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 @@ -2715,7 +7364,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.3.4 + ruby-version: 3.2.5 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -2725,16 +7374,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" + - *133 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/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 @@ -2742,7 +7390,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.3.4 + ruby-version: 3.2.5 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -2752,16 +7400,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 + - &134 + 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__webmachine2_ubuntu-latest: - name: Ruby 3.3.4 - webmachine2 - needs: ruby_3-3-4_ubuntu-latest + 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 @@ -2769,7 +7418,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.3.4 + ruby-version: 3.2.5 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -2779,16 +7428,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" + - *134 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/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 @@ -2796,7 +7444,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.3.4 + ruby-version: 3.2.5 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -2806,16 +7454,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 + - &135 + 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-5_ubuntu-latest: - name: Ruby 3.3.4 - redis-5 - needs: ruby_3-3-4_ubuntu-latest + 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 @@ -2823,7 +7472,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.3.4 + ruby-version: 3.2.5 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -2833,24 +7482,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" + - *135 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/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.3.4 + ruby-version: 3.2.5 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -2860,18 +7508,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 + - &136 + 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 + 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 @@ -2889,19 +7536,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 - run: "./script/bundler_wrapper exec rake test" - - name: Run tests without extension - run: "./script/bundler_wrapper exec rake test:failure" + - *136 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 - runs-on: ubuntu-latest + 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 @@ -2920,14 +7564,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/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/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 @@ -2935,7 +7581,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" @@ -2945,16 +7591,19 @@ 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 + - &137 + 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/capistrano3.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/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 @@ -2962,7 +7611,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" @@ -2972,16 +7621,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" + - *137 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/dry-monitor.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/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 @@ -2989,7 +7637,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" @@ -2999,16 +7647,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 + - &138 + 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/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 @@ -3016,7 +7665,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" @@ -3026,16 +7675,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" + - *138 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/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 @@ -3043,7 +7691,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" @@ -3053,16 +7701,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 + - &139 + 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/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 @@ -3070,7 +7719,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" @@ -3080,16 +7729,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" + - *139 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/capistrano3-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 @@ -3097,7 +7745,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" @@ -3107,16 +7755,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 + - &140 + 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/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 @@ -3124,7 +7773,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" @@ -3134,16 +7783,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" + - *140 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/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 @@ -3151,7 +7799,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" @@ -3161,16 +7809,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 + - &141 + 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/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 @@ -3178,7 +7827,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" @@ -3188,16 +7837,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" + - *141 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/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 @@ -3205,7 +7853,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" @@ -3215,16 +7863,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 + - &142 + 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/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 @@ -3232,7 +7881,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" @@ -3242,16 +7891,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" + - *142 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/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 @@ -3259,7 +7907,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" @@ -3269,16 +7917,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 + - &143 + 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/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 @@ -3286,7 +7935,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" @@ -3296,16 +7945,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" + - *143 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/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 @@ -3313,7 +7961,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" @@ -3323,16 +7971,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 + - &144 + 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-1_ubuntu-latest: - name: Ruby 3.2.5 - que-1 - needs: ruby_3-2-5_ubuntu-latest + 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 @@ -3340,7 +7989,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" @@ -3350,16 +7999,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" + - *144 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/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 @@ -3367,7 +8015,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" @@ -3377,16 +8025,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 + - &145 + 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__rails-6-1_ubuntu-latest: - name: Ruby 3.2.5 - rails-6.1 - needs: ruby_3-2-5_ubuntu-latest + 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 @@ -3394,7 +8043,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" @@ -3404,16 +8053,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" + - *145 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/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 @@ -3421,7 +8069,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" @@ -3431,16 +8079,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 + - &146 + 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-1_ubuntu-latest: - name: Ruby 3.2.5 - rails-7.1 - needs: ruby_3-2-5_ubuntu-latest + 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 @@ -3448,7 +8097,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" @@ -3458,16 +8107,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" + - *146 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/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 @@ -3475,7 +8123,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" @@ -3485,16 +8133,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 + - &147 + 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-8-0_ubuntu-latest: - name: Ruby 3.2.5 - rails-8.0 - needs: ruby_3-2-5_ubuntu-latest + 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 @@ -3502,7 +8151,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" @@ -3512,16 +8161,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" + - *147 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/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 @@ -3529,7 +8177,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" @@ -3539,16 +8187,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 + - &148 + 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__resque-2_ubuntu-latest: - name: Ruby 3.2.5 - resque-2 - needs: ruby_3-2-5_ubuntu-latest + 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 @@ -3556,7 +8205,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" @@ -3566,16 +8215,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" + - *148 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/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 @@ -3583,7 +8231,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" @@ -3593,16 +8241,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 + - &149 + 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__sequel_ubuntu-latest: - name: Ruby 3.2.5 - sequel - needs: ruby_3-2-5_ubuntu-latest + 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 @@ -3610,7 +8259,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" @@ -3620,16 +8269,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" + - *149 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/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 @@ -3637,7 +8285,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" @@ -3647,16 +8295,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 + - &150 + 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__sinatra_ubuntu-latest: - name: Ruby 3.2.5 - sinatra - 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 @@ -3664,7 +8313,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" @@ -3674,16 +8323,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" + - *150 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-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 @@ -3691,7 +8339,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" @@ -3701,16 +8349,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 + - &151 + 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__redis-4_ubuntu-latest: - name: Ruby 3.2.5 - redis-4 - 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 @@ -3718,7 +8367,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" @@ -3728,16 +8377,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" + - *151 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 + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + 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 @@ -3745,7 +8393,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" @@ -3755,24 +8403,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 + - &152 + 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_macos-14: - name: Ruby 3.2.5 (macos-14) - needs: validation - runs-on: macos-14 + 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 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" @@ -3782,18 +8431,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" + - *152 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-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: - name: Check out repository @@ -3811,17 +8457,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 + - &153 + 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__capistrano2_ubuntu-latest: - name: Ruby 3.1.6 - capistrano2 + 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: @@ -3840,15 +8485,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" + - *153 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-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: @@ -3867,15 +8511,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 + - &154 + 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__dry-monitor_ubuntu-latest: - name: Ruby 3.1.6 - dry-monitor + 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: @@ -3894,15 +8539,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" + - *154 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/dry-monitor.gemfile - ruby_3-1-6__faraday-1_ubuntu-latest: - name: Ruby 3.1.6 - faraday-1 + 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: @@ -3921,15 +8565,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 + - &155 + 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-2_ubuntu-latest: - name: Ruby 3.1.6 - faraday-2 + 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: @@ -3948,15 +8593,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" + - *155 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-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: @@ -3975,15 +8619,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 + - &156 + 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__hanami-2-0_ubuntu-latest: - name: Ruby 3.1.6 - hanami-2.0 + 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: @@ -4002,15 +8647,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" + - *156 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-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: @@ -4029,15 +8673,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 + - &157 + 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-2_ubuntu-latest: - name: Ruby 3.1.6 - hanami-2.2 + 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: @@ -4056,15 +8701,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" + - *157 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-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: @@ -4083,15 +8727,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 + - &158 + 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__mongo_ubuntu-latest: - name: Ruby 3.1.6 - mongo + 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: @@ -4110,15 +8755,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" + - *158 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-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: @@ -4137,15 +8781,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 + - &159 + 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-1-6__padrino_ubuntu-latest: - name: Ruby 3.1.6 - padrino + 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: @@ -4164,15 +8809,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" + - *159 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-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: @@ -4191,15 +8835,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 + - &160 + 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-1-6__psych-4_ubuntu-latest: - name: Ruby 3.1.6 - psych-4 + 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: @@ -4218,15 +8863,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" + - *160 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/psych-4.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: @@ -4245,15 +8889,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 + - &161 + 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: @@ -4272,15 +8917,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" + - *161 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: @@ -4299,15 +8943,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/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: @@ -4326,15 +8971,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/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: @@ -4353,15 +8997,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/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: @@ -4380,15 +9025,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/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: @@ -4407,15 +9051,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/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: @@ -4434,15 +9079,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/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: @@ -4461,15 +9105,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/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: @@ -4488,15 +9133,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/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: @@ -4515,15 +9159,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/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: @@ -4542,15 +9187,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/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: @@ -4569,15 +9213,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/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: @@ -4596,13 +9241,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" + - *167 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 @@ -4742,6 +9386,33 @@ jobs: JRUBY_OPTS: '' COV: '1' BUNDLE_GEMFILE: gemfiles/dry-monitor.gemfile + ruby_3-0-7__excon_ubuntu-latest: + name: Ruby 3.0.7 - excon + needs: ruby_3-0-7_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.0.7 + 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" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/excon.gemfile ruby_3-0-7__faraday-1_ubuntu-latest: name: Ruby 3.0.7 - faraday-1 needs: ruby_3-0-7_ubuntu-latest @@ -5529,6 +10200,33 @@ jobs: JRUBY_OPTS: '' COV: '1' BUNDLE_GEMFILE: gemfiles/capistrano3.gemfile + ruby_2-7-8__excon_ubuntu-latest: + name: Ruby 2.7.8 - excon + needs: ruby_2-7-8_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: 2.7.8 + 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" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/excon.gemfile ruby_2-7-8__faraday-1_ubuntu-latest: name: Ruby 2.7.8 - faraday-1 needs: ruby_2-7-8_ubuntu-latest diff --git a/Rakefile b/Rakefile index 021e7cf76..66c53f814 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"] diff --git a/appsignal.gemspec b/appsignal.gemspec index 087b13c7d..07969a87f 100644 --- a/appsignal.gemspec +++ b/appsignal.gemspec @@ -60,6 +60,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 6b4637b42..dc383a8e0 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" @@ -150,6 +162,7 @@ matrix: - "3.2.5" - "3.1.6" - "3.0.7" + - gem: "excon" - gem: "faraday-1" - gem: "faraday-2" only: @@ -278,6 +291,7 @@ matrix: - "3.4.1" - "3.3.4" - "3.2.5" + - gem: "mongo" - gem: "resque-2" - gem: "resque-3" only: 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..15be7a64b --- /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, minimum_version| + gem name, ">= #{minimum_version}" +end 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/excon.gemfile b/gemfiles/excon.gemfile new file mode 100644 index 000000000..526081e0b --- /dev/null +++ b/gemfiles/excon.gemfile @@ -0,0 +1,5 @@ +source "https://rubygems.org" + +gem "excon" + +gemspec :path => "../" 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/faraday-2.gemfile b/gemfiles/faraday-2.gemfile index 00a571da9..27a42cbd5 100644 --- a/gemfiles/faraday-2.gemfile +++ b/gemfiles/faraday-2.gemfile @@ -1,7 +1,5 @@ source "https://rubygems.org" -gem "excon" gem "faraday", "~> 2.0" -gem "faraday-excon" gemspec :path => "../" 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-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 b938342d1..e70c20431 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", @@ -106,8 +107,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 => [], @@ -130,9 +135,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 @@ -158,6 +168,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", @@ -168,6 +179,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", @@ -202,21 +214,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 @@ -229,6 +249,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 @@ -433,6 +486,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 @@ -509,6 +613,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 @@ -532,6 +660,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/helpers/instrumentation.rb b/lib/appsignal/helpers/instrumentation.rb index 7c1acbd35..cb7ef2f34 100644 --- a/lib/appsignal/helpers/instrumentation.rb +++ b/lib/appsignal/helpers/instrumentation.rb @@ -715,7 +715,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( @@ -800,10 +800,18 @@ def instrument( title = nil, body = nil, body_format = Appsignal::EventFormatter::DEFAULT, + opentelemetry_kind: nil, &block ) Appsignal::Transaction.current - .instrument(name, title, body, body_format, &block) + .instrument( + name, + title, + body, + body_format, + :opentelemetry_kind => opentelemetry_kind, + &block + ) end # Instrumentation helper for SQL queries. 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/active_job.rb b/lib/appsignal/hooks/active_job.rb index 2b6446d0b..71a2934cd 100644 --- a/lib/appsignal/hooks/active_job.rb +++ b/lib/appsignal/hooks/active_job.rb @@ -29,8 +29,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? @@ -43,33 +46,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(*, **) - 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"] @@ -91,8 +67,17 @@ 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) + ) end begin @@ -152,6 +137,66 @@ 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(*, **) + Appsignal.instrument( + "enqueue.active_job", + "enqueue #{self.class.name} job", + :opentelemetry_kind => :producer + ) do + 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/excon.rb b/lib/appsignal/hooks/excon.rb index ff09de8bf..09b308438 100644 --- a/lib/appsignal/hooks/excon.rb +++ b/lib/appsignal/hooks/excon.rb @@ -12,7 +12,21 @@ def dependencies_present? def install require "appsignal/integrations/excon" + require "appsignal/integrations/excon/appsignal_middleware" ::Excon.defaults[:instrumentor] = Appsignal::Integrations::ExconIntegration + + # Insert our middleware just before the Mock middleware (the innermost, + # where the response is produced) so its `request_call` runs before the + # request is sent -- and, being inside the Instrumentor middleware, while + # our event span is current, so the injected `traceparent` reflects it. + # Appending to the end would place it after Mock, which short-circuits + # the request chain before reaching it. + 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 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 01183fd25..1cd159e17 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..c9356a85c 100644 --- a/lib/appsignal/hooks/sequel.rb +++ b/lib/appsignal/hooks/sequel.rb @@ -10,7 +10,8 @@ def log_yield(sql, args = nil) "sql.sequel", nil, sql, - Appsignal::EventFormatter::SQL_BODY_FORMAT + Appsignal::EventFormatter::SQL_BODY_FORMAT, + :opentelemetry_kind => :client ) do super end @@ -25,7 +26,8 @@ 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 ) do super end diff --git a/lib/appsignal/hooks/shoryuken.rb b/lib/appsignal/hooks/shoryuken.rb index 4ab7cd205..b95f5b834 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 f78a0839f..62f25912a 100644 --- a/lib/appsignal/hooks/sidekiq.rb +++ b/lib/appsignal/hooks/sidekiq.rb @@ -44,7 +44,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/active_support_notifications.rb b/lib/appsignal/integrations/active_support_notifications.rb index 074219964..99f4faab2 100644 --- a/lib/appsignal/integrations/active_support_notifications.rb +++ b/lib/appsignal/integrations/active_support_notifications.rb @@ -7,17 +7,30 @@ 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`. + # ActiveSupport::Notifications events whose span represents an outgoing + # call to a datastore, so they carry CLIENT kind in collector mode (to + # match the dedicated DB integrations). Kept deliberately narrow: + # `start_event` runs for every instrumented Rails event and span kind is + # immutable, so only genuine client calls belong here. Object + # instantiation (`instantiation.active_record`) is not a client call. + CLIENT_EVENT_NAMES = ["sql.active_record"].freeze + + # Events a dedicated AppSignal integration already records with richer + # semantics, so the generic notifications path must not record them a + # second time. The ActiveJob hook owns `enqueue.active_job`: it wraps the + # enqueue in a producer event that also injects trace context, and the + # native notification fires nested inside it. The Faraday integration owns + # `request.faraday`: its middleware records the request as a client event + # and injects trace context, and Faraday's own instrumentation + # notification, if the user added that middleware, fires nested inside it. SUPPRESSED_EVENT_NAMES = ["enqueue.active_job", "request.faraday"].freeze def start_event(name) return unless record_event?(name) - Appsignal::Transaction.current.start_event + Appsignal::Transaction.current.start_event( + :opentelemetry_kind => CLIENT_EVENT_NAMES.include?(name.to_s) ? :client : nil + ) end def finish_event(name, payload = {}) diff --git a/lib/appsignal/integrations/data_mapper.rb b/lib/appsignal/integrations/data_mapper.rb index 358ea4ada..50ef9797d 100644 --- a/lib/appsignal/integrations/data_mapper.rb +++ b/lib/appsignal/integrations/data_mapper.rb @@ -21,13 +21,15 @@ def log(message) 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 ) super end diff --git a/lib/appsignal/integrations/dry_monitor.rb b/lib/appsignal/integrations/dry_monitor.rb index bad3e495b..e2ef8b5f5 100644 --- a/lib/appsignal/integrations/dry_monitor.rb +++ b/lib/appsignal/integrations/dry_monitor.rb @@ -4,8 +4,13 @@ module Appsignal module Integrations # @!visibility private module DryMonitorIntegration + # ROM emits its SQL queries as dry-monitor `"sql"` events; tag those as + # CLIENT in collector mode to match the dedicated DB integrations. Span + # kind is immutable, so it has to be set here at event start. def instrument(event_id, payload = {}, &block) - Appsignal::Transaction.current.start_event + Appsignal::Transaction.current.start_event( + :opentelemetry_kind => event_id.to_s == "sql" ? :client : nil + ) super ensure diff --git a/lib/appsignal/integrations/excon.rb b/lib/appsignal/integrations/excon.rb index b659caffb..56a92dd1f 100644 --- a/lib/appsignal/integrations/excon.rb +++ b/lib/appsignal/integrations/excon.rb @@ -22,7 +22,7 @@ def self.instrument(name, data, &block) else "#{data[:method].to_s.upcase} #{data[:scheme]}://#{data[:host]}" end - Appsignal.instrument(rails_name, title, &block) + Appsignal.instrument(rails_name, title, :opentelemetry_kind => :client, &block) 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..32b736834 --- /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 existing Excon instrumentor + # records the event 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 instrumentor's event span is + # active during the 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..38cd4336d 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,8 +17,16 @@ 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 ) do + # 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 @@ -37,8 +46,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..80227d928 100644 --- a/lib/appsignal/integrations/http.rb +++ b/lib/appsignal/integrations/http.rb @@ -9,7 +9,12 @@ 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, + &block + ) end # The event is recorded at the request boundary, so a redirected request @@ -32,6 +37,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..c3e9841de 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,8 @@ 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) end # Called by Mongo::Monitor when query succeeds @@ -47,8 +49,9 @@ def finish(result, event) command = store.delete(event.request_id) || {} # 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}", diff --git a/lib/appsignal/integrations/net_http.rb b/lib/appsignal/integrations/net_http.rb index 731085b91..7dba3fac8 100644 --- a/lib/appsignal/integrations/net_http.rb +++ b/lib/appsignal/integrations/net_http.rb @@ -14,8 +14,13 @@ 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 ) do + # 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) super end end diff --git a/lib/appsignal/integrations/que.rb b/lib/appsignal/integrations/que.rb index 5e5c54cb6..361688275 100644 --- a/lib/appsignal/integrations/que.rb +++ b/lib/appsignal/integrations/que.rb @@ -2,11 +2,77 @@ 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 + + # 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. + def extract(tags) + 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 injects + # the current context into a copy of the tags; keeps the result only if it + # still fits Que's limits, otherwise returns the original tags unchanged -- + # we skip propagation rather than break the user's enqueue. Outside + # collector mode returns the tags unchanged. + def inject(tags) + original = Array(tags) + injected = Appsignal::OpenTelemetry.if_started do + copy = original.dup + ::OpenTelemetry.propagation.inject(copy, :setter => TagSetter) + copy + end + return original if injected.nil? || !within_limits?(injected) + + injected + 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 + + # 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(local_attrs.dig(:data, :tags)) + ) begin Appsignal.instrument("perform_job.que") { super } @@ -14,7 +80,6 @@ def _run(*args) 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 { @@ -37,27 +102,54 @@ 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 - def enqueue(*_args, job_options: {}, **_rest) - # 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. + def enqueue(*args, job_options: {}, **rest) + # 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? - # 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, :job_options => merged, **rest) + end + end + + private + + # 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) + # 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) + end + + Appsignal.instrument(event_name, title, :opentelemetry_kind => :producer) do + yield job_options_with_context(job_options) + 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) + tags = QueTraceContext.inject(job_options[:tags]) + tags.empty? ? job_options : job_options.merge(:tags => tags) end end @@ -65,22 +157,18 @@ def enqueue(*_args, job_options: {}, **_rest) # # `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)) 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/redis.rb b/lib/appsignal/integrations/redis.rb index ddb1ccd96..898f60cee 100644 --- a/lib/appsignal/integrations/redis.rb +++ b/lib/appsignal/integrations/redis.rb @@ -12,7 +12,12 @@ def write(command) "#{command[0]}#{" ?" * (command.size - 1)}" end - Appsignal.instrument "query.redis", id, sanitized_command do + Appsignal.instrument( + "query.redis", + id, + sanitized_command, + :opentelemetry_kind => :client + ) do super end end diff --git a/lib/appsignal/integrations/redis_client.rb b/lib/appsignal/integrations/redis_client.rb index 891e915e7..d30230248 100644 --- a/lib/appsignal/integrations/redis_client.rb +++ b/lib/appsignal/integrations/redis_client.rb @@ -12,7 +12,12 @@ def write(command) "#{command[0]}#{" ?" * (command.size - 1)}" end - Appsignal.instrument "query.redis", @config.id, sanitized_command do + Appsignal.instrument( + "query.redis", + @config.id, + sanitized_command, + :opentelemetry_kind => :client + ) do super end end diff --git a/lib/appsignal/integrations/resque.rb b/lib/appsignal/integrations/resque.rb index 70e86d451..d7154f87a 100644 --- a/lib/appsignal/integrations/resque.rb +++ b/lib/appsignal/integrations/resque.rb @@ -5,7 +5,12 @@ 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) + ) Appsignal.instrument "perform.resque" do super @@ -25,8 +30,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 +41,24 @@ def perform # # @!visibility private module ResquePushIntegration - def push(_queue, item) + def push(queue, item) # 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 + ) do + 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..6b79894c1 100644 --- a/lib/appsignal/integrations/shoryuken.rb +++ b/lib/appsignal/integrations/shoryuken.rb @@ -2,17 +2,91 @@ 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) + + # 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 + ) Appsignal.instrument("perform_job.shoryuken", &block) 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) } @@ -73,7 +147,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 +157,24 @@ def fetch_args(batch, sqs_msg, body) # # @!visibility private class ShoryukenClientMiddleware - def call(options, &block) + def call(options) # 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 + ) do + ShoryukenTraceContext.inject(options) + yield + end end private diff --git a/lib/appsignal/integrations/sidekiq.rb b/lib/appsignal/integrations/sidekiq.rb index 98a8e0ab1..861ad6dbe 100644 --- a/lib/appsignal/integrations/sidekiq.rb +++ b/lib/appsignal/integrations/sidekiq.rb @@ -49,23 +49,35 @@ def call(exception, sidekiq_context, _sidekiq_config = nil) 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) # 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 - Appsignal.instrument("enqueue.sidekiq", "enqueue #{job["class"]} job", &block) + Appsignal.instrument( + "enqueue.sidekiq", + "enqueue #{job["class"]} job", + :opentelemetry_kind => :producer + ) do + Appsignal::OpenTelemetry.inject_context(job) + yield + end end end @@ -76,7 +88,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? @@ -88,7 +100,12 @@ 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) + ) transaction.set_action_if_nil(action_name) formatted_metadata(item).each do |key, value| diff --git a/lib/appsignal/integrations/webmachine.rb b/lib/appsignal/integrations/webmachine.rb index 884143053..cf5381c80 100644 --- a/lib/appsignal/integrations/webmachine.rb +++ b/lib/appsignal/integrations/webmachine.rb @@ -10,7 +10,16 @@ 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 + ) end begin 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..1cf4f62be --- /dev/null +++ b/lib/appsignal/logger/opentelemetry_backend.rb @@ -0,0 +1,78 @@ +# 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 + + MUTEX = Mutex.new + + 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 + + # @!visibility private + # + # Test-only. Drops the cached logger so the next call re-resolves + # `OpenTelemetry.logger_provider`. + def reset! + MUTEX.synchronize { @logger = nil } + end + + private + + # Double-checked locking: read the cached logger without the + # mutex on the hot path, take the lock and re-check only on the + # first call. + def logger + @logger || MUTEX.synchronize do + @logger ||= ::OpenTelemetry.logger_provider.logger(:name => "appsignal-logger") + end + 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..77527b27b --- /dev/null +++ b/lib/appsignal/opentelemetry.rb @@ -0,0 +1,291 @@ +# frozen_string_literal: true + +require "appsignal/opentelemetry/attributes" +require "appsignal/opentelemetry/dependencies" + +module Appsignal + # @!visibility private + module OpenTelemetry + class << self + # Configure the global OpenTelemetry SDK to export OTLP/HTTP protobuf to + # the collector endpoint defined in `config[:collector_endpoint]`. + # + # Lazily requires the OpenTelemetry SDK and OTLP exporter gems so that + # users not in collector mode do not pay the load cost. + # + # Sets `@started` to `true` on success, `false` if the SDK gems can't be + # loaded or any other error occurs. Callers can read this via + # {.started?} 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 (both default to "otlp", pointed at the default OTLP + # endpoint), each with its own background thread. We replace both + # providers with our own right after, which would orphan those + # threads -- unreachable by any shutdown. Suppress the auto-setup; + # the exporters we build below are the only ones that should run. + # Set unconditionally: a user-set "otlp" here would otherwise slip + # past and reintroduce the orphaned threads. + 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, ...) using the globally configured propagator (W3C + # TraceContext + baggage). Called by integrations on the emit side so a + # downstream service joins the same trace. + # + # No-op unless the SDK has booted ({.started?}); outside collector mode + # there is no context to propagate. The carrier is injected from whatever + # span is current at call time -- inside an `Appsignal.instrument` block + # that is the AppSignal event span, so the written `traceparent` reflects + # it. + 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 + ::OpenTelemetry.propagation.extract( + env, + :getter => ::OpenTelemetry::Common::Propagation.rack_env_getter + ) + end + end + + # Read the trace context off an incoming background job hash, so a + # transaction created for the job 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` / + # `tracestate` keys (how OpenTelemetry's Sidekiq instrumentation injects) + # and a nested `__otel_headers` (how its ActiveJob instrumentation does). + # ActiveJob runs the headers through ActiveJob's argument serializer, so + # `__otel_headers` arrives as an array of `[key, value]` pairs, not a hash; + # accept both shapes. The nested keys win when present, since ActiveJob is + # the outer, 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) + ::OpenTelemetry.propagation.extract(carrier) + 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? + unmet = unmet_gem_requirements + return true if unmet.empty? + + @started = false + Appsignal::Utils::StdoutAndLoggerMessage.warning( + "Cannot enable collector mode: the installed OpenTelemetry gems are " \ + "older than the minimum supported versions (#{unmet.join(", ")}). " \ + "Update them in your Gemfile; the AppSignal agent will be used instead." + ) + false + end + + # Descriptions of the OpenTelemetry gems that are missing or older than + # the minimum version in {REQUIRED_GEMS}. Empty when all are satisfied. + def unmet_gem_requirements + REQUIRED_GEMS.filter_map do |name, minimum| + spec = Gem.loaded_specs[name] + if spec.nil? + "#{name} (not installed)" + elsif spec.version < Gem::Version.new(minimum) + "#{name} #{spec.version} (requires >= #{minimum})" + end + end + 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..ce13ebfd4 --- /dev/null +++ b/lib/appsignal/opentelemetry/dependencies.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module Appsignal + module OpenTelemetry + # @!visibility private + # + # The OpenTelemetry gems collector mode depends on, mapped to the minimum + # version we support. 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). + # + # The floors are the first releases that support Ruby 3.1 (the family-wide + # "3.1 min version" train), except `opentelemetry-metrics-sdk`, which is + # floored at the release that added `Process._fork`-based fork recovery for + # the periodic metric reader. That fork support is why collector mode + # itself requires Ruby 3.1 (see `MIN_RUBY_VERSION_FOR_COLLECTOR_MODE` in + # `Appsignal::Config`). + # + # 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.8.0", + "opentelemetry-common" => "0.20.0", + "opentelemetry-metrics-sdk" => "0.7.1", + "opentelemetry-logs-sdk" => "0.2.0", + "opentelemetry-exporter-otlp" => "0.30.0", + "opentelemetry-exporter-otlp-metrics" => "0.4.0", + "opentelemetry-exporter-otlp-logs" => "0.2.0" + }.freeze + end +end diff --git a/lib/appsignal/rack/abstract_middleware.rb b/lib/appsignal/rack/abstract_middleware.rb index 8761559d5..6758aedfb 100644 --- a/lib/appsignal/rack/abstract_middleware.rb +++ b/lib/appsignal/rack/abstract_middleware.rb @@ -33,7 +33,10 @@ 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) + ) end unless wrapped_instrumentation diff --git a/lib/appsignal/rack/event_handler.rb b/lib/appsignal/rack/event_handler.rb index 77c299dd6..6ae605c02 100644 --- a/lib/appsignal/rack/event_handler.rb +++ b/lib/appsignal/rack/event_handler.rb @@ -63,7 +63,10 @@ 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 = Appsignal::Transaction.create( + Appsignal::Transaction::HTTP_REQUEST, + :opentelemetry_context => Appsignal::OpenTelemetry.extract_rack_context(request.env) + ) transaction.start_event request.env[APPSIGNAL_TRANSACTION] = transaction diff --git a/lib/appsignal/transaction.rb b/lib/appsignal/transaction.rb index de328628d..7e9714f79 100644 --- a/lib/appsignal/transaction.rb +++ b/lib/appsignal/transaction.rb @@ -29,7 +29,7 @@ class << self # # @param namespace [String] Namespace of the to be created transaction. # @return [Transaction] - def create(namespace) + def create(namespace, opentelemetry_context: 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 +37,12 @@ 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 + ) + ) else transaction = current # Otherwise, log the issue about trying to start another transaction @@ -160,7 +165,7 @@ 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(namespace, id: SecureRandom.uuid, backend: nil, opentelemetry_context: nil) @transaction_id = id @action = nil @namespace = namespace @@ -168,7 +173,6 @@ def initialize(namespace, id: SecureRandom.uuid, ext: nil) @discarded = false @completed = false @tags = {} - @breadcrumbs = [] @store = Hash.new { |hash, key| hash[key] = {} } @error_blocks = Hash.new { |hash, key| hash[key] = [] } @is_duplicate = false @@ -179,11 +183,11 @@ def initialize(namespace, id: SecureRandom.uuid, ext: nil) @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 + ) run_after_create_hooks end @@ -205,9 +209,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 @@ -220,39 +237,17 @@ def complete unless duplicate? self.class.last_errors = @error_blocks.keys - should_sample = @ext.finish(0) + 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 @@ -519,14 +514,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. @@ -543,7 +540,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. @@ -591,7 +588,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. @@ -605,7 +602,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 @@ -615,7 +612,7 @@ 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 # @!visibility private @@ -648,10 +645,10 @@ def add_error(error, &block) # @!visibility private # @see Helpers::Instrumentation#instrument - def start_event + def start_event(opentelemetry_kind: nil) return if paused? - @ext.start_event(0) + @backend.start_event(:opentelemetry_kind => opentelemetry_kind) end # @!visibility private @@ -659,34 +656,46 @@ 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 + ) 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 ) end # @!visibility private # @see Helpers::Instrumentation#instrument - def instrument(name, title = nil, body = nil, body_format = Appsignal::EventFormatter::DEFAULT) - start_event + def instrument( + name, + title = nil, + body = nil, + body_format = Appsignal::EventFormatter::DEFAULT, + opentelemetry_kind: nil + ) + start_event(:opentelemetry_kind => opentelemetry_kind) yield if block_given? ensure finish_event(name, title, body, body_format) @@ -694,34 +703,42 @@ def instrument(name, title = nil, body = nil, body_format = Appsignal::EventForm # @!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, :session_data, :headers # @!visibility private def internal_set_error(error, &block) - _set_error(error) if @error_blocks.empty? + is_new_error = !@error_blocks.include?(error) - if !@error_blocks.include?(error) && @error_blocks.length >= ERRORS_LIMIT + if is_new_error && @error_blocks.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 + + if @error_blocks.empty? + _set_error(error) + elsif is_new_error && @backend.records_errors_eagerly? + # 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 + @error_blocks[error] << block @error_blocks[error].compact! end private - attr_reader :breadcrumbs - def run_after_create_hooks self.class.after_create.each do |block| block.call(self) @@ -734,23 +751,99 @@ def run_before_complete_hooks end end + # Reports the errors stored on the transaction at completion, in one of two + # ways depending on the backend: + # + # - eager (collector): each error was already recorded as its own exception + # event when added, on the span current at that moment; here we only run + # the error blocks. + # - deferred (agent): 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 + if @backend.records_errors_eagerly? + run_error_blocks + else + report_errors_as_duplicates + end + end + + # Eager mode: the errors are already recorded, so just run their blocks. + # Blocks run in add-order, so a later error's block wins on a shared key, and + # all block-set metadata merges onto the root span. (Per-error metadata + # isolation is deferred -- the processor/UI does not read per-event + # attributes yet.) + def run_error_blocks + @error_blocks.each_value do |blocks| + self.class.with_transaction(self) do + blocks.each { |block| block.call(self) } + end + end + end + + # Agent mode: the extension transaction holds a single error, so report each + # additional error as a duplicate transaction. + def report_errors_as_duplicates + @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 + + 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} " \ @@ -759,55 +852,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) @@ -820,10 +868,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,7 +892,6 @@ def sample_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) @@ -855,12 +903,11 @@ 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.session_data = @session_data.dup transaction.headers = @headers.dup diff --git a/lib/appsignal/transaction/base_backend.rb b/lib/appsignal/transaction/base_backend.rb new file mode 100644 index 000000000..9b4c23b53 --- /dev/null +++ b/lib/appsignal/transaction/base_backend.rb @@ -0,0 +1,85 @@ +# 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) + raise NotImplementedError + end + + def finish_event(_name, _title, _body, _body_format) + raise NotImplementedError + end + + def record_event(_name, _title, _body, _body_format, _duration, opentelemetry_kind: nil) # rubocop:disable Metrics/ParameterLists + 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 + + # 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 records each error eagerly onto one trace, or relies + # on the Transaction duplicating itself per error. + def records_errors_eagerly? + raise NotImplementedError + end + + # Lifecycle. + def finish + raise NotImplementedError + end + + def complete + raise NotImplementedError + end + + def discard + raise NotImplementedError + end + + # Only used when `records_errors_eagerly?` is false (agent mode). Backends + # that record eagerly 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..934defbd3 --- /dev/null +++ b/lib/appsignal/transaction/extension_backend.rb @@ -0,0 +1,167 @@ +# 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 + + # `opentelemetry_context` is an incoming trace context used only in + # collector mode; agent mode has no notion of it, so it's ignored here. + def initialize(transaction_id, namespace, handle: nil, opentelemetry_context: nil) # rubocop:disable Lint/UnusedMethodArgument + super() + @handle = handle || + Appsignal::Extension.start_transaction(transaction_id, namespace, 0) || + Appsignal::Extension::MockTransaction.new + @breadcrumbs = [] + end + + # Agent mode has no span kind; `opentelemetry_kind` is ignored here. + def start_event(opentelemetry_kind: 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; `opentelemetry_kind` is ignored here. + def record_event(name, title, body, body_format, duration, opentelemetry_kind: nil) # rubocop:disable Lint/UnusedMethodArgument, Metrics/ParameterLists + @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 + + # `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 records_errors_eagerly? + 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..5fd3bc8c2 --- /dev/null +++ b/lib/appsignal/transaction/opentelemetry_backend.rb @@ -0,0 +1,454 @@ +# 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" + + # Keys correspond to `Appsignal::Transaction::HTTP_REQUEST`, + # `ACTION_CABLE` and `BACKGROUND_JOB` respectively. Spelled as strings + # because this file is required (via `Backends`) before + # `lib/appsignal/transaction.rb`, so the constants are not yet defined + # at class-body evaluation time. + SPAN_KIND_BY_NAMESPACE = { + "http_request" => :server, + "action_cable" => :server, + "background_job" => :consumer + }.freeze + + # Collector treats SERVER/CONSUMER spans as subtrace roots; SERVER is + # the safe default for user-defined namespaces (almost always + # external-triggered units of work). + DEFAULT_SPAN_KIND = :server + + # 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; only surfaces if `complete` has to drain a span that was + # started but never finished. + EVENT_SPAN_PLACEHOLDER_NAME = "appsignal.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 + + def initialize(transaction_id, namespace, opentelemetry_context: nil, **) + super() + @transaction_id = transaction_id + @namespace = namespace + @completed = false + @event_stack = [] + @breadcrumb_count = 0 + @queue_start = nil + @start_time = Time.now + + kind = SPAN_KIND_BY_NAMESPACE.fetch(namespace, DEFAULT_SPAN_KIND) + @span = start_transaction_span(namespace, kind, 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) + span = tracer.start_span(EVENT_SPAN_PLACEHOLDER_NAME, :kind => opentelemetry_kind) + token = ::OpenTelemetry::Context.attach( + ::OpenTelemetry::Trace.context_with_span(span) + ) + @event_stack.push([span, token]) + end + + def finish_event(name, title, body, body_format) + return if @event_stack.empty? + + span, token = @event_stack.pop + write_event_name_attributes(span, name, title) + write_event_body_attributes(span, body, body_format) + ::OpenTelemetry::Context.detach(token) + 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(name, title, body, body_format, duration, opentelemetry_kind: nil) # rubocop:disable Metrics/ParameterLists + start_time = Time.now - (duration / 1_000_000_000.0) + span = tracer.start_span( + EVENT_SPAN_PLACEHOLDER_NAME, + :start_timestamp => start_time, + :kind => opentelemetry_kind + ) + write_event_name_attributes(span, name, title) + write_event_body_attributes(span, body, body_format) + 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) + 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. + 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), + :attributes => { "appsignal.queue_start" => start } + ) + 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 + + # Routes each sample-data category to the attribute the collector reads. + # The JSON-blob categories (params, 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 "params" + @span.set_attribute(params_attribute, 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. Uses AppSignal's own span, not the OTel current span, which + # may belong to another instrumentation. `appsignal.alert_this_error` tells + # the collector to report it even on a child span; the collector computes + # the digest. Causes ride on one `appsignal.error_causes` JSON attribute + # (keys match the processor's `ErrorSubCause`); separate cause events would + # each become their own incident. + def set_error(class_name, message, backtrace, causes, _root_cause_missing) + span = current_span + + attributes = { + "exception.type" => class_name, + "exception.message" => message.to_s, + "exception.stacktrace" => Array(backtrace).join("\n"), + "appsignal.alert_this_error" => true + } + + unless causes.empty? + attributes["appsignal.error_causes"] = JSON.generate( + causes.map do |cause| + { + "name" => cause[:name], + "message" => cause[:message], + "lines" => cause[:backtrace] || [] + } + end + ) + end + + span.add_event("exception", :attributes => attributes) + 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 -- not the + # OTel current span, which may belong to another instrumentation. + # Breadcrumbs are emitted immediately (not flushed at completion) because by + # completion the event span has finished, and the OTel SDK drops events + # added to an ended span. + # + # The breadcrumb's recorded time becomes the event timestamp (events carry + # their own time), so it is not duplicated as an attribute; + # category/action/message are attributes and the metadata Hash is a JSON + # string (event attributes are flat). + # + # Capped at `BREADCRUMB_LIMIT` per transaction: streamed events can't be + # retracted, so we keep the first N rather than agent mode's last N. + 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 metric + # idempotent across a double `complete`, and skips it on `discard`. + emit_queue_duration_metric unless @completed + teardown + end + + # Discarding does not mean "don't send" as it does in agent mode: the + # root span is still finished and exported, but flagged with + # `appsignal.ignore_subtrace = true` so the collector drops the whole + # subtrace (the attribute is hoisted to the subtrace root, last write + # wins). The flag must be written before the span finishes -- attributes + # set on an ended span are dropped. Tearing the span down here also + # detaches the context, so a discarded transaction can't leak its root + # span as the current OTel context onto 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 the Transaction never duplicates itself -- + # which is why `duplicate` is left unimplemented (see BaseBackend). + def records_errors_eagerly? + 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? + span, token = @event_stack.pop + ::OpenTelemetry::Context.detach(token) + span.finish + end + ::OpenTelemetry::Context.detach(@context_token) if @context_token + @span&.finish + 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 + + def hostname + Appsignal.config&.[](:hostname) || Socket.gethostname + end + + def tracer + ::OpenTelemetry.tracer_provider.tracer(TRACER_NAME, Appsignal::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 + span, _token = @event_stack.last + span || @span + end + + def placeholder_span_name(namespace) + "appsignal.transaction #{namespace}" + end + + # Open the transaction's root span, relating it to any incoming trace + # context by the unit of work's kind: + # + # - SERVER (web): parent under the remote span so the transaction + # continues the upstream trace. + # - CONSUMER (jobs): start a fresh trace linked back to the remote span. A + # job is its own unit of work decoupled from the enqueuer, so it gets its + # own trace, with a link recording the causal relationship. + # - No context, an invalid remote span, or any other kind: a plain root + # span that ignores any ambient OTel context, as a transaction is its + # own unit of work. + def start_transaction_span(namespace, kind, opentelemetry_context) + name = placeholder_span_name(namespace) + remote = remote_span_context(opentelemetry_context) + + if remote && kind == :server + tracer.start_span(name, :with_parent => opentelemetry_context, :kind => kind) + elsif remote && kind == :consumer + tracer.start_root_span( + name, + :kind => kind, + :links => [::OpenTelemetry::Trace::Link.new(remote)] + ) + else + tracer.start_root_span(name, :kind => kind) + end + 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 collector exposes three params channels (query parameters, request + # payload, function parameters), each separately filtered and labeled in + # the trace UI. The gem only has a single merged params blob, so route it + # by namespace: message/job (CONSUMER-kind) transactions use the + # function-parameters channel, everything else (web-style, SERVER-kind) + # uses the request-payload channel. + def params_attribute + if SPAN_KIND_BY_NAMESPACE.fetch(@namespace, DEFAULT_SPAN_KIND) == :consumer + "appsignal.function.parameters" + else + "appsignal.request.payload" + end + 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 + + # The OTel span name is what the collector surfaces as the event's + # label in the trace UI, so prefer the human-readable `title` (e.g. + # "User Load", "GET https://example.com") and fall back to the AS::N + # `name` (e.g. "sql.active_record") when no formatter supplied a title. + # The machine name still rides along in `appsignal.category` so it is + # not lost once the title wins the span name -- it keeps the event's + # grouping key available for later filtering. + def write_event_name_attributes(span, name, title) + span.name = title && !title.empty? ? title : name + span.set_attribute("appsignal.category", name) + end + + def write_event_body_attributes(span, body, body_format) + return if body.nil? || body.empty? + + if body_format == Appsignal::EventFormatter::SQL_BODY_FORMAT + span.set_attribute("db.query.text", body) + span.set_attribute("db.system.name", SQL_DB_SYSTEM) + else + span.set_attribute("appsignal.body", body) + end + 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/sig/appsignal.rbi b/sig/appsignal.rbi index ac31143d8..c7ea4c8a6 100644 --- a/sig/appsignal.rbi +++ b/sig/appsignal.rbi @@ -833,7 +833,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". # @@ -922,10 +922,11 @@ module Appsignal title: T.nilable(String), body: T.nilable(String), body_format: Integer, + opentelemetry_kind: T.untyped, 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, &block); end # Instrumentation helper for SQL queries. # @@ -1041,6 +1042,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 @@ -1626,8 +1659,8 @@ module Appsignal # transaction. # # _@param_ `namespace` — Namespace of the to be created transaction. - sig { params(namespace: String).returns(Transaction) } - def self.create(namespace); end + sig { params(namespace: String, opentelemetry_context: T.untyped).returns(Transaction) } + def self.create(namespace, opentelemetry_context: nil); end # Returns currently active transaction or a {NilTransaction} if none is # active. @@ -2453,7 +2486,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". # @@ -2542,10 +2575,11 @@ module Appsignal title: T.nilable(String), body: T.nilable(String), body_format: Integer, + opentelemetry_kind: T.untyped, 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, &block); end # Instrumentation helper for SQL queries. # @@ -2629,6 +2663,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 b2d4a0a91..e5eea8673 100644 --- a/sig/appsignal.rbs +++ b/sig/appsignal.rbs @@ -778,7 +778,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". # @@ -862,7 +862,8 @@ module Appsignal String name, ?String? title, ?String? body, - ?Integer body_format + ?Integer body_format, + ?opentelemetry_kind: untyped ) -> Object # Instrumentation helper for SQL queries. @@ -991,6 +992,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. @@ -1499,7 +1530,7 @@ module Appsignal # transaction. # # _@param_ `namespace` — Namespace of the to be created transaction. - def self.create: (String namespace) -> Transaction + def self.create: (String namespace, ?opentelemetry_context: untyped) -> Transaction # Returns currently active transaction or a {NilTransaction} if none is # active. @@ -2291,7 +2322,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". # @@ -2375,7 +2406,8 @@ module Appsignal String name, ?String? title, ?String? body, - ?Integer body_format + ?Integer body_format, + ?opentelemetry_kind: untyped ) -> Object # Instrumentation helper for SQL queries. @@ -2450,6 +2482,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..81a314c69 --- /dev/null +++ b/spec/integration/collector_mode_traces_spec.rb @@ -0,0 +1,55 @@ +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. The title-less + # events keep the event name as the span name; the SQL event has a + # human-readable title ("Find user"), which becomes the span name, with + # the event name carried in the `appsignal.category` attribute. + expect(by_name.keys).to include("template.render", "partial.render") + sql = spans.find { |s| attribute_value(s, "appsignal.category") == "active_record.sql" } + expect(sql).not_to be_nil + expect(sql.name).to eq("Find user") + + # 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") + end + + def attribute_value(span, key) + pair = span.attributes.find { |attr| attr.key == key } + pair&.value&.string_value + end + end +end diff --git a/spec/integration/diagnose b/spec/integration/diagnose index 1e98e7d5e..69b23b08b 160000 --- a/spec/integration/diagnose +++ b/spec/integration/diagnose @@ -1 +1 @@ -Subproject commit 1e98e7d5ef86f0bf3631d1b45fdfdc86f9ced736 +Subproject commit 69b23b08bd80de5971d9d803a7e7244558c1c234 diff --git a/spec/integration/runner.rb b/spec/integration/runner.rb index aa1d9d7be..b337ba392 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,13 @@ def run end read_output @finished = true + + return if @status.exitstatus.zero? + + raise "Runner '#{@script_file}' exited with status #{@status.exitstatus}.\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 c5a2fb8c7..08df217dd 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, @@ -725,8 +726,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", @@ -750,11 +755,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, @@ -768,6 +778,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", @@ -778,6 +789,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", @@ -808,19 +820,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" @@ -919,6 +939,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", @@ -934,8 +955,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 => [], @@ -954,10 +979,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 => {} ) @@ -1609,6 +1639,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/hooks/action_cable_spec.rb b/spec/lib/appsignal/hooks/action_cable_spec.rb index a1b67e936..b5b4e4041 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,104 @@ 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(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 +168,289 @@ 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(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 +460,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..a52dc028e 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,200 @@ 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(span.attributes["appsignal.category"]).to eq("sql.active_record") + expect(span.attributes).not_to have_key("appsignal.body") + 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 event with no registered formatter" do + def perform + as.instrument("no-registered.formatter", :key => "something") { "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 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(span.attributes["appsignal.category"]).to eq("no-registered.formatter") + expect(span.attributes).not_to have_key("db.query.text") + expect(span.attributes).not_to have_key("db.system.name") + 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 "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(span.attributes["appsignal.category"]).to eq("not_a_string") + 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 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 - expect(return_value).to eq "value" + it "in collector mode", :collector_mode do + start_collector_agent + transaction = http_request_transaction + set_current_transaction(transaction) + as.notifier = notifier - expect(transaction).to_not include_events + expect(perform).to eq "value" + Appsignal::Transaction.complete_current! + + expect(event_spans).to be_empty + 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 suppressed event, recorded by a dedicated integration" do + def perform + as.instrument("request.faraday", :method => :get) { "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(perform).to eq "value" + expect(transaction).to_not include_events + end - expect(transaction).to_not include_events + 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 - context "when an error is raised in an instrumented block" do - it "instruments an ActiveSupport::Notifications.instrument event" do + 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 +204,41 @@ "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 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 +248,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..efad02d38 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,153 @@ 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 body is empty — + # the OTel backend skips writing db.query.text / db.system.name. + expect(span.attributes).not_to have_key("db.query.text") + expect(span.attributes).not_to have_key("db.system.name") + 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 9d2a8e8ff..2d7699d5b 100644 --- a/spec/lib/appsignal/hooks/activejob_spec.rb +++ b/spec/lib/appsignal/hooks/activejob_spec.rb @@ -73,10 +73,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 @@ -103,41 +103,90 @@ 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["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 - expect(last_transaction).to include_tags("queue" => "custom_queue") + 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) + + perform + last_transaction.complete + + expect(root_span.attributes["appsignal.tag.queue"]).to eq("custom_queue") + end end end @@ -152,67 +201,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 - queue_job(ActiveJobPriorityTestJob) + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) - expect(last_transaction).to include_tags("queue" => queue, "priority" => 10) + allow(Appsignal).to receive(:increment_counter) + + perform + 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) + + 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 @@ -220,35 +323,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 @@ -265,147 +407,327 @@ 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 + + it "leaves the job untouched outside collector mode", :agent_mode do + start_agent(**start_agent_args) + + job = ActiveJobTestJob.new + job.__otel_headers = { "traceparent" => traceparent } - context "with an active transaction" do - it "records a single enqueue.active_job event on the transaction" do + 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, named after the job being enqueued. + producer = event_spans.find { |s| s.name == "enqueue ActiveJobTestJob job" } + expect(producer.attributes["appsignal.category"]).to eq("enqueue.active_job") + expect(producer.kind).to eq(:producer) + 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 "starts a linked trace in collector mode", :collector_mode do + start_collector_agent + perform_with_incoming_context + + # A job is its own unit of work: new trace, linked back to the enqueuer. + expect(root_span.kind).to eq(:consumer) + expect(root_span.hex_trace_id).to_not eq(trace_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 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 @@ -436,12 +758,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 @@ -473,22 +812,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 @@ -502,55 +836,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), @@ -559,6 +905,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 @@ -576,42 +996,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( @@ -621,15 +1024,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", @@ -641,12 +1132,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 @@ -688,4 +1205,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..8d8da81c1 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,40 @@ 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") + 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 +105,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 +117,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 +138,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 +150,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 +168,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 +177,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 43e183058..c51798cbf 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" 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,42 @@ "title" => "query.postgres" ) 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.postgres") + 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") + expect(attrs["db.system.name"]).to eq("other_sql") + expect(attrs["appsignal.category"]).to eq("query.postgres") + expect(attrs).not_to have_key("appsignal.body") + end end - context "when is an unregistered formatter event" do + describe "an unregistered formatter event" do let(:event_id) { :foo } - let(:payload) do - { - :name => "foo" - } - end + let(:payload) { { :name => "foo" } } - it "creates a generic event" 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 +104,26 @@ "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") + 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) + attrs = span.attributes + expect(attrs["appsignal.category"]).to eq("foo") + 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 bf18e522a..de8654ccc 100644 --- a/spec/lib/appsignal/hooks/excon_spec.rb +++ b/spec/lib/appsignal/hooks/excon_spec.rb @@ -1,17 +1,31 @@ describe Appsignal::Hooks::ExconHook do - before { start_agent } - context "with Excon" do before do stub_const("Excon", Class.new do def self.defaults - @defaults ||= {} + # Mock is the innermost default middleware; the hook inserts ours + # before it. Referenced lazily so it's resolved after the stub below. + @defaults ||= { :middlewares => [Excon::Middleware::Mock] } end end) + stub_const("Excon::Middleware", Module.new) + stub_const("Excon::Middleware::Base", Class.new do + def initialize(stack = nil) + @stack = stack + end + + # The default `request_call` just returns the datum, standing in for the + # rest of the (empty) middleware stack. + def request_call(datum) + datum + end + end) + stub_const("Excon::Middleware::Mock", Class.new(Excon::Middleware::Base)) Appsignal::Hooks::ExconHook.new.install end describe "#dependencies_present?" do + before { start_agent } subject { described_class.new.dependencies_present? } it { is_expected.to be_truthy } @@ -21,37 +35,121 @@ def self.defaults it "adds the AppSignal instrumentor to Excon" do expect(Excon.defaults[:instrumentor]).to eql(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 describe "instrumentation" do - let(:transaction) { http_request_transaction } - before { set_current_transaction(transaction) } - around { |example| keep_transactions { example.run } } - - it "instruments a http request" do - data = { - :host => "www.google.com", - :method => :get, - :scheme => "http" - } - Excon.defaults[:instrumentor].instrument("excon.request", data) {} # rubocop:disable Lint/EmptyBlock - - expect(transaction).to include_event( - "name" => "request.excon", - "title" => "GET http://www.google.com", - "body" => "" - ) + describe "a http request" do + def perform + data = { + :host => "www.google.com", + :method => :get, + :scheme => "http" + } + Excon.defaults[:instrumentor].instrument("excon.request", data) do + # The middleware injects from whatever span is current. The + # instrumentor's event span is active here, mirroring a real + # request where the middleware runs inside the instrumented block. + datum = inject_with_middleware + ensure + @injected_headers = datum && datum[:headers] + end + end + + # Runs the AppSignal Excon middleware's `request_call` over an empty + # datum, returning the datum so we can read the injected headers. + def inject_with_middleware + # Excon middlewares wrap the next one in the stack and forward to it, + # so pass a tail that just returns the datum. Excon's Middleware::Base + # requires this argument. + tail = Class.new do + def request_call(datum) + datum + end + end.new + middleware = Appsignal::Integrations::ExconMiddleware.new(tail) + middleware.request_call({}) + 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.excon", + "title" => "GET http://www.google.com", + "body" => "" + ) + end + + it "in collector mode", :collector_mode do + start_collector_agent + set_current_transaction(http_request_transaction) + perform + Appsignal::Transaction.complete_current! + + expect(event_spans.size).to eq(1) + span = event_spans.first + expect(span.name).to eq("GET http://www.google.com") + expect(span.kind).to eq(:client) + expect(span.parent_span_id).to eq(root_span.span_id) + expect(span.attributes["appsignal.category"]).to eq("request.excon") + expect(span.attributes).not_to have_key("appsignal.body") + + # The middleware wrote a W3C traceparent for the client span onto the + # outgoing request headers, so the called service joins this trace. + expect(@injected_headers["traceparent"]) + .to eq("00-#{span.hex_trace_id}-#{span.hex_span_id}-01") + end end - it "instruments a http response" do - data = { :host => "www.google.com" } - Excon.defaults[:instrumentor].instrument("excon.response", data) {} # rubocop:disable Lint/EmptyBlock + describe "a http response" do + def perform + data = { :host => "www.google.com" } + Excon.defaults[:instrumentor].instrument("excon.response", data) {} # rubocop:disable Lint/EmptyBlock + 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" => "response.excon", + "title" => "www.google.com", + "body" => "" + ) + end + + it "in collector mode", :collector_mode do + start_collector_agent + set_current_transaction(http_request_transaction) + perform + Appsignal::Transaction.complete_current! - expect(transaction).to include_event( - "name" => "response.excon", - "title" => "www.google.com", - "body" => "" - ) + expect(event_spans.size).to eq(1) + span = event_spans.first + expect(span.name).to eq("www.google.com") + expect(span.parent_span_id).to eq(root_span.span_id) + expect(span.attributes["appsignal.category"]).to eq("response.excon") + expect(span.attributes).not_to have_key("appsignal.body") + end 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..7a8b64614 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,41 @@ 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(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 +101,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 +144,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 +158,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 +201,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 +220,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..a9fa819cf 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,82 @@ 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("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(span.attributes["appsignal.category"]).to eq("query.redis") + 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("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(span.attributes["appsignal.category"]).to eq("query.redis") + expect(span.attributes).not_to have_key("db.query.text") + end end end end @@ -118,6 +166,7 @@ def write(_commands) context "install" do before do + start_agent(:options => options) Appsignal::Hooks.load_hooks end @@ -131,6 +180,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 +192,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 +211,80 @@ 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("stub_id") + expect(span.parent_span_id).to eq(root_span.span_id) + expect(span.attributes["appsignal.body"]).to eq("get ?") + expect(span.attributes["appsignal.category"]).to eq("query.redis") + 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("stub_id") + expect(span.parent_span_id).to eq(root_span.span_id) + expect(span.attributes["appsignal.body"]).to eq("#{script} ? ?") + expect(span.attributes["appsignal.category"]).to eq("query.redis") + expect(span.attributes).not_to have_key("db.query.text") + end end end end @@ -200,6 +296,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 +306,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..28db616a4 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. @@ -69,33 +72,81 @@ 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("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(span.attributes["appsignal.category"]).to eq("query.redis") + 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("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(span.attributes["appsignal.category"]).to eq("query.redis") + expect(span.attributes).not_to have_key("db.query.text") + end end end end @@ -105,6 +156,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 +167,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..d82805af7 100644 --- a/spec/lib/appsignal/hooks/sequel_spec.rb +++ b/spec/lib/appsignal/hooks/sequel_spec.rb @@ -8,30 +8,48 @@ 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) - - expect(db).to receive(:log_duration).at_least(:once) + 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 - db["SELECT 1"].all.to_a + 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.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) + expect(span.attributes["db.system.name"]).to eq("other_sql") + expect(span.attributes).not_to have_key("appsignal.body") + expect(span.attributes["appsignal.category"]).to eq("sql.sequel") 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..57240a140 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,28 @@ 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("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") + expect(attrs["db.system.name"]).to eq("other_sql") + expect(attrs["appsignal.category"]).to eq("query.data_mapper") + 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 +77,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 +95,25 @@ 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("DataMapper Query") + expect(span.kind).to eq(:client) + expect(span.parent_span_id).to eq(root_span.span_id) + attrs = span.attributes + expect(attrs["appsignal.category"]).to eq("query.data_mapper") + 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 25b4eb3aa..28e39df1e 100644 --- a/spec/lib/appsignal/integrations/delayed_job_plugin_spec.rb +++ b/spec/lib/appsignal/integrations/delayed_job_plugin_spec.rb @@ -12,7 +12,6 @@ def self.plugins end end) require "appsignal/integrations/delayed_job_plugin" - start_agent(:options => options) end # We haven't found a way to test the hooks, we'll have to do that manually @@ -23,6 +22,7 @@ def self.plugins let(:created_at) { time - 3600 } let(:run_at) { time - 3600 } let(:payload_object) { double(:args => args) } + let(:start_agent_args) { { :options => options } } let(:job_data) do { :id => 123, @@ -41,28 +41,50 @@ def self.plugins def perform Timecop.freeze(time) do - keep_transactions do - plugin.invoke_with_instrumentation(job, invoked_block) - end + plugin.invoke_with_instrumentation(job, invoked_block) end end context "with a normal call" do - it "wraps it in a transaction" do - perform - - transaction = last_transaction - expect(transaction).to have_namespace("background_job") - expect(transaction).to have_action("TestClass#perform") - expect(transaction).to_not have_error - expect(transaction).to include_event(:name => "perform_job.delayed_job") - expect(transaction).to include_tags( - "priority" => 1, - "attempts" => 1, - "queue" => "default", - "id" => "123" - ) - expect(transaction).to include_params(["argument"]) + describe "wraps it in a transaction" do + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + + transaction = last_transaction + expect(transaction).to have_namespace("background_job") + expect(transaction).to have_action("TestClass#perform") + expect(transaction).to_not have_error + expect(transaction).to include_event(:name => "perform_job.delayed_job") + expect(transaction).to include_tags( + "priority" => 1, + "attempts" => 1, + "queue" => "default", + "id" => "123" + ) + expect(transaction).to include_params(["argument"]) + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + + expect(root_span.kind).to eq(:consumer) + expect(root_span.attributes["appsignal.action_name"]).to eq("TestClass#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.delayed_job" } + 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(span.attributes["appsignal.category"]).to eq("perform_job.delayed_job") + expect(root_span.attributes["appsignal.tag.priority"]).to eq(1) + expect(root_span.attributes["appsignal.tag.attempts"]).to eq(1) + expect(root_span.attributes["appsignal.tag.queue"]).to eq("default") + expect(root_span.attributes["appsignal.tag.id"]).to eq("123") + expect(JSON.parse(root_span.attributes["appsignal.function.parameters"])) + .to eq(["argument"]) + end end context "with more complex params" do @@ -73,19 +95,41 @@ def perform } end - it "adds the more complex arguments" do - perform + describe "adds the more complex arguments" do + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + + expect(last_transaction).to include_params("foo" => "Foo", "bar" => "Bar") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform - expect(last_transaction).to include_params("foo" => "Foo", "bar" => "Bar") + expect(JSON.parse(root_span.attributes["appsignal.function.parameters"])) + .to eq("foo" => "Foo", "bar" => "Bar") + end end context "with parameter filtering" do let(:options) { { :filter_parameters => ["foo"] } } - it "filters selected arguments" do - perform + describe "filters selected arguments" do + 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 @@ -93,7 +137,8 @@ def perform context "with run_at in the future" do let(:run_at) { Time.parse("2017-01-01 10:01:00UTC") } - it "reports queue_start with run_at time" do + it "reports queue_start with run_at time", :agent_mode do + start_agent(**start_agent_args) perform expect(last_transaction).to have_queue_start(run_at.to_i * 1000) @@ -105,39 +150,87 @@ def perform { :name => "CustomClassMethod.perform", :payload_object => payload_object } end - it "wraps it in a transaction using the class method job name" do - perform - expect(last_transaction).to have_action("CustomClassMethod.perform") + describe "wraps it in a transaction using the class method job name" do + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + + expect(last_transaction).to have_action("CustomClassMethod.perform") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + + expect(root_span.attributes["appsignal.action_name"]) + .to eq("CustomClassMethod.perform") + end end end context "with custom name call" do - before { perform } - context "with appsignal_name defined" do context "with payload_object being an object" do context "with value" do let(:payload_object) { double(:appsignal_name => "CustomClass#perform") } - it "wraps it in a transaction using the custom name" do - expect(last_transaction).to have_action("CustomClass#perform") + describe "wraps it in a transaction using the custom name" do + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + + expect(last_transaction).to have_action("CustomClass#perform") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + + expect(root_span.attributes["appsignal.action_name"]) + .to eq("CustomClass#perform") + end end end context "with non-String value" do let(:payload_object) { double(:appsignal_name => Object.new) } - it "wraps it in a transaction using the original job name" do - expect(last_transaction).to have_action("TestClass#perform") + describe "wraps it in a transaction using the original job name" do + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + + expect(last_transaction).to have_action("TestClass#perform") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + + expect(root_span.attributes["appsignal.action_name"]) + .to eq("TestClass#perform") + end end end context "with class method name as job" do let(:payload_object) { double(:appsignal_name => "CustomClassMethod.perform") } - it "wraps it in a transaction using the custom name" do - perform - expect(last_transaction).to have_action("CustomClassMethod.perform") + describe "wraps it in a transaction using the custom name" do + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + + expect(last_transaction).to have_action("CustomClassMethod.perform") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + + expect(root_span.attributes["appsignal.action_name"]) + .to eq("CustomClassMethod.perform") + end end end end @@ -146,25 +239,63 @@ def perform context "with value" do let(:payload_object) { double(:appsignal_name => "CustomClassHash#perform") } - it "wraps it in a transaction using the custom name" do - expect(last_transaction).to have_action("CustomClassHash#perform") + describe "wraps it in a transaction using the custom name" do + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + + expect(last_transaction).to have_action("CustomClassHash#perform") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + + expect(root_span.attributes["appsignal.action_name"]) + .to eq("CustomClassHash#perform") + end end end context "with non-String value" do let(:payload_object) { double(:appsignal_name => Object.new) } - it "wraps it in a transaction using the original job name" do - expect(last_transaction).to have_action("TestClass#perform") + describe "wraps it in a transaction using the original job name" do + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + + expect(last_transaction).to have_action("TestClass#perform") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + + expect(root_span.attributes["appsignal.action_name"]) + .to eq("TestClass#perform") + end end end context "with class method name as job" do let(:payload_object) { { :appsignal_name => "CustomClassMethod.perform" } } - it "wraps it in a transaction using the custom name" do - perform - expect(last_transaction).to have_action("CustomClassMethod.perform") + describe "wraps it in a transaction using the custom name" do + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + + expect(last_transaction).to have_action("CustomClassMethod.perform") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + + expect(root_span.attributes["appsignal.action_name"]) + .to eq("CustomClassMethod.perform") + end end end end @@ -185,8 +316,21 @@ def self.appsignal_name # this means ClassActingAsHash returns `Object.new` instead # of `self.appsignal_name`. Since this isn't a valid `String` # we return the default job name as action name. - it "wraps it in a transaction using the original job name" do - expect(last_transaction).to have_action("TestClass#perform") + describe "wraps it in a transaction using the original job name" do + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + + expect(last_transaction).to have_action("TestClass#perform") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + + expect(root_span.attributes["appsignal.action_name"]) + .to eq("TestClass#perform") + end end end end @@ -197,9 +341,20 @@ def self.appsignal_name { :name => "Banana", :payload_object => payload_object } end - it "appends #perform to the class name" do - perform - expect(last_transaction).to have_action("Banana#perform") + describe "appends #perform to the class name" do + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + + expect(last_transaction).to have_action("Banana#perform") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + + expect(root_span.attributes["appsignal.action_name"]).to eq("Banana#perform") + end end end @@ -230,21 +385,45 @@ def self.appsignal_name end let(:args) { ["activejob_argument"] } - it "wraps it in a transaction with the correct params" do - perform + describe "wraps it in a transaction with the correct params" do + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform - transaction = last_transaction - expect(transaction).to have_namespace("background_job") - expect(transaction).to have_action("TestClass#perform") - expect(transaction).to_not have_error - expect(transaction).to include_event("name" => "perform_job.delayed_job") - expect(transaction).to include_tags( - "priority" => 1, - "attempts" => 1, - "queue" => "default", - "id" => "123" - ) - expect(transaction).to include_params(["activejob_argument"]) + transaction = last_transaction + expect(transaction).to have_namespace("background_job") + expect(transaction).to have_action("TestClass#perform") + expect(transaction).to_not have_error + expect(transaction).to include_event("name" => "perform_job.delayed_job") + expect(transaction).to include_tags( + "priority" => 1, + "attempts" => 1, + "queue" => "default", + "id" => "123" + ) + expect(transaction).to include_params(["activejob_argument"]) + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + + expect(root_span.kind).to eq(:consumer) + expect(root_span.attributes["appsignal.action_name"]).to eq("TestClass#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.delayed_job" } + 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(span.attributes["appsignal.category"]).to eq("perform_job.delayed_job") + expect(root_span.attributes["appsignal.tag.priority"]).to eq(1) + expect(root_span.attributes["appsignal.tag.attempts"]).to eq(1) + expect(root_span.attributes["appsignal.tag.queue"]).to eq("default") + expect(root_span.attributes["appsignal.tag.id"]).to eq("123") + expect(JSON.parse(root_span.attributes["appsignal.function.parameters"])) + .to eq(["activejob_argument"]) + end end context "with more complex params" do @@ -255,35 +434,62 @@ def self.appsignal_name } end - it "adds the more complex arguments" do - perform - transaction = last_transaction - expect(transaction).to have_action("TestClass#perform") - expect(transaction).to include_params( - "foo" => "Foo", - "bar" => "Bar" - ) - end - - context "with parameter filtering" do - let(:options) { { :filter_parameters => ["foo"] } } - - it "filters selected arguments" do + describe "adds the more complex arguments" do + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) perform + transaction = last_transaction expect(transaction).to have_action("TestClass#perform") expect(transaction).to include_params( - "foo" => "[FILTERED]", + "foo" => "Foo", "bar" => "Bar" ) end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + + expect(root_span.attributes["appsignal.action_name"]).to eq("TestClass#perform") + expect(JSON.parse(root_span.attributes["appsignal.function.parameters"])) + .to eq("foo" => "Foo", "bar" => "Bar") + end + end + + context "with parameter filtering" do + let(:options) { { :filter_parameters => ["foo"] } } + + describe "filters selected arguments" do + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + + transaction = last_transaction + expect(transaction).to have_action("TestClass#perform") + expect(transaction).to include_params( + "foo" => "[FILTERED]", + "bar" => "Bar" + ) + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + + expect(root_span.attributes["appsignal.action_name"]).to eq("TestClass#perform") + expect(JSON.parse(root_span.attributes["appsignal.function.parameters"])) + .to eq("foo" => "[FILTERED]", "bar" => "Bar") + end + end end end context "with run_at in the future" do let(:run_at) { Time.parse("2017-01-01 10:01:00UTC") } - it "reports queue_start with run_at time" do + it "reports queue_start with run_at time", :agent_mode do + start_agent(**start_agent_args) perform expect(last_transaction).to have_queue_start(run_at.to_i * 1000) @@ -299,15 +505,36 @@ def self.appsignal_name expect(invoked_block).to receive(:call).and_raise(error) end - it "adds the error to the transaction" do - expect do - perform - end.to raise_error(error) + describe "adds the error to the transaction" do + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + expect do + perform + end.to raise_error(error) + + transaction = last_transaction + expect(transaction).to have_namespace("background_job") + expect(transaction).to have_action("TestClass#perform") + expect(transaction).to have_error("ExampleException", "uh oh") + end - transaction = last_transaction - expect(transaction).to have_namespace("background_job") - expect(transaction).to have_action("TestClass#perform") - expect(transaction).to have_error("ExampleException", "uh oh") + it "in collector mode", :collector_mode do + start_collector_agent + expect do + perform + end.to raise_error(error) + + 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) + end end end end @@ -315,6 +542,8 @@ def self.appsignal_name describe ".extract_value" do let(:plugin) { Appsignal::Integrations::DelayedJobPlugin } + before { start_agent } + context "for a hash" do let(:hash) { { :key => "value", :bool_false => false } } diff --git a/spec/lib/appsignal/integrations/excon_spec.rb b/spec/lib/appsignal/integrations/excon_spec.rb new file mode 100644 index 000000000..3a50cf245 --- /dev/null +++ b/spec/lib/appsignal/integrations/excon_spec.rb @@ -0,0 +1,60 @@ +if DependencyHelper.excon_present? + require "excon" + require "appsignal/integrations/excon" + require "appsignal/integrations/excon/appsignal_middleware" + + # Integration test against the real Excon gem (the hooks/excon_spec.rb suite + # stubs Excon). Verifies, end to end, that the inject-only middleware writes + # trace context onto a live outgoing request while the instrumentor's client + # event span is current -- the ordering the stubbed suite can't prove. + describe "Excon integration" do + before { Appsignal::Hooks::ExconHook.new.install } + + describe "a GET request" do + def perform + stub_request(:get, "http://www.example.com/") + Excon.get("http://www.example.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.excon", + "title" => "GET http://www.example.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! + + span = event_spans.find { |s| s.attributes["appsignal.category"] == "request.excon" } + expect(span).not_to be_nil + expect(span.kind).to eq(:client) + expect(span.parent_span_id).to eq(root_span.span_id) + + # The injected traceparent must reflect the Excon CLIENT event span, not + # the root span -- proving the middleware runs inside the instrumentor's + # event span on a real request. + expect(injected_traceparent("http://www.example.com/")) + .to eq("00-#{span.hex_trace_id}-#{span.hex_span_id}-01") + 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/faraday_spec.rb b/spec/lib/appsignal/integrations/faraday_spec.rb index 46d3658dd..45affbb9c 100644 --- a/spec/lib/appsignal/integrations/faraday_spec.rb +++ b/spec/lib/appsignal/integrations/faraday_spec.rb @@ -1,25 +1,25 @@ 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 } # 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,36 +34,68 @@ 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.parent_span_id).to eq(root_span.span_id) + + # 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. - describe "a request over the Excon adapter", :if => DependencyHelper.excon_present? do - before { Appsignal::Hooks::ExconHook.new.install } + # With a non-Net::HTTP adapter (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) - def perform - stub_request(:get, "http://www.example.com/") - connection = Faraday.new("http://www.example.com") do |faraday| - faraday.adapter :excon + 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 - connection.get("/") end + connection.get("/") + Appsignal::Transaction.complete_current! - it "records the request once, as the Faraday event" do - start_agent - transaction = http_request_transaction - set_current_transaction(transaction) - perform + 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 - expect(transaction).to include_event( - "name" => "request.faraday", - "title" => "GET http://www.example.com", - "body" => "" - ) - # Excon is suppressed under Faraday, so it isn't recorded again. - expect(transaction).to_not include_event("name" => "request.excon") - end + # Finds the recorded event span for an `appsignal.category` (AS::N name). + def event_span(category) + event_spans.find { |span| span.attributes["appsignal.category"] == 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..b48478ecd 100644 --- a/spec/lib/appsignal/integrations/http_spec.rb +++ b/spec/lib/appsignal/integrations/http_spec.rb @@ -6,78 +6,177 @@ 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("GET http://www.google.com") + expect(span.kind).to eq(:client) + expect(span.parent_span_id).to eq(root_span.span_id) + expect(span.attributes["appsignal.category"]).to eq("request.http_rb") + 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", :params => { :q => "Appsignal" }) + HTTP.get("https://www.google.com") + end + + 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("GET https://www.google.com") + expect(span.kind).to eq(:client) + expect(span.parent_span_id).to eq(root_span.span_id) + expect(span.attributes["appsignal.category"]).to eq("request.http_rb") + 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("GET https://www.google.com") + expect(span.attributes["appsignal.category"]).to eq("request.http_rb") + expect(span.attributes).not_to have_key("appsignal.body") + 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("POST https://www.google.com") + expect(span.attributes["appsignal.category"]).to eq("request.http_rb") + 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 +184,191 @@ ["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("GET http://www.google.com") + expect(span.kind).to eq(:client) + expect(span.attributes["appsignal.category"]).to eq("request.http_rb") + 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("GET http://www.google.com") + expect(span.attributes["appsignal.category"]).to eq("request.http_rb") + 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("GET http://www.google.com") + expect(span.attributes["appsignal.category"]).to eq("request.http_rb") + 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")) + HTTP.get(HTTP::URI.parse("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("GET http://www.google.com") + expect(span.attributes["appsignal.category"]).to eq("request.http_rb") + 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("GET http://www.google.com") + expect(span.attributes["appsignal.category"]).to eq("request.http_rb") + 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("GET http://www.example.com") + expect(span.attributes["appsignal.category"]).to eq("request.http_rb") + 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..9c5bc9aa0 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 } @@ -38,9 +37,10 @@ def command_failed_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 +58,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 +77,27 @@ 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_spans.find { |s| s.attributes["appsignal.category"] == "query.mongodb" } + expect(span).not_to be_nil + expect(span.name).to eq("find | test | SUCCEEDED") + expect(span.kind).to eq(:client) + expect(span.parent_span_id).to eq(root_span.span_id) + expect(span.attributes["appsignal.category"]).to eq("query.mongodb") + expect(span.attributes["appsignal.body"]).to eq("{\"foo\":\"?\"}") + + 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 failed query" do @@ -88,7 +109,7 @@ 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) @@ -101,10 +122,27 @@ 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_spans.find { |s| s.attributes["appsignal.category"] == "query.mongodb" } + expect(span).not_to be_nil + expect(span.name).to eq("find | test | FAILED") + expect(span.kind).to eq(:client) + expect(span.attributes["appsignal.category"]).to eq("query.mongodb") + expect(span.attributes["appsignal.body"]).to eq("{\"foo\":\"?\"}") + 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 +150,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 +158,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 +176,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 +188,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..76238c2f0 100644 --- a/spec/lib/appsignal/integrations/net_http_spec.rb +++ b/spec/lib/appsignal/integrations/net_http_spec.rb @@ -1,33 +1,97 @@ 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("GET http://www.google.com") + expect(span.kind).to eq(:client) + expect(span.parent_span_id).to eq(root_span.span_id) + expect(span.attributes["appsignal.category"]).to eq("request.net_http") + 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 - it "instruments a https request" do - stub_request(:any, "https://www.google.com/") + 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 - uri = URI.parse("https://www.google.com") - http = Net::HTTP.new(uri.host, uri.port) - http.use_ssl = true - http.get(uri.request_uri) + 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("GET https://www.google.com") + expect(span.kind).to eq(:client) + expect(span.parent_span_id).to eq(root_span.span_id) + expect(span.attributes["appsignal.category"]).to eq("request.net_http") + 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 - 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..e739fef12 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,205 @@ 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") 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") 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 +247,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 1fe937dfd..327358fb8 100644 --- a/spec/lib/appsignal/integrations/que_spec.rb +++ b/spec/lib/appsignal/integrations/que_spec.rb @@ -25,111 +25,205 @@ 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["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).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(span.attributes["appsignal.category"]).to eq("perform_job.que") + 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 + + 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" => 0, + "id" => 123, + "priority" => 100, + "queue" => "dfl", + "run_at" => fixed_time.to_s + ) + expect(transaction).to be_completed + end - expect { perform_que_job(instance) }.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 + 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 @@ -154,13 +248,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 @@ -174,17 +286,70 @@ 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 + + expect(root_span.attributes["appsignal.action_name"]) + .to eq("MyCustomJob#perform") + expect(last_transaction).to be_completed + 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" } + # 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 - transaction = last_transaction - expect(transaction).to have_action("MyCustomJob#perform") - expect(transaction).to be_completed + it "in collector mode", :collector_mode do + start_collector_agent + perform + + # The job runs as its own trace, linked back to the enqueuer. + expect(root_span.kind).to eq(:consumer) + 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. describe Appsignal::Integrations::QueClientPlugin do let(:job) do Class.new(::Que::Job) do @@ -201,10 +366,7 @@ def self.name captured[:values] = values if command == :insert_job [{}] end - - start_agent end - around { |example| keep_transactions { example.run } } # `data` is the last value Que passes to its `:insert_job` query on both Que # 1 and Que 2 (Que 2 inserts `kwargs` before it, shifting its index); the @@ -219,7 +381,8 @@ def enqueue(tags: ["user:42"]) end context "with an active transaction" do - it "records an enqueue event and leaves the job's tags untouched" do + it "in agent mode", :agent_mode do + start_agent transaction = http_request_transaction set_current_transaction(transaction) @@ -229,34 +392,94 @@ def enqueue(tags: ["user:42"]) 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") + # No wire context in agent mode; only the user's own tag persists. expect(enqueued_tags).to eq(["user:42"]) 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_spans.find { |s| s.name == "enqueue MyQueJob job" } + expect(producer.attributes["appsignal.category"]).to eq("enqueue.que") + expect(producer.kind).to eq(:producer) + expect(producer.parent_span_id).to eq(root_span.span_id) + + # 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 + + it "skips propagation rather than break the enqueue when tags are full", + :collector_mode 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 end context "without an active transaction" do - it "is a transparent pass-through" do - expect { enqueue }.to_not raise_error + it "in collector mode", :collector_mode do + start_collector_agent + + enqueue + # No transaction to attach to: nothing recorded, nothing injected. + expect(span_exporter.finished_spans.map(&:name)).to_not include("enqueue.que") expect(enqueued_tags).to eq(["user:42"]) 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") 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(span_exporter.finished_spans.map(&:name)).to_not include("enqueue.que") + # ...but the trace context is still injected so the job links back. + expect(enqueued_tags).to include(a_string_starting_with("traceparent:")) + 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 @@ -276,35 +499,90 @@ 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.select { |s| s.name == "bulk enqueue MyQueJob jobs" } + expect(producers.size).to eq(1) + producer = producers.first + expect(producer.attributes["appsignal.category"]).to eq("bulk_enqueue.que") + expect(producer.kind).to eq(:producer) + expect(producer.parent_span_id).to eq(root_span.span_id) + + # Every job in the batch carries the one producer span's context. + expect(enqueued_tags).to include("user:42") + expect(enqueued_tags) + .to include("traceparent:00-#{producer.hex_trace_id}-#{producer.hex_span_id}-01") + 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 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(span_exporter.finished_spans.map(&:name)).to_not include("bulk_enqueue.que") + # ...but the trace context is still injected so the jobs link back. + expect(enqueued_tags).to include(a_string_starting_with("traceparent:")) + end end end end diff --git a/spec/lib/appsignal/integrations/railtie_spec.rb b/spec/lib/appsignal/integrations/railtie_spec.rb index dbff4aeea..652d60acb 100644 --- a/spec/lib/appsignal/integrations/railtie_spec.rb +++ b/spec/lib/appsignal/integrations/railtie_spec.rb @@ -254,25 +254,62 @@ 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) + 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 +318,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 +411,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 +761,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..cf9c574f7 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,102 @@ 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["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).not_to be_nil + expect(span.parent_span_id).to eq(root_span.span_id) + 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 runs as its own trace, linked back to the span that enqueued it. + expect(root_span.kind).to eq(:consumer) + 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) + + # 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 +125,33 @@ 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) + 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 +163,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 +190,145 @@ 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_spans.find { |s| s.name == "enqueue ResqueTestJob job" } + expect(producer.attributes["appsignal.category"]).to eq("enqueue.resque") + expect(producer.kind).to eq(:producer) + 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(span_exporter.finished_spans.map(&:name)).to_not include("enqueue.resque") + 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(span_exporter.finished_spans.map(&:name)).to_not include("enqueue.resque") + # ...but the trace context is still injected so the job links back. + expect(item).to 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 +341,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 +352,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 +370,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..185aa865b 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,50 @@ 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 event names the queue. + producer = event_spans.find { |s| s.name == "enqueue on test-queue" } + expect(producer.attributes["appsignal.category"]).to eq("enqueue.shoryuken") + expect(producer.kind).to eq(:producer) + + # 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..2774a8906 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,98 @@ 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["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).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(span.attributes["appsignal.category"]).to eq("perform_job.shoryuken") + 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.attributes["appsignal.queue_start"]).to eq(sent_timestamp) + 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 - expect(last_transaction).to include_params("foo" => "[FILTERED]", "bar" => "Bar") + 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(JSON.parse(root_span.attributes["appsignal.function.parameters"])) + .to eq("foo" => "[FILTERED]", "bar" => "Bar") + end end end end @@ -74,38 +130,139 @@ 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 - expect(last_transaction).to include_params("params" => body) + 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 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 + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform - expect(last_transaction).to include_params("params" => body) + # 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 runs as its own trace, linked back to the span that enqueued it. + expect(root_span.kind).to eq(:consumer) + 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) + + # 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 +272,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 +287,80 @@ 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(span.attributes["appsignal.category"]).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") + # 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.attributes["appsignal.queue_start"]).to eq(sent_timestamp) + + # 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 +368,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 +388,118 @@ 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_spans.find { |s| s.name == "enqueue MyShoryukenWorker job" } + expect(producer.attributes["appsignal.category"]).to eq("enqueue.shoryuken") + expect(producer.kind).to eq(:producer) + 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_spans.find { |s| s.name == "enqueue on my-queue" } + expect(producer).to_not be_nil + expect(producer.attributes["appsignal.category"]).to eq("enqueue.shoryuken") + 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(span_exporter.finished_spans.map(&:name)).to_not include("enqueue.shoryuken") + 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(span_exporter.finished_spans.map(&:name)).to_not include("enqueue.shoryuken") + # ...but the trace context is still injected so the job links back. + expect(options[:message_attributes]).to have_key("traceparent") + end end end diff --git a/spec/lib/appsignal/integrations/sidekiq_spec.rb b/spec/lib/appsignal/integrations/sidekiq_spec.rb index 5dc119a21..bdb249db0 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,130 @@ 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["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 +241,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 @@ -177,49 +327,102 @@ 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 } } 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_spans.find { |s| s.name == "enqueue TestClass job" } + expect(producer.attributes["appsignal.category"]).to eq("enqueue.sidekiq") + expect(producer.kind).to eq(:producer) + 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(job["traceparent"]) + .to eq("00-#{producer.hex_trace_id}-#{producer.hex_span_id}-01") end 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(span_exporter.finished_spans.map(&:name)).to_not include("enqueue.sidekiq") + 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(span_exporter.finished_spans.map(&:name)).to_not include("enqueue.sidekiq") + # ...but the trace context is still injected so the job links back. + expect(job).to have_key("traceparent") + end end end @@ -271,9 +474,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) @@ -281,31 +482,124 @@ 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 - transaction_hash = transaction.to_h - expect(transaction_hash["metadata"].keys) - .to_not include(*Appsignal::Integrations::SidekiqMiddleware::EXCLUDED_JOB_KEYS) + 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 runs as its own trace, linked back to the span that enqueued it. + def expect_linked_back_to_remote + expect(root_span.kind).to eq(:consumer) + 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 + + 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_linked_back_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]]) } + + 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_linked_back_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 @@ -315,10 +609,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 @@ -338,22 +647,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") + end + + it "in collector mode", :collector_mode do + start_collector_agent + 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(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 @@ -374,22 +718,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 @@ -397,37 +776,72 @@ 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).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(span.attributes["appsignal.category"]).to eq("perform_job.sidekiq") + end end end @@ -435,55 +849,110 @@ 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.attributes["appsignal.queue_start"]) + .to eq(Time.parse("2001-01-01 10:00:00UTC").to_i * 1000) + 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(span.attributes["appsignal.category"]).to eq("perform_job.sidekiq") + end end end @@ -578,8 +1047,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 @@ -605,35 +1074,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]) @@ -645,6 +1098,83 @@ 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.attributes["appsignal.queue_start"]).to eq(time.to_i * 1000) + # 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.attributes["appsignal.queue_start"]).to eq(time.to_i * 1000) + 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 @@ -657,15 +1187,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..2f1990dd9 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,30 @@ 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") + end end context "with action already set" do @@ -69,52 +83,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 params" 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.payload"]) + 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 params" 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.payload"]) + 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 +241,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..eb6242d8b 100644 --- a/spec/lib/appsignal/loaders/padrino_spec.rb +++ b/spec/lib/appsignal/loaders/padrino_spec.rb @@ -66,7 +66,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 +113,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 +126,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 +171,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 +197,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 +222,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 +249,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 +272,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 +297,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 +320,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 +347,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 +370,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..ce19f573e --- /dev/null +++ b/spec/lib/appsignal/logger/opentelemetry_backend_spec.rb @@ -0,0 +1,153 @@ +# 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 + described_class.reset! + end + + after { described_class.reset! } + + 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 caching" do + it "fetches the OTel logger once and reuses it across emits" do + expect(::OpenTelemetry.logger_provider).to receive(:logger) + .with(:name => "appsignal-logger").once.and_call_original + + described_class.emit("g", ::Logger::INFO, Appsignal::Logger::PLAINTEXT, "a", {}) + described_class.emit("g", ::Logger::INFO, Appsignal::Logger::PLAINTEXT, "b", {}) + end + + it "rebuilds the logger after reset! to pick up a new provider" do + described_class.emit("g", ::Logger::INFO, Appsignal::Logger::PLAINTEXT, "a", {}) + described_class.reset! + + 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..a8aa30b91 --- /dev/null +++ b/spec/lib/appsignal/metrics/opentelemetry_backend_spec.rb @@ -0,0 +1,143 @@ +# 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 + 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_spec.rb b/spec/lib/appsignal/opentelemetry_spec.rb new file mode 100644 index 000000000..c35e212be --- /dev/null +++ b/spec/lib/appsignal/opentelemetry_spec.rb @@ -0,0 +1,340 @@ +# 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! } + + 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 when started" do + require "opentelemetry-common" + allow(described_class).to receive(:started?).and_return(true) + + expect(::OpenTelemetry.propagation).to receive(:extract) + .with(env, :getter => ::OpenTelemetry::Common::Propagation.rack_env_getter) + + described_class.extract_rack_context(env) + 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 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..e991d147e 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,147 @@ 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 - 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") + expect(root_span.kind).to eq(:server) + end end - it "wraps the response body in a BodyWrapper subclass" do + 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 + + 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_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 "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 +177,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 +203,306 @@ 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 - it "records the error" do - expect(last_transaction).to have_error("ExampleException", "error message") + 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 - 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 - 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 # 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 + + 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 - expect(last_transaction).to include_params( - "page" => "2", - "query" => "lorem" - ) + 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 - expect(last_transaction).to include_session_data("session" => "data", "user_id" => 123) + 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) + 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.attributes["appsignal.queue_start"]) + .to eq(queue_start_time.to_i) + end end end @@ -238,53 +535,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 - expect(last_transaction).to include_params("abc" => "123") + 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 + + 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 + + it "in collector mode", :collector_mode do + start_collector_agent + perform - expect(last_transaction).to include_session_data("data" => "value") + 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 +610,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| + span.attributes["appsignal.category"] == "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| + span.attributes["appsignal.category"] == "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") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + Appsignal::Transaction.complete_current! - expect(last_transaction).to have_action("My custom action") + 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 +729,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 +756,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 +789,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 +816,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..637c1d9dc 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 lives in appsignal.category; the span name carries the + # human-readable title (falling back to the event name when title-less). + span = event_spans.find { |s| s.attributes["appsignal.category"] == name } + expect(span).not_to be_nil + expect(span.parent_span_id).to eq(root_span.span_id) + expect(span.name).to eq(title) if title + end + + def expect_collector_no_event(name) + transaction.complete + expect(event_spans.map { |s| s.attributes["appsignal.category"] }).to_not include(name) + 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,421 @@ 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 + + it "in collector mode", :collector_mode do + start_collector_agent + + perform - expect(transaction).to_not include_event("name" => "process_response_body.rack") + 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 + + it "in agent mode", :agent_mode do + start_agent + + perform - expect(transaction).to have_error("ExampleException", "error message") + expect(transaction).to_not include_event("name" => "process_response_body.rack") + end + + it "in collector mode", :collector_mode do + start_collector_agent + + perform + transaction.complete + + # Mirrors the agent `to_not include_event` here: that matcher only + # excludes a *default-shaped* event (empty title). Iterating the + # returned Enumerator still instruments `each`, so the recorded event + # carries the "#each" title -- there is just never a title-less one. + # A title-less event would fall back to naming the span after its + # category (the event name); the "#each" one never does. + titleless_event = event_spans.find do |span| + span.attributes["appsignal.category"] == "process_response_body.rack" && + span.name == span.attributes["appsignal.category"] + end + expect(titleless_event).to be_nil + 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 + + perform - expect(transaction).to_not have_error + 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 + + 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 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 - 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 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 + + 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 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 + + 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 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 - 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 "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 + + it "in collector mode", :collector_mode do + start_collector_agent - expect(transaction).to include_event("name" => "close_response_body.rack") + 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_collector_event("close_response_body.rack") + end + end + + 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 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(transaction).to have_error("ExampleException", "error message") + expect_collector_error("ExampleException", "error message") + 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 "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::EPIPE) - 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 "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 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(Errno::ECONNRESET) - 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 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 "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 - 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 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") - 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 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 +510,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 +519,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") + 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 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 + + 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 - 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 - 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 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 - 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 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 + + it "in collector mode", :collector_mode do + start_collector_agent - expect(transaction).to_not have_error + 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 + + 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 "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 +813,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 +1096,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..bdb86f6b5 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,133 @@ 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) - 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 - expect(Appsignal::Transaction.current).to eq(transaction) + 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) + 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 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) + end - expect(Appsignal::Transaction.current).to be_kind_of(Appsignal::Transaction::NilTransaction) + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + use_test_logger + perform - 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" - ) + 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.attributes["appsignal.queue_start"]).to eq(queue_start_time.to_i) + event = event_spans.find do |span| + span.attributes["appsignal.category"] == "process_request.rack" + end + expect(event).not_to be_nil + expect(event.parent_span_id).to eq(root_span.span_id) + expect(event.name).to eq("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 +211,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 +230,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 +246,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 +261,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 - expect(last_transaction).to_not have_error + 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 + 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 +375,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 +454,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 +486,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 +515,334 @@ 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 + + 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) + 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_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_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 + + 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 +851,176 @@ 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_spans.find do |span| + span.attributes["appsignal.category"] == "process_request.rack" + end + expect(event).not_to be_nil + expect(event.parent_span_id).to eq(root_span.span_id) + expect(event.name).to eq("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 "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 "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 "in collector mode", :collector_mode do + start_collector_agent + use_test_logger + perform - on_start - on_error(ExampleStandardError.new("the error")) - on_finish + 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 +1034,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..007ef2b96 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,30 @@ 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(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 +155,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 +229,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 +251,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 +272,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..ea7e90598 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,96 @@ 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) + 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..c53166b27 100644 --- a/spec/lib/appsignal/rack/instrumentation_middleware_spec.rb +++ b/spec/lib/appsignal/rack/instrumentation_middleware_spec.rb @@ -3,36 +3,80 @@ 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 - expect(last_transaction).to include_event("name" => "process_request_middleware.rack") + 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) + 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 + + it "in agent mode", :agent_mode do + start_agent + perform - expect(last_transaction).to have_action("MyAction") + 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..55850bee7 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,206 @@ 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") - end - it "calls the next middleware in the stack" do - expect(app).to be_called + 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 "reports the error on the transaction" do - expect(last_transaction).to have_error("ExampleException", "error message") + 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 - it "sets the controller action as the action name" do - make_request + 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 "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete - expect(last_transaction).to have_namespace(Appsignal::Transaction::HTTP_REQUEST) - expect(last_transaction).to have_action("MockController#index") + 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 request metadata on the transaction" do - make_request + describe "sets request metadata on the transaction" do + def perform + setup_transaction + make_request + end - expect(last_transaction).to include_metadata( - "method" => "GET", - "path" => "/blog" - ) - expect(last_transaction).to include_tags("request_id" => "request_id123") + 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") + 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 + + 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]" - ) + 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..343ea40ce 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,48 @@ 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) + end end end @@ -128,29 +153,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 +236,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 +285,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 +335,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..9e517dee6 --- /dev/null +++ b/spec/lib/appsignal/transaction/extension_backend_spec.rb @@ -0,0 +1,226 @@ +# 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 "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 + + 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 "#records_errors_eagerly?" do + it "returns false (extra errors are reported as duplicate transactions)" do + expect(backend.records_errors_eagerly?).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..081744c87 --- /dev/null +++ b/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb @@ -0,0 +1,1014 @@ +# 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 = [] + + # 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") + described_class.new("abc-123", namespace).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 "#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 + + { + "http_request" => :server, + "background_job" => :consumer, + "action_cable" => :server, + "some_custom_ns" => :server + }.each do |namespace, expected_kind| + it "maps namespace #{namespace.inspect} to SpanKind #{expected_kind.inspect}" do + create_backend(namespace).complete + expect(span_exporter.finished_spans.first.kind).to eq(expected_kind) + 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(namespace, context) + described_class.new("abc-123", namespace, :opentelemetry_context => context) + .tap { |b| @backends_created << b } + end + + it "parents a server transaction under the remote span (continues the trace)" do + backend = create_backend_with_context("http_request", 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(root.kind).to eq(:server) + end + + it "starts a fresh root trace when no context is given" do + backend = create_backend("http_request") + 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) + end + + it "links a consumer transaction back to the remote span (starts a new trace)" do + backend = create_backend_with_context("background_job", remote_context) + backend.complete + root = finished_span(backend.instance_variable_get(:@span)) + + # A job is its own unit of work: new 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) + expect(root.kind).to eq(:consumer) + + # ... but linked back to the enqueuing span. + expect(root.links.size).to eq(1) + link_context = root.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 + + it "does not link a consumer transaction when there is no context" do + backend = create_backend("background_job") + backend.complete + root = finished_span(backend.instance_variable_get(:@span)) + + expect(root.kind).to eq(:consumer) + expect(root.links).to be_nil + 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("params", "anything") }.not_to raise_error + end + end + + describe "#start_event with opentelemetry_kind" do + def event_span_for(category) + span_exporter.finished_spans.find { |s| s.attributes["appsignal.category"] == category } + 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_for("request.net_http").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_for("sql.query").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.attributes["appsignal.queue_start"]).to eq(1_700_000_000_000) + end + + it "emits the queue duration metric in two series on completion" do + backend = create_backend("background_job") + 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 "ignores values below the epoch-ms floor" do + expect(metrics).to_not receive(:add_distribution_value) + backend = create_backend + 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 "#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 "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 + backend_span_id = backend.instance_variable_get(:@span).context.span_id + root = span_exporter.finished_spans.find { |s| s.span_id == backend_span_id } + root.events.find { |e| e.name == "exception" } + 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 "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 + + 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.attributes["appsignal.category"] == "sql.query" } + 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 "#records_errors_eagerly?" do + it "returns true (multiple exception events on one span)" do + expect(create_backend.records_errors_eagerly?).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 + 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 after the title, 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.attributes["appsignal.category"] == "custom.event" } + expect(event_span).not_to be_nil + # The human-readable title becomes the span name; the event name + # rides along in appsignal.category. + expect(event_span.name).to eq("Title") + expect(event_span.attributes["appsignal.category"]).to eq("custom.event") + 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.attributes["appsignal.category"] == "custom.event" } + expect(span).not_to be_nil + expect(span.name).to eq("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 + 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.attributes["appsignal.category"] == "sql.query" }.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 + + 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.attributes["appsignal.category"] == "custom" }.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.attributes["appsignal.category"] == "no.body" } + empty_body = span_exporter.finished_spans + .find { |s| s.attributes["appsignal.category"] == "empty.body" } + 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 + + 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.attributes["appsignal.category"] == "no.title" } + empty_title = span_exporter.finished_spans + .find { |s| s.attributes["appsignal.category"] == "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 + 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("appsignal.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 "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 d3ab930dd..31dc2efbc 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,11 @@ 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) { "some_backend" } - it "assigns the extension transaction to the transaction" do - expect(described_class.new("web", :ext => ext).ext).to be(ext) + it "assigns the backend to the transaction" do + expect(described_class.new("web", :backend => backend).backend).to be(backend) end end @@ -99,6 +110,100 @@ 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 "starts a root span with SpanKind::SERVER for HTTP_REQUEST", :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 SpanKind::CONSUMER for BACKGROUND_JOB", :collector_mode do + start_collector_agent + create_transaction(Appsignal::Transaction::BACKGROUND_JOB) + Appsignal::Transaction.complete_current! + + expect(span_exporter.finished_spans.first.kind).to eq(:consumer) + end + + it "uses SpanKind::SERVER for ACTION_CABLE", :collector_mode do + start_collector_agent + create_transaction(Appsignal::Transaction::ACTION_CABLE) + Appsignal::Transaction.complete_current! + + expect(span_exporter.finished_spans.first.kind).to eq(:server) + end + + it "uses SpanKind::SERVER for an unknown custom namespace", :collector_mode do + start_collector_agent + create_transaction("my_custom_namespace") + Appsignal::Transaction.complete_current! + + span = span_exporter.finished_spans.first + expect(span.kind).to eq(:server) + expect(span.name).to eq("appsignal.transaction my_custom_namespace") + 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.attributes["appsignal.category"] == "sql.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 + end end describe ".current" do @@ -183,6 +288,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 +319,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 +334,67 @@ "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 + def perform + transaction.discard! + transaction.restore! + transaction.complete + end + + it "in agent mode", :agent_mode do + start_agent + perform + + expect(transaction).to be_completed + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform - expect(transaction).to be_completed + # 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 +525,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 +698,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 +780,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 +789,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", @@ -723,520 +904,1247 @@ 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 - transaction._sample - expect(transaction).to include_params(params) + expect(JSON.parse(root_span.attributes["appsignal.request.payload"])) + .to eq("block" => "value") + end + end + + 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: 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: 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 + + describe "when the given value is nil" do + def perform + transaction.add_params("key" => "value") + transaction.add_params_if_nil(nil) + end - it "does not include filtered out session data" do - transaction.add_session_data("data" => "value1", "filtered_key" => "filtered_value") + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + transaction._sample - transaction._sample - expect(transaction).to include_session_data("data" => "value1") + 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 - end - describe "#add_session_data_if_nil" do - let(:transaction) { new_transaction } + 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 - 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) + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + transaction._sample - transaction._sample - expect(transaction).to include_session_data(data) - end + expect(transaction).to include_params("other" => "params") + 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 collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete - transaction._sample - expect(transaction).to include_session_data(data) + expect(JSON.parse(root_span.attributes["appsignal.request.payload"])) + .to eq("other" => "params") + end 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 } + 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 - transaction._sample - expect(transaction).to include_session_data(block_data) - 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_if_nil(nil) + expect(transaction).to include_params("other" => "params") + end - transaction._sample - expect(transaction).to include_session_data(data) + 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 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) + 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 - transaction._sample - expect(transaction).to include_session_data(preset_data) - end + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + transaction._sample - 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 } + expect(transaction).to_not include_params + end - transaction._sample - expect(transaction).to include_session_data(preset_data) + 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_headers" do + describe "#add_session_data" do let(:transaction) { new_transaction } - it "has a #set_headers alias" do - expect(transaction.method(:add_headers)).to eq(transaction.method(:set_headers)) + it "has a #set_session_data alias" do + expect(transaction.method(:add_session_data)).to eq(transaction.method(:set_session_data)) end - it "adds the headers to the transaction" do - headers = { "PATH_INFO" => "value" } - transaction.add_headers(headers) - - transaction._sample - expect(transaction).to include_environment(headers) - end + describe "adding the session data to the transaction" do + def perform + transaction.add_session_data("key" => "value") + end - 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" } } + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + transaction._sample - transaction._sample - expect(transaction).to include_environment( - "PATH_INFO" => "value", - "REQUEST_METHOD" => "value", - "HTTP_ACCEPT" => "value" - ) - end + expect(transaction).to include_session_data("key" => "value") + end - it "adds the headers to the transaction with a block" do - headers = { "PATH_INFO" => "value" } - transaction.add_headers { headers } + 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.request.session_data"])) + .to eq("key" => "value") + end 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 } - - transaction._sample - expect(transaction).to include_environment(block_data) - 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 "logs an error if an error occurred storing the headers" do - transaction.add_headers { 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 headers: RuntimeError: uh oh" - ) - end + expect(transaction).to include_session_data( + "abc" => "value", + "def" => "value", + "xyz" => "value" + ) + 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) + 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.request.session_data"])).to eq( + "abc" => "value", + "def" => "value", + "xyz" => "value" + ) + end end - context "with request_headers options" do - let(:options) { { :request_headers => ["MY_HEADER"] } } + 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 "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 + + 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_environment("MY_HEADER" => "value1") + + 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_headers_if_nil" do + describe "#add_session_data_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)) + 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 headers are not set" do - it "adds the headers to the transaction" do - headers = { "PATH_INFO" => "value" } - transaction.add_headers_if_nil(headers) + 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 - transaction._sample - expect(transaction).to include_environment(headers) + 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 - it "adds the headers to the transaction with a block" do - headers = { "PATH_INFO" => "value" } - transaction.add_headers_if_nil { headers } + 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(headers) + + expect(transaction).to include_environment( + "HTTP_ACCEPT" => "text/html", + "PATH_INFO" => "/path" + ) 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 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(block_data) + + expect(transaction).to include_environment( + "HTTP_ACCEPT" => "text/html", + "HTTP_RANGE" => "bytes=0-", + "HTTP_CACHE_CONTROL" => "no-cache" + ) 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 + + 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(headers) + + 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 "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) + 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(preset_headers) + + 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 "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 } + 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(preset_headers) + + 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_tags" do + describe "#add_headers_if_nil" 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 - ) + 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 - it "merges the tags when called multiple times" do - transaction.add_tags(:key1 => "value1") - transaction.add_tags(:key2 => "value2") - transaction._sample + 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_tags( - "key1" => "value1", - "key2" => "value2" - ) + 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 "with config default_tags" do - let(:options) do - { :default_tags => { "config_tag" => "config_value", "another_tag" => 123 } } + 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 - it "includes default_tags from config" do - transaction._sample + 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 - expect(transaction).to include_tags( - "config_tag" => "config_value", - "another_tag" => 123 + 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_tags" do + let(:transaction) { new_transaction } + let(:long_string) { "a" * 10_001 } + + 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 "transaction tags override default_tags" do - transaction.add_tags("config_tag" => "transaction_value") + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform transaction._sample + # The extension truncates over-long tag values to 10,000 chars + "...". expect(transaction).to include_tags( - "config_tag" => "transaction_value", - "another_tag" => 123 + "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 "merges default_tags with transaction tags" do - transaction.add_tags("transaction_tag" => "transaction_value") + 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_tags( - "config_tag" => "config_value", - "another_tag" => 123, - "transaction_tag" => "transaction_value" + "key1" => "value1", + "key2" => "value2" ) end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + expect(root_span.attributes["appsignal.tag.key1"]).to eq("value1") + expect(root_span.attributes["appsignal.tag.key2"]).to eq("value2") + end + end + + context "with config default_tags" do + let(:options) do + { :default_tags => { "config_tag" => "config_value", "another_tag" => 123 } } + end + + describe "including default_tags from config" do + def perform + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + transaction._sample + + expect(transaction).to include_tags( + "config_tag" => "config_value", + "another_tag" => 123 + ) + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + 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 + + describe "transaction tags override default_tags" do + def perform + transaction.add_tags("config_tag" => "transaction_value") + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + transaction._sample + + expect(transaction).to include_tags( + "config_tag" => "transaction_value", + "another_tag" => 123 + ) + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + 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 + + describe "merging default_tags with transaction tags" do + def perform + transaction.add_tags("transaction_tag" => "transaction_value") + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + transaction._sample + + expect(transaction).to include_tags( + "config_tag" => "config_value", + "another_tag" => 123, + "transaction_tag" => "transaction_value" + ) + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + 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 @@ -1247,91 +2155,166 @@ expect(transaction.method(:add_custom_data)).to eq(transaction.method(:set_custom_data)) end - it "adds a custom Hash data to the transaction" do - transaction.add_custom_data( - :user => { - :id => 123, - :locale => "abc" - }, - :organization => { - :slug => "appsignal", - :plan => "enterprise" - } - ) + 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 - transaction._sample - expect(transaction).to include_custom_data( - "user" => { - "id" => 123, - "locale" => "abc" - }, - "organization" => { - "slug" => "appsignal", - "plan" => "enterprise" + let(:expected) do + { + "user" => { + "id" => 123, + "locale" => "abc" + }, + "organization" => { + "slug" => "appsignal", + "plan" => "enterprise" + } } - ) + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + transaction._sample + + expect(transaction).to include_custom_data(expected) + 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(expected) + end end - it "adds a custom Array data to the transaction" do - transaction.add_custom_data([ - [123, "abc"], - ["appsignal", "enterprise"] - ]) + describe "adding a custom Array data to the transaction" do + def perform + transaction.add_custom_data([ + [123, "abc"], + ["appsignal", "enterprise"] + ]) + end - transaction._sample - expect(transaction).to include_custom_data([ - [123, "abc"], - ["appsignal", "enterprise"] - ]) + 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_custom_data(expected) + 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(expected) + 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 + 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 - transaction.add_custom_data(123) - transaction._sample - expect(transaction).to_not include_custom_data + def expect_unsupported_type_logs(logs) + 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") - transaction.add_custom_data("def" => "value") + 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._sample - expect(transaction).to include_custom_data( - "abc" => "value", - "def" => "value" - ) + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + 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_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", @@ -1341,10 +2324,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", @@ -1361,13 +2347,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.attributes["appsignal.category"] == "sql.active_record" + 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( @@ -1378,15 +2409,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( @@ -1394,30 +2442,71 @@ "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) - 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 @@ -1426,262 +2515,580 @@ 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") + 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) + 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(1_000_000_000_000) + end + + it "in collector mode", :collector_mode do + start_collector_agent + # 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 - expect(transaction).to have_queue_start(10) + event = root_span.events.find { |e| e.name == "appsignal.queue_start" } + expect(event).not_to be_nil + expect(event.attributes["appsignal.queue_start"]).to eq(queue_start) + + # 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) + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete - expect(transaction).to_not include_metadata("filter_key" => anything) + 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 + def expect_unsupported_type_logs(logs) + 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: #|\])/ + ) + 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) + 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) + 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 + + expect(JSON.parse(root_span.attributes["appsignal.request.payload"])).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 @@ -1746,47 +3153,200 @@ def to_s e end - # Report the wrapper on another transaction first - transaction1 = new_transaction - transaction1.add_error(error) - expect(transaction1).to have_error + # Report the wrapper on another transaction first + transaction1 = new_transaction + transaction1.add_error(error) + expect(transaction1).to have_error + + transaction2 = new_transaction + transaction2.add_error(error.cause) + expect(transaction).to_not have_error + end + end + + context "when a block is given" do + it "stores the block in the error blocks" do + block = proc { "block" } + + transaction.add_error(error, &block) + + expect(transaction.error_blocks).to eq({ + error => [block] + }) + 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 - transaction2 = new_transaction - transaction2.add_error(error.cause) - expect(transaction).to_not have_error + 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 - context "when a block is given" do - it "stores the block in the error blocks" do - block = proc { "block" } + 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 - transaction.add_error(error, &block) + 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 - expect(transaction.error_blocks).to eq({ - error => [block] - }) + 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 - context "when no error is set in the transaction" do - it "sets the error on the transaction" do + 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 - expect(transaction).to have_error( - "ExampleStandardError", - "test message", - ["line 1"] + 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 - it "does store the error in the errors" do - transaction.add_error(error) + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete - expect(transaction.error_blocks).to eq({ error => [] }) + # 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.type"] }) + .to contain_exactly("ExampleStandardError", "ExampleStandardError") + expect(events.map { |e| e.attributes["exception.message"] }) + .to contain_exactly("test message", "other message") end end + # 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 + + event_span = event_spans.find { |span| span.attributes["appsignal.category"] == "query" } + expect(event_span.events.map(&:name)).to include("exception") + expect(Array(root_span.events).map(&:name)).not_to include("exception") + end + + # 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(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 let(:other_error) do ExampleStandardError.new("other test message").tap do |e| @@ -1906,17 +3466,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 @@ -1927,26 +3501,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", @@ -1954,6 +3547,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? @@ -1972,18 +3574,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 @@ -1995,6 +3619,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 @@ -2018,11 +3647,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 @@ -2060,7 +3698,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") @@ -2111,6 +3750,45 @@ def to_s ) end + # 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" do transaction.send(:_set_error, error) transaction.send(:_set_error, error_without_cause) @@ -2282,7 +3960,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| { @@ -2308,6 +3987,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 @@ -2322,7 +4028,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( @@ -2331,6 +4038,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 @@ -2471,7 +4188,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 @@ -2496,6 +4214,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 @@ -2545,15 +4283,23 @@ 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).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).and_call_original + + transaction.start_event(:opentelemetry_kind => :client) + 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 @@ -2562,15 +4308,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( @@ -2582,12 +4326,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( @@ -2601,7 +4344,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 @@ -2610,16 +4353,15 @@ 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 ).and_call_original transaction.record_event( @@ -2632,13 +4374,13 @@ def to_s 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 ).and_call_original transaction.record_event( @@ -2653,7 +4395,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", @@ -2664,6 +4406,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("T") + expect(span.attributes["appsignal.category"]).to eq("custom.event") + 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 @@ -2671,6 +4449,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("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", + "appsignal.category" => "sql.active_record" + ) + 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("Title") + expect(span.attributes).to include( + "appsignal.body" => "Body", + "appsignal.category" => "custom.event" + ) + 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.attributes["appsignal.category"] == "outer.event" } + inner = event_spans.find { |s| s.attributes["appsignal.category"] == "inner.event" } + + 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["appsignal.category"]).to eq("custom.event") + 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 @@ -2695,7 +4652,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..b7b3924d7 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,241 @@ 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 + 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 "setting a custom namespace via the namespace argument" do + def perform + Appsignal.monitor(:namespace => "custom", :action => nil) + end - expect(last_transaction).to have_namespace("custom") - end + it "in agent mode", :agent_mode do + start_agent + perform - it "doesn't overwrite custom namespace set in the block" do - Appsignal.monitor(:namespace => "custom", :action => nil) do - Appsignal.set_namespace("more custom") + 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(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(last_transaction).to have_action("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 +1390,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 @@ -1219,17 +1477,34 @@ def on_start 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,23 +1529,44 @@ 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 "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 + + context "with transaction" do + let(:transaction) { http_request_transaction } + before { set_current_transaction(transaction) } + it "merges the params if called multiple times" do Appsignal.add_params("param1" => "value1") Appsignal.add_params("param2" => "value2") @@ -1317,23 +1613,44 @@ def on_start end describe ".add_session_data" do - before { start_agent } + before do |example| + start_agent unless example.metadata[:agent_mode] || example.metadata[:collector_mode] + end it "has a .set_session_data alias" do expect(Appsignal.method(:add_session_data)).to eq(Appsignal.method(:set_session_data)) end - context "with transaction" do + describe "adding session data through the public API" do let(:transaction) { http_request_transaction } - before { set_current_transaction(transaction) } - it "adds session data to the transaction" do + 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_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 } + before { set_current_transaction(transaction) } + it "merges the session data if called multiple times" do Appsignal.set_session_data("data1" => "value1") Appsignal.set_session_data("data2" => "value2") @@ -1363,22 +1680,43 @@ def on_start end describe ".add_headers" do - before { start_agent } + before do |example| + start_agent unless example.metadata[:agent_mode] || example.metadata[:collector_mode] + end it "has a .set_headers alias" do expect(Appsignal.method(:add_headers)).to eq(Appsignal.method(:set_headers)) end - context "with transaction" do + describe "adding request headers 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") + 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("PATH_INFO" => "/some-path") + expect(transaction).to include_environment("HTTP_ACCEPT" => "text/html") + end + + 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 + + context "with transaction" do + let(:transaction) { http_request_transaction } + before { set_current_transaction(transaction) } it "merges the request headers if called multiple times" do Appsignal.add_headers("PATH_INFO" => "/some-path") @@ -1409,21 +1747,28 @@ def on_start end describe ".add_custom_data" do - before { start_agent } + 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 - context "with transaction" do + describe "adding custom data through the public API" do let(:transaction) { http_request_transaction } - before { set_current_transaction transaction } - it "adds custom data to the current transaction" do + def perform + set_current_transaction(transaction) Appsignal.add_custom_data( :user => { :id => 123 }, :organization => { :slug => "appsignal" } ) + end + + it "in agent mode", :agent_mode do + start_agent + perform transaction._sample expect(transaction).to include_custom_data( @@ -1432,6 +1777,22 @@ def on_start ) 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( + "user" => { "id" => 123 }, + "organization" => { "slug" => "appsignal" } + ) + end + end + + context "with transaction" do + let(:transaction) { http_request_transaction } + before { set_current_transaction transaction } + it "merges the custom data if called multiple times" do Appsignal.add_custom_data(:abc => "value") Appsignal.add_custom_data(:def => "value") @@ -1457,13 +1818,15 @@ def on_start end describe ".add_breadcrumb" do - before { start_agent } + before do |example| + start_agent unless example.metadata[:agent_mode] || example.metadata[:collector_mode] + end - context "with transaction" do + describe "adding a breadcrumb through the public API" do let(:transaction) { http_request_transaction } - before { set_current_transaction(transaction) } - it "adds the breadcrumb to the transaction" do + def perform + set_current_transaction(transaction) Appsignal.add_breadcrumb( "Network", "http", @@ -1471,8 +1834,13 @@ def on_start { :response => 200 }, fixed_time ) + end - transaction._sample + it "in agent mode", :agent_mode do + start_agent + perform + + transaction.complete expect(transaction).to include_breadcrumb( "http", "Network", @@ -1481,6 +1849,20 @@ def on_start fixed_time ) end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + # 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 @@ -1492,158 +1874,87 @@ def on_start end end - describe "custom metrics" do - let(:tags) { { :foo => "bar" } } + describe ".internal_logger" do + subject { Appsignal.internal_logger } - 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) - end + it { is_expected.to be_a Logger } + 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) + describe ".log_formatter" do + subject { Appsignal.log_formatter.call("Debug", Time.parse("2015-07-08"), nil, "log line") } + + it "formats a log" do + expect(subject).to eq "[2015-07-08T00:00:00 (process) ##{Process.pid}][Debug] log line\n" + end + + context "with prefix" do + subject do + Appsignal.log_formatter("prefix").call("Debug", Time.parse("2015-07-08"), nil, "log line") 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 "adds a prefix" do + expect(subject) + .to eq "[2015-07-08T00:00:00 (process) ##{Process.pid}][Debug] prefix: log line\n" end + 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") + describe ".config" do + subject { Appsignal.config } - Appsignal.set_gauge("key", 10) - end + it { is_expected.to be_a Appsignal::Config } + it "should return configuration" do + expect(subject[:endpoint]).to eq "https://push.appsignal.com" 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") - end + describe ".send_error" do + let(:error) { ExampleException.new("error message") } + let(:err_stream) { std_stream } + let(:stderr) { err_stream.read } + around do |example| + keep_transactions { example.run } + 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 "sending the error" do + def perform + Appsignal.send_error(error) 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) - end + it "in agent mode", :agent_mode do + start_agent + expect { perform }.to(change { created_transactions.count }.by(1)) - 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) - 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") - - Appsignal.increment_counter("key", 10) - 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) - 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) - 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) - 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") - - Appsignal.add_distribution_value("key", 10) + 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 - end - end - - describe ".internal_logger" do - subject { Appsignal.internal_logger } - - it { is_expected.to be_a Logger } - end - - describe ".log_formatter" do - subject { Appsignal.log_formatter.call("Debug", Time.parse("2015-07-08"), nil, "log line") } - it "formats a log" do - expect(subject).to eq "[2015-07-08T00:00:00 (process) ##{Process.pid}][Debug] log line\n" - 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 - context "with prefix" do - subject do - Appsignal.log_formatter("prefix").call("Debug", Time.parse("2015-07-08"), nil, "log line") - end + 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") - it "adds a prefix" do - expect(subject) - .to eq "[2015-07-08T00:00:00 (process) ##{Process.pid}][Debug] prefix: log line\n" + 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 ".config" do - subject { Appsignal.config } - - it { is_expected.to be_a Appsignal::Config } - it "should return configuration" do - expect(subject[:endpoint]).to eq "https://push.appsignal.com" - end - end - - describe ".send_error" do - let(:error) { ExampleException.new("error message") } - let(:err_stream) { std_stream } - let(:stderr) { err_stream.read } - around do |example| - keep_transactions { example.run } - end - - it "sends the error to AppSignal" do - expect { Appsignal.send_error(error) }.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 context "when given error is not an Exception" do let(:error) { "string value" } @@ -1663,15 +1974,38 @@ 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 - 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 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 + + 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 @@ -1719,11 +2053,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 +2072,24 @@ 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 + before { set_current_transaction(transaction) } + context "when the error is not an Exception" do let(:error) { Object.new } @@ -1779,7 +2138,6 @@ 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 } } context "when the error is not an Exception" do @@ -1802,16 +2160,34 @@ 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 @@ -1849,15 +2225,82 @@ def on_start context "when there is an active transaction" do let(:transaction) { http_request_transaction } - before { set_current_transaction(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 - it "sets the error in the active transaction" do - Appsignal.report_error(error) + describe "reporting the error onto it" do + def perform + set_current_transaction(transaction) + Appsignal.report_error(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 + + 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") + 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 + + describe "with multiple reported errors" do + let(:other_error) do + ExampleStandardError.new("other message").tap { |e| e.set_backtrace(["line 2"]) } + end + + def perform + set_current_transaction(transaction) + Appsignal.report_error(error) + Appsignal.report_error(other_error) + end + + 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 @@ -2025,61 +2468,368 @@ 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 - expect(result).to eq "return value" + 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 + + 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("title") + expect(span.parent_span_id).to eq(root_span.span_id) + expect(span.attributes["appsignal.category"]).to eq("name") + 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") + 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 "does not record events on the transaction" do - expect(transaction).to receive(:pause!).and_call_original - expect(transaction).to receive(:resume!).and_call_original + it "in collector mode", :collector_mode do + start_collector_agent + set_current_transaction(transaction) + perform + Appsignal::Transaction.complete_current! - Appsignal.instrument("register.this.event") { :do_nothing } - Appsignal.ignore_instrumentation_events do - Appsignal.instrument("dont.register.this.event") { :do_nothing } - end + expect(event_spans.size).to eq(1) + span = event_spans.first + expect(span.name).to eq("title") + expect(span.attributes["appsignal.category"]).to eq("name") + expect(span.attributes["appsignal.body"]).to eq("body") + end + end - expect(transaction).to include_event("name" => "register.this.event") - expect(transaction).to_not include_event("name" => "dont.register.this.event") + 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("title") + expect(span.attributes["appsignal.category"]).to eq("name") + expect(span.attributes["appsignal.body"]).to eq("body") + 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("title") + expect(span.parent_span_id).to eq(root_span.span_id) + expect(span.attributes["appsignal.category"]).to eq("name") + 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 + 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 52ddc968e..a30fb3ee4 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..ec02c1fba 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 @@ -163,7 +163,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 +182,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 +191,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..a5a796e7c --- /dev/null +++ b/spec/support/shared_contexts/collector_mode.rb @@ -0,0 +1,136 @@ +# 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/logger backends so their cached + # meter/logger re-resolve against these providers on the next emit. + ::OpenTelemetry.tracer_provider = tracer_provider + ::OpenTelemetry.meter_provider = meter_provider + ::OpenTelemetry.logger_provider = logger_provider + Appsignal::Metrics::OpenTelemetryBackend.reset! + Appsignal::Logger::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 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)