From 84d83b657d65a93859ba73f295216a3b0ee9cab1 Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Wed, 8 Jul 2026 17:21:47 +0200 Subject: [PATCH 01/69] Add OpenTelemetry collector mode Setting `collector_endpoint`, or `APPSIGNAL_COLLECTOR_ENDPOINT`, puts the integration in collector mode. `Appsignal.start` then boots an OpenTelemetry SDK that exports OTLP over HTTP to that endpoint, alongside the usual C extension agent. The OpenTelemetry gems are not gemspec dependencies, because collector mode is optional. The boot checks for them and falls back to the agent when they are absent or too old. --- .../add-collector-attribute-options.md | 10 + .changesets/add-collector-mode.md | 19 + .github/workflows/ci.yml | 6877 ++++++++++++++--- Rakefile | 70 + appsignal.gemspec | 7 + build_matrix.yml | 12 + gemfiles/capistrano2-collector.gemfile | 6 + gemfiles/capistrano3-collector.gemfile | 6 + gemfiles/code_ownership-collector.gemfile | 6 + gemfiles/collector.rb | 11 + gemfiles/dry-monitor-collector.gemfile | 6 + gemfiles/excon-collector.gemfile | 6 + gemfiles/faraday-1-collector.gemfile | 6 + gemfiles/faraday-2-collector.gemfile | 6 + gemfiles/grape-collector.gemfile | 6 + gemfiles/hanami-2.0-collector.gemfile | 6 + gemfiles/hanami-2.1-collector.gemfile | 6 + gemfiles/hanami-2.2-collector.gemfile | 6 + gemfiles/http5-collector.gemfile | 6 + gemfiles/http6-collector.gemfile | 6 + gemfiles/mongo-collector.gemfile | 6 + gemfiles/no_dependencies-collector.gemfile | 6 + gemfiles/ownership-collector.gemfile | 6 + gemfiles/padrino-collector.gemfile | 6 + gemfiles/psych-3-collector.gemfile | 6 + gemfiles/psych-4-collector.gemfile | 6 + gemfiles/que-0.14-collector.gemfile | 6 + gemfiles/que-1-collector.gemfile | 6 + gemfiles/que-2-collector.gemfile | 6 + gemfiles/rails-6.0-collector.gemfile | 6 + gemfiles/rails-6.1-collector.gemfile | 6 + gemfiles/rails-7.0-collector.gemfile | 6 + gemfiles/rails-7.1-collector.gemfile | 6 + gemfiles/rails-7.2-collector.gemfile | 6 + gemfiles/rails-8.0-collector.gemfile | 6 + gemfiles/rails-8.1-collector.gemfile | 6 + gemfiles/redis-4-collector.gemfile | 6 + gemfiles/redis-5-collector.gemfile | 6 + gemfiles/resque-2-collector.gemfile | 6 + gemfiles/resque-3-collector.gemfile | 6 + gemfiles/sequel-collector.gemfile | 6 + gemfiles/shoryuken-6-collector.gemfile | 6 + gemfiles/shoryuken-7-collector.gemfile | 6 + gemfiles/sidekiq-7-collector.gemfile | 6 + gemfiles/sidekiq-8-collector.gemfile | 6 + gemfiles/sinatra-collector.gemfile | 6 + gemfiles/webmachine2-collector.gemfile | 6 + lib/appsignal.rb | 9 + lib/appsignal/backends.rb | 23 + lib/appsignal/config.rb | 141 +- lib/appsignal/opentelemetry.rb | 281 + lib/appsignal/opentelemetry/attributes.rb | 31 + lib/appsignal/opentelemetry/dependencies.rb | 34 + .../utils/stdout_and_logger_message.rb | 9 + sig/appsignal.rbi | 32 + sig/appsignal.rbs | 30 + spec/integration/collector_mode_spec.rb | 105 + spec/integration/diagnose | 2 +- spec/integration/runner.rb | 45 +- .../runners/collector_mode_emit.rb | 42 + spec/integration/runners/stop_with_trap.rb | 17 +- spec/integration/stop_spec.rb | 3 - spec/lib/appsignal/config_spec.rb | 221 + spec/lib/appsignal/opentelemetry_spec.rb | 340 + spec/lib/appsignal_spec.rb | 49 +- spec/spec_helper.rb | 27 +- spec/support/helpers/dependency_helper.rb | 18 + spec/support/helpers/mode_helpers.rb | 25 + spec/support/helpers/otlp_collector_server.rb | 148 + spec/support/shared_contexts/agent_mode.rb | 18 + 70 files changed, 7821 insertions(+), 1075 deletions(-) create mode 100644 .changesets/add-collector-attribute-options.md create mode 100644 .changesets/add-collector-mode.md create mode 100644 gemfiles/capistrano2-collector.gemfile create mode 100644 gemfiles/capistrano3-collector.gemfile create mode 100644 gemfiles/code_ownership-collector.gemfile create mode 100644 gemfiles/collector.rb create mode 100644 gemfiles/dry-monitor-collector.gemfile create mode 100644 gemfiles/excon-collector.gemfile create mode 100644 gemfiles/faraday-1-collector.gemfile create mode 100644 gemfiles/faraday-2-collector.gemfile create mode 100644 gemfiles/grape-collector.gemfile create mode 100644 gemfiles/hanami-2.0-collector.gemfile create mode 100644 gemfiles/hanami-2.1-collector.gemfile create mode 100644 gemfiles/hanami-2.2-collector.gemfile create mode 100644 gemfiles/http5-collector.gemfile create mode 100644 gemfiles/http6-collector.gemfile create mode 100644 gemfiles/mongo-collector.gemfile create mode 100644 gemfiles/no_dependencies-collector.gemfile create mode 100644 gemfiles/ownership-collector.gemfile create mode 100644 gemfiles/padrino-collector.gemfile create mode 100644 gemfiles/psych-3-collector.gemfile create mode 100644 gemfiles/psych-4-collector.gemfile create mode 100644 gemfiles/que-0.14-collector.gemfile create mode 100644 gemfiles/que-1-collector.gemfile create mode 100644 gemfiles/que-2-collector.gemfile create mode 100644 gemfiles/rails-6.0-collector.gemfile create mode 100644 gemfiles/rails-6.1-collector.gemfile create mode 100644 gemfiles/rails-7.0-collector.gemfile create mode 100644 gemfiles/rails-7.1-collector.gemfile create mode 100644 gemfiles/rails-7.2-collector.gemfile create mode 100644 gemfiles/rails-8.0-collector.gemfile create mode 100644 gemfiles/rails-8.1-collector.gemfile create mode 100644 gemfiles/redis-4-collector.gemfile create mode 100644 gemfiles/redis-5-collector.gemfile create mode 100644 gemfiles/resque-2-collector.gemfile create mode 100644 gemfiles/resque-3-collector.gemfile create mode 100644 gemfiles/sequel-collector.gemfile create mode 100644 gemfiles/shoryuken-6-collector.gemfile create mode 100644 gemfiles/shoryuken-7-collector.gemfile create mode 100644 gemfiles/sidekiq-7-collector.gemfile create mode 100644 gemfiles/sidekiq-8-collector.gemfile create mode 100644 gemfiles/sinatra-collector.gemfile create mode 100644 gemfiles/webmachine2-collector.gemfile create mode 100644 lib/appsignal/backends.rb create mode 100644 lib/appsignal/opentelemetry.rb create mode 100644 lib/appsignal/opentelemetry/attributes.rb create mode 100644 lib/appsignal/opentelemetry/dependencies.rb create mode 100644 spec/integration/collector_mode_spec.rb create mode 100644 spec/integration/runners/collector_mode_emit.rb create mode 100644 spec/lib/appsignal/opentelemetry_spec.rb create mode 100644 spec/support/helpers/mode_helpers.rb create mode 100644 spec/support/helpers/otlp_collector_server.rb create mode 100644 spec/support/shared_contexts/agent_mode.rb 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..96148e2ab --- /dev/null +++ b/.changesets/add-collector-mode.md @@ -0,0 +1,19 @@ +--- +bump: major +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/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8ab31fe3c..75228b588 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,7 @@ # This is a generated file by the `rake build_matrix:github:generate` task. # See `build_matrix.yml` for the build matrix. # Generate this file with `rake build_matrix:github:generate`. -# Generated job count: 255 +# Generated job count: 432 --- name: Ruby gem CI 'on': @@ -124,7 +124,8 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &1 + name: Run tests run: "./script/bundler_wrapper exec rake test" - name: Run tests without extension run: "./script/bundler_wrapper exec rake test:failure" @@ -133,8 +134,8 @@ jobs: JRUBY_OPTS: '' COV: '1' BUNDLE_GEMFILE: gemfiles/no_dependencies.gemfile - ruby_4-0-0__capistrano2_ubuntu-latest: - name: Ruby 4.0.0 - capistrano2 + ruby_4-0-0__no_dependencies-collector_ubuntu-latest: + name: Ruby 4.0.0 - no_dependencies-collector needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: @@ -153,15 +154,14 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *1 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/capistrano2.gemfile - ruby_4-0-0__capistrano3_ubuntu-latest: - name: Ruby 4.0.0 - capistrano3 + BUNDLE_GEMFILE: gemfiles/no_dependencies-collector.gemfile + ruby_4-0-0__capistrano2_ubuntu-latest: + name: Ruby 4.0.0 - capistrano2 needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: @@ -180,15 +180,16 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &2 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/capistrano3.gemfile - ruby_4-0-0__code_ownership_ubuntu-latest: - name: Ruby 4.0.0 - code_ownership + BUNDLE_GEMFILE: gemfiles/capistrano2.gemfile + ruby_4-0-0__capistrano2-collector_ubuntu-latest: + name: Ruby 4.0.0 - capistrano2-collector needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: @@ -207,15 +208,14 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *2 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/code_ownership.gemfile - ruby_4-0-0__delayed_job_ubuntu-latest: - name: Ruby 4.0.0 - delayed_job + BUNDLE_GEMFILE: gemfiles/capistrano2-collector.gemfile + ruby_4-0-0__capistrano3_ubuntu-latest: + name: Ruby 4.0.0 - capistrano3 needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: @@ -234,15 +234,16 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &3 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/delayed_job.gemfile - ruby_4-0-0__dry-monitor_ubuntu-latest: - name: Ruby 4.0.0 - dry-monitor + BUNDLE_GEMFILE: gemfiles/capistrano3.gemfile + ruby_4-0-0__capistrano3-collector_ubuntu-latest: + name: Ruby 4.0.0 - capistrano3-collector needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: @@ -261,15 +262,14 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *3 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/dry-monitor.gemfile - ruby_4-0-0__excon_ubuntu-latest: - name: Ruby 4.0.0 - excon + BUNDLE_GEMFILE: gemfiles/capistrano3-collector.gemfile + ruby_4-0-0__code_ownership_ubuntu-latest: + name: Ruby 4.0.0 - code_ownership needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: @@ -288,15 +288,16 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &4 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/excon.gemfile - ruby_4-0-0__faraday-1_ubuntu-latest: - name: Ruby 4.0.0 - faraday-1 + BUNDLE_GEMFILE: gemfiles/code_ownership.gemfile + ruby_4-0-0__code_ownership-collector_ubuntu-latest: + name: Ruby 4.0.0 - code_ownership-collector needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: @@ -315,15 +316,14 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *4 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/faraday-1.gemfile - ruby_4-0-0__faraday-2_ubuntu-latest: - name: Ruby 4.0.0 - faraday-2 + BUNDLE_GEMFILE: gemfiles/code_ownership-collector.gemfile + ruby_4-0-0__delayed_job_ubuntu-latest: + name: Ruby 4.0.0 - delayed_job needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: @@ -342,15 +342,16 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &5 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/faraday-2.gemfile - ruby_4-0-0__grape_ubuntu-latest: - name: Ruby 4.0.0 - grape + BUNDLE_GEMFILE: gemfiles/delayed_job.gemfile + ruby_4-0-0__delayed_job-collector_ubuntu-latest: + name: Ruby 4.0.0 - delayed_job-collector needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: @@ -369,15 +370,14 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *5 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/grape.gemfile - ruby_4-0-0__http5_ubuntu-latest: - name: Ruby 4.0.0 - http5 + BUNDLE_GEMFILE: gemfiles/delayed_job-collector.gemfile + ruby_4-0-0__dry-monitor_ubuntu-latest: + name: Ruby 4.0.0 - dry-monitor needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: @@ -396,15 +396,16 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &6 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/http5.gemfile - ruby_4-0-0__http6_ubuntu-latest: - name: Ruby 4.0.0 - http6 + BUNDLE_GEMFILE: gemfiles/dry-monitor.gemfile + ruby_4-0-0__dry-monitor-collector_ubuntu-latest: + name: Ruby 4.0.0 - dry-monitor-collector needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: @@ -423,15 +424,14 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *6 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/http6.gemfile - ruby_4-0-0__mongo_ubuntu-latest: - name: Ruby 4.0.0 - mongo + BUNDLE_GEMFILE: gemfiles/dry-monitor-collector.gemfile + ruby_4-0-0__excon_ubuntu-latest: + name: Ruby 4.0.0 - excon needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: @@ -450,15 +450,16 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &7 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/mongo.gemfile - ruby_4-0-0__ownership_ubuntu-latest: - name: Ruby 4.0.0 - ownership + BUNDLE_GEMFILE: gemfiles/excon.gemfile + ruby_4-0-0__excon-collector_ubuntu-latest: + name: Ruby 4.0.0 - excon-collector needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: @@ -477,15 +478,14 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *7 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/ownership.gemfile - ruby_4-0-0__psych-3_ubuntu-latest: - name: Ruby 4.0.0 - psych-3 + BUNDLE_GEMFILE: gemfiles/excon-collector.gemfile + ruby_4-0-0__faraday-1_ubuntu-latest: + name: Ruby 4.0.0 - faraday-1 needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: @@ -504,15 +504,16 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &8 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/psych-3.gemfile - ruby_4-0-0__psych-4_ubuntu-latest: - name: Ruby 4.0.0 - psych-4 + BUNDLE_GEMFILE: gemfiles/faraday-1.gemfile + ruby_4-0-0__faraday-1-collector_ubuntu-latest: + name: Ruby 4.0.0 - faraday-1-collector needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: @@ -531,15 +532,14 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *8 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/psych-4.gemfile - ruby_4-0-0__que-0-14_ubuntu-latest: - name: Ruby 4.0.0 - que-0.14 + BUNDLE_GEMFILE: gemfiles/faraday-1-collector.gemfile + ruby_4-0-0__faraday-2_ubuntu-latest: + name: Ruby 4.0.0 - faraday-2 needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: @@ -558,15 +558,16 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &9 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/que-0.14.gemfile - ruby_4-0-0__que-1_ubuntu-latest: - name: Ruby 4.0.0 - que-1 + BUNDLE_GEMFILE: gemfiles/faraday-2.gemfile + ruby_4-0-0__faraday-2-collector_ubuntu-latest: + name: Ruby 4.0.0 - faraday-2-collector needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: @@ -585,15 +586,14 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *9 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/que-1.gemfile - ruby_4-0-0__que-2_ubuntu-latest: - name: Ruby 4.0.0 - que-2 + BUNDLE_GEMFILE: gemfiles/faraday-2-collector.gemfile + ruby_4-0-0__grape_ubuntu-latest: + name: Ruby 4.0.0 - grape needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: @@ -612,15 +612,16 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &10 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/que-2.gemfile - ruby_4-0-0__rails-7-0_ubuntu-latest: - name: Ruby 4.0.0 - rails-7.0 + BUNDLE_GEMFILE: gemfiles/grape.gemfile + ruby_4-0-0__grape-collector_ubuntu-latest: + name: Ruby 4.0.0 - grape-collector needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: @@ -639,15 +640,14 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *10 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/rails-7.0.gemfile - ruby_4-0-0__rails-7-1_ubuntu-latest: - name: Ruby 4.0.0 - rails-7.1 + BUNDLE_GEMFILE: gemfiles/grape-collector.gemfile + ruby_4-0-0__http5_ubuntu-latest: + name: Ruby 4.0.0 - http5 needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: @@ -666,15 +666,16 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &11 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/rails-7.1.gemfile - ruby_4-0-0__rails-7-2_ubuntu-latest: - name: Ruby 4.0.0 - rails-7.2 + BUNDLE_GEMFILE: gemfiles/http5.gemfile + ruby_4-0-0__http5-collector_ubuntu-latest: + name: Ruby 4.0.0 - http5-collector needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: @@ -693,15 +694,14 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *11 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/rails-7.2.gemfile - ruby_4-0-0__rails-8-0_ubuntu-latest: - name: Ruby 4.0.0 - rails-8.0 + BUNDLE_GEMFILE: gemfiles/http5-collector.gemfile + ruby_4-0-0__http6_ubuntu-latest: + name: Ruby 4.0.0 - http6 needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: @@ -720,15 +720,16 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &12 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/rails-8.0.gemfile - ruby_4-0-0__resque-2_ubuntu-latest: - name: Ruby 4.0.0 - resque-2 + BUNDLE_GEMFILE: gemfiles/http6.gemfile + ruby_4-0-0__http6-collector_ubuntu-latest: + name: Ruby 4.0.0 - http6-collector needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: @@ -747,15 +748,14 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *12 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/resque-2.gemfile - ruby_4-0-0__resque-3_ubuntu-latest: - name: Ruby 4.0.0 - resque-3 + BUNDLE_GEMFILE: gemfiles/http6-collector.gemfile + ruby_4-0-0__mongo_ubuntu-latest: + name: Ruby 4.0.0 - mongo needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: @@ -774,15 +774,16 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &13 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/resque-3.gemfile - ruby_4-0-0__sequel_ubuntu-latest: - name: Ruby 4.0.0 - sequel + BUNDLE_GEMFILE: gemfiles/mongo.gemfile + ruby_4-0-0__mongo-collector_ubuntu-latest: + name: Ruby 4.0.0 - mongo-collector needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: @@ -801,15 +802,14 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *13 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/sequel.gemfile - ruby_4-0-0__shoryuken-7_ubuntu-latest: - name: Ruby 4.0.0 - shoryuken-7 + BUNDLE_GEMFILE: gemfiles/mongo-collector.gemfile + ruby_4-0-0__ownership_ubuntu-latest: + name: Ruby 4.0.0 - ownership needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: @@ -828,15 +828,16 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &14 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/shoryuken-7.gemfile - ruby_4-0-0__sinatra_ubuntu-latest: - name: Ruby 4.0.0 - sinatra + BUNDLE_GEMFILE: gemfiles/ownership.gemfile + ruby_4-0-0__ownership-collector_ubuntu-latest: + name: Ruby 4.0.0 - ownership-collector needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: @@ -855,15 +856,14 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *14 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/sinatra.gemfile - ruby_4-0-0__webmachine2_ubuntu-latest: - name: Ruby 4.0.0 - webmachine2 + BUNDLE_GEMFILE: gemfiles/ownership-collector.gemfile + ruby_4-0-0__psych-3_ubuntu-latest: + name: Ruby 4.0.0 - psych-3 needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: @@ -882,15 +882,16 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &15 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/webmachine2.gemfile - ruby_4-0-0__redis-4_ubuntu-latest: - name: Ruby 4.0.0 - redis-4 + BUNDLE_GEMFILE: gemfiles/psych-3.gemfile + ruby_4-0-0__psych-3-collector_ubuntu-latest: + name: Ruby 4.0.0 - psych-3-collector needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: @@ -909,15 +910,14 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *15 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/redis-4.gemfile - ruby_4-0-0__redis-5_ubuntu-latest: - name: Ruby 4.0.0 - redis-5 + BUNDLE_GEMFILE: gemfiles/psych-3-collector.gemfile + ruby_4-0-0__psych-4_ubuntu-latest: + name: Ruby 4.0.0 - psych-4 needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: @@ -936,15 +936,16 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &16 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/redis-5.gemfile - ruby_4-0-0__sidekiq-7_ubuntu-latest: - name: Ruby 4.0.0 - sidekiq-7 + BUNDLE_GEMFILE: gemfiles/psych-4.gemfile + ruby_4-0-0__psych-4-collector_ubuntu-latest: + name: Ruby 4.0.0 - psych-4-collector needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: @@ -963,15 +964,14 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *16 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/sidekiq-7.gemfile - ruby_4-0-0__sidekiq-8_ubuntu-latest: - name: Ruby 4.0.0 - sidekiq-8 + BUNDLE_GEMFILE: gemfiles/psych-4-collector.gemfile + ruby_4-0-0__que-0-14_ubuntu-latest: + name: Ruby 4.0.0 - que-0.14 needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: @@ -990,17 +990,18 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &17 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/sidekiq-8.gemfile - ruby_4-0-0_macos-14: - name: Ruby 4.0.0 (macos-14) - needs: validation - runs-on: macos-14 + BUNDLE_GEMFILE: gemfiles/que-0.14.gemfile + ruby_4-0-0__que-0-14-collector_ubuntu-latest: + name: Ruby 4.0.0 - que-0.14-collector + needs: ruby_4-0-0_ubuntu-latest + runs-on: ubuntu-latest steps: - name: Check out repository uses: actions/checkout@v4 @@ -1017,18 +1018,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" - - name: Run tests without extension - run: "./script/bundler_wrapper exec rake test:failure" + - *17 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/no_dependencies.gemfile - ruby_3-4-1_ubuntu-latest: - name: Ruby 3.4.1 - needs: validation + BUNDLE_GEMFILE: gemfiles/que-0.14-collector.gemfile + ruby_4-0-0__que-1_ubuntu-latest: + name: Ruby 4.0.0 - que-1 + needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -1036,7 +1034,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.4.1 + ruby-version: 4.0.0 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -1046,18 +1044,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &18 + name: Run tests run: "./script/bundler_wrapper exec rake test" - - name: Run tests without extension - run: "./script/bundler_wrapper exec rake test:failure" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/no_dependencies.gemfile - ruby_3-4-1__capistrano2_ubuntu-latest: - name: Ruby 3.4.1 - capistrano2 - needs: ruby_3-4-1_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/que-1.gemfile + ruby_4-0-0__que-1-collector_ubuntu-latest: + name: Ruby 4.0.0 - que-1-collector + needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -1065,7 +1062,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.4.1 + ruby-version: 4.0.0 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -1075,16 +1072,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *18 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/capistrano2.gemfile - ruby_3-4-1__capistrano3_ubuntu-latest: - name: Ruby 3.4.1 - capistrano3 - needs: ruby_3-4-1_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/que-1-collector.gemfile + ruby_4-0-0__que-2_ubuntu-latest: + name: Ruby 4.0.0 - que-2 + needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -1092,7 +1088,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.4.1 + ruby-version: 4.0.0 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -1102,16 +1098,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &19 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/capistrano3.gemfile - ruby_3-4-1__code_ownership_ubuntu-latest: - name: Ruby 3.4.1 - code_ownership - needs: ruby_3-4-1_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/que-2.gemfile + ruby_4-0-0__que-2-collector_ubuntu-latest: + name: Ruby 4.0.0 - que-2-collector + needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -1119,7 +1116,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.4.1 + ruby-version: 4.0.0 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -1129,16 +1126,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *19 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/code_ownership.gemfile - ruby_3-4-1__delayed_job_ubuntu-latest: - name: Ruby 3.4.1 - delayed_job - needs: ruby_3-4-1_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/que-2-collector.gemfile + ruby_4-0-0__rails-7-0_ubuntu-latest: + name: Ruby 4.0.0 - rails-7.0 + needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -1146,7 +1142,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.4.1 + ruby-version: 4.0.0 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -1156,16 +1152,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &20 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/delayed_job.gemfile - ruby_3-4-1__dry-monitor_ubuntu-latest: - name: Ruby 3.4.1 - dry-monitor - needs: ruby_3-4-1_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/rails-7.0.gemfile + ruby_4-0-0__rails-7-0-collector_ubuntu-latest: + name: Ruby 4.0.0 - rails-7.0-collector + needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -1173,7 +1170,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.4.1 + ruby-version: 4.0.0 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -1183,16 +1180,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *20 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/dry-monitor.gemfile - ruby_3-4-1__excon_ubuntu-latest: - name: Ruby 3.4.1 - excon - needs: ruby_3-4-1_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/rails-7.0-collector.gemfile + ruby_4-0-0__rails-7-1_ubuntu-latest: + name: Ruby 4.0.0 - rails-7.1 + needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -1200,7 +1196,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.4.1 + ruby-version: 4.0.0 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -1210,16 +1206,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &21 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/excon.gemfile - ruby_3-4-1__faraday-1_ubuntu-latest: - name: Ruby 3.4.1 - faraday-1 - needs: ruby_3-4-1_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/rails-7.1.gemfile + ruby_4-0-0__rails-7-1-collector_ubuntu-latest: + name: Ruby 4.0.0 - rails-7.1-collector + needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -1227,7 +1224,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.4.1 + ruby-version: 4.0.0 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -1237,16 +1234,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *21 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/faraday-1.gemfile - ruby_3-4-1__faraday-2_ubuntu-latest: - name: Ruby 3.4.1 - faraday-2 - needs: ruby_3-4-1_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/rails-7.1-collector.gemfile + ruby_4-0-0__rails-7-2_ubuntu-latest: + name: Ruby 4.0.0 - rails-7.2 + needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -1254,7 +1250,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.4.1 + ruby-version: 4.0.0 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -1264,16 +1260,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &22 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/faraday-2.gemfile - ruby_3-4-1__grape_ubuntu-latest: - name: Ruby 3.4.1 - grape - needs: ruby_3-4-1_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/rails-7.2.gemfile + ruby_4-0-0__rails-7-2-collector_ubuntu-latest: + name: Ruby 4.0.0 - rails-7.2-collector + needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -1281,7 +1278,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.4.1 + ruby-version: 4.0.0 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -1291,16 +1288,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *22 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/grape.gemfile - ruby_3-4-1__hanami-2-0_ubuntu-latest: - name: Ruby 3.4.1 - hanami-2.0 - needs: ruby_3-4-1_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/rails-7.2-collector.gemfile + ruby_4-0-0__rails-8-0_ubuntu-latest: + name: Ruby 4.0.0 - rails-8.0 + needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -1308,7 +1304,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.4.1 + ruby-version: 4.0.0 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -1318,16 +1314,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &23 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/hanami-2.0.gemfile - ruby_3-4-1__hanami-2-1_ubuntu-latest: - name: Ruby 3.4.1 - hanami-2.1 - needs: ruby_3-4-1_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/rails-8.0.gemfile + ruby_4-0-0__rails-8-0-collector_ubuntu-latest: + name: Ruby 4.0.0 - rails-8.0-collector + needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -1335,7 +1332,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.4.1 + ruby-version: 4.0.0 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -1345,16 +1342,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *23 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/hanami-2.1.gemfile - ruby_3-4-1__hanami-2-2_ubuntu-latest: - name: Ruby 3.4.1 - hanami-2.2 - needs: ruby_3-4-1_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/rails-8.0-collector.gemfile + ruby_4-0-0__resque-2_ubuntu-latest: + name: Ruby 4.0.0 - resque-2 + needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -1362,7 +1358,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.4.1 + ruby-version: 4.0.0 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -1372,16 +1368,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &24 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/hanami-2.2.gemfile - ruby_3-4-1__http5_ubuntu-latest: - name: Ruby 3.4.1 - http5 - needs: ruby_3-4-1_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/resque-2.gemfile + ruby_4-0-0__resque-2-collector_ubuntu-latest: + name: Ruby 4.0.0 - resque-2-collector + needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -1389,7 +1386,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.4.1 + ruby-version: 4.0.0 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -1399,16 +1396,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *24 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/http5.gemfile - ruby_3-4-1__http6_ubuntu-latest: - name: Ruby 3.4.1 - http6 - needs: ruby_3-4-1_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/resque-2-collector.gemfile + ruby_4-0-0__resque-3_ubuntu-latest: + name: Ruby 4.0.0 - resque-3 + needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -1416,7 +1412,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.4.1 + ruby-version: 4.0.0 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -1426,16 +1422,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &25 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/http6.gemfile - ruby_3-4-1__mongo_ubuntu-latest: - name: Ruby 3.4.1 - mongo - needs: ruby_3-4-1_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/resque-3.gemfile + ruby_4-0-0__resque-3-collector_ubuntu-latest: + name: Ruby 4.0.0 - resque-3-collector + needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -1443,7 +1440,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.4.1 + ruby-version: 4.0.0 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -1453,16 +1450,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *25 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/mongo.gemfile - ruby_3-4-1__ownership_ubuntu-latest: - name: Ruby 3.4.1 - ownership - needs: ruby_3-4-1_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/resque-3-collector.gemfile + ruby_4-0-0__sequel_ubuntu-latest: + name: Ruby 4.0.0 - sequel + needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -1470,7 +1466,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.4.1 + ruby-version: 4.0.0 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -1480,16 +1476,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &26 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/ownership.gemfile - ruby_3-4-1__padrino_ubuntu-latest: - name: Ruby 3.4.1 - padrino - needs: ruby_3-4-1_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/sequel.gemfile + ruby_4-0-0__sequel-collector_ubuntu-latest: + name: Ruby 4.0.0 - sequel-collector + needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -1497,7 +1494,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.4.1 + ruby-version: 4.0.0 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -1507,16 +1504,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *26 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/padrino.gemfile - ruby_3-4-1__psych-3_ubuntu-latest: - name: Ruby 3.4.1 - psych-3 - needs: ruby_3-4-1_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/sequel-collector.gemfile + ruby_4-0-0__shoryuken-7_ubuntu-latest: + name: Ruby 4.0.0 - shoryuken-7 + needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -1524,7 +1520,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.4.1 + ruby-version: 4.0.0 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -1534,16 +1530,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &27 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/psych-3.gemfile - ruby_3-4-1__psych-4_ubuntu-latest: - name: Ruby 3.4.1 - psych-4 - needs: ruby_3-4-1_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/shoryuken-7.gemfile + ruby_4-0-0__shoryuken-7-collector_ubuntu-latest: + name: Ruby 4.0.0 - shoryuken-7-collector + needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -1551,7 +1548,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.4.1 + ruby-version: 4.0.0 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -1561,16 +1558,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *27 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/psych-4.gemfile - ruby_3-4-1__que-0-14_ubuntu-latest: - name: Ruby 3.4.1 - que-0.14 - needs: ruby_3-4-1_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/shoryuken-7-collector.gemfile + ruby_4-0-0__sinatra_ubuntu-latest: + name: Ruby 4.0.0 - sinatra + needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -1578,7 +1574,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.4.1 + ruby-version: 4.0.0 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -1588,16 +1584,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &28 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/que-0.14.gemfile - ruby_3-4-1__que-1_ubuntu-latest: - name: Ruby 3.4.1 - que-1 - needs: ruby_3-4-1_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/sinatra.gemfile + ruby_4-0-0__sinatra-collector_ubuntu-latest: + name: Ruby 4.0.0 - sinatra-collector + needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -1605,7 +1602,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.4.1 + ruby-version: 4.0.0 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -1615,16 +1612,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *28 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/que-1.gemfile - ruby_3-4-1__que-2_ubuntu-latest: - name: Ruby 3.4.1 - que-2 - needs: ruby_3-4-1_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/sinatra-collector.gemfile + ruby_4-0-0__webmachine2_ubuntu-latest: + name: Ruby 4.0.0 - webmachine2 + needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -1632,7 +1628,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.4.1 + ruby-version: 4.0.0 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -1642,16 +1638,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &29 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/que-2.gemfile - ruby_3-4-1__rails-7-0_ubuntu-latest: - name: Ruby 3.4.1 - rails-7.0 - needs: ruby_3-4-1_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/webmachine2.gemfile + ruby_4-0-0__webmachine2-collector_ubuntu-latest: + name: Ruby 4.0.0 - webmachine2-collector + needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -1659,7 +1656,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.4.1 + ruby-version: 4.0.0 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -1669,16 +1666,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *29 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/rails-7.0.gemfile - ruby_3-4-1__rails-7-1_ubuntu-latest: - name: Ruby 3.4.1 - rails-7.1 - needs: ruby_3-4-1_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/webmachine2-collector.gemfile + ruby_4-0-0__redis-4_ubuntu-latest: + name: Ruby 4.0.0 - redis-4 + needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -1686,7 +1682,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.4.1 + ruby-version: 4.0.0 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -1696,16 +1692,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &30 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/rails-7.1.gemfile - ruby_3-4-1__rails-7-2_ubuntu-latest: - name: Ruby 3.4.1 - rails-7.2 - needs: ruby_3-4-1_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/redis-4.gemfile + ruby_4-0-0__redis-4-collector_ubuntu-latest: + name: Ruby 4.0.0 - redis-4-collector + needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -1713,7 +1710,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.4.1 + ruby-version: 4.0.0 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -1723,16 +1720,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *30 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/rails-7.2.gemfile - ruby_3-4-1__rails-8-0_ubuntu-latest: - name: Ruby 3.4.1 - rails-8.0 - needs: ruby_3-4-1_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/redis-4-collector.gemfile + ruby_4-0-0__redis-5_ubuntu-latest: + name: Ruby 4.0.0 - redis-5 + needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -1740,7 +1736,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.4.1 + ruby-version: 4.0.0 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -1750,16 +1746,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &31 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/rails-8.0.gemfile - ruby_3-4-1__rails-8-1_ubuntu-latest: - name: Ruby 3.4.1 - rails-8.1 - needs: ruby_3-4-1_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/redis-5.gemfile + ruby_4-0-0__redis-5-collector_ubuntu-latest: + name: Ruby 4.0.0 - redis-5-collector + needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -1767,7 +1764,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.4.1 + ruby-version: 4.0.0 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -1777,16 +1774,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *31 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/rails-8.1.gemfile - ruby_3-4-1__resque-2_ubuntu-latest: - name: Ruby 3.4.1 - resque-2 - needs: ruby_3-4-1_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/redis-5-collector.gemfile + ruby_4-0-0__sidekiq-7_ubuntu-latest: + name: Ruby 4.0.0 - sidekiq-7 + needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -1794,7 +1790,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.4.1 + ruby-version: 4.0.0 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -1804,16 +1800,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &32 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/resque-2.gemfile - ruby_3-4-1__resque-3_ubuntu-latest: - name: Ruby 3.4.1 - resque-3 - needs: ruby_3-4-1_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/sidekiq-7.gemfile + ruby_4-0-0__sidekiq-7-collector_ubuntu-latest: + name: Ruby 4.0.0 - sidekiq-7-collector + needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -1821,7 +1818,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.4.1 + ruby-version: 4.0.0 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -1831,16 +1828,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *32 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/resque-3.gemfile - ruby_3-4-1__sequel_ubuntu-latest: - name: Ruby 3.4.1 - sequel - needs: ruby_3-4-1_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/sidekiq-7-collector.gemfile + ruby_4-0-0__sidekiq-8_ubuntu-latest: + name: Ruby 4.0.0 - sidekiq-8 + needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -1848,7 +1844,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.4.1 + ruby-version: 4.0.0 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -1858,16 +1854,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &33 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/sequel.gemfile - ruby_3-4-1__shoryuken-7_ubuntu-latest: - name: Ruby 3.4.1 - shoryuken-7 - needs: ruby_3-4-1_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/sidekiq-8.gemfile + ruby_4-0-0__sidekiq-8-collector_ubuntu-latest: + name: Ruby 4.0.0 - sidekiq-8-collector + needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -1875,7 +1872,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.4.1 + ruby-version: 4.0.0 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -1885,24 +1882,23 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *33 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/shoryuken-7.gemfile - ruby_3-4-1__sinatra_ubuntu-latest: - name: Ruby 3.4.1 - sinatra - needs: ruby_3-4-1_ubuntu-latest - runs-on: ubuntu-latest + BUNDLE_GEMFILE: gemfiles/sidekiq-8-collector.gemfile + ruby_4-0-0_macos-14: + name: Ruby 4.0.0 (macos-14) + needs: validation + runs-on: macos-14 steps: - name: Check out repository uses: actions/checkout@v4 - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.4.1 + ruby-version: 4.0.0 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -1914,14 +1910,16 @@ jobs: found'" - name: Run tests run: "./script/bundler_wrapper exec rake test" + - name: Run tests without extension + run: "./script/bundler_wrapper exec rake test:failure" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/sinatra.gemfile - ruby_3-4-1__webmachine2_ubuntu-latest: - name: Ruby 3.4.1 - webmachine2 - needs: ruby_3-4-1_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/no_dependencies.gemfile + ruby_3-4-1_ubuntu-latest: + name: Ruby 3.4.1 + needs: validation runs-on: ubuntu-latest steps: - name: Check out repository @@ -1939,15 +1937,18 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &34 + name: Run tests run: "./script/bundler_wrapper exec rake test" + - name: Run tests without extension + run: "./script/bundler_wrapper exec rake test:failure" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/webmachine2.gemfile - ruby_3-4-1__redis-4_ubuntu-latest: - name: Ruby 3.4.1 - redis-4 + BUNDLE_GEMFILE: gemfiles/no_dependencies.gemfile + ruby_3-4-1__no_dependencies-collector_ubuntu-latest: + name: Ruby 3.4.1 - no_dependencies-collector needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: @@ -1966,15 +1967,14 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *34 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/redis-4.gemfile - ruby_3-4-1__redis-5_ubuntu-latest: - name: Ruby 3.4.1 - redis-5 + BUNDLE_GEMFILE: gemfiles/no_dependencies-collector.gemfile + ruby_3-4-1__capistrano2_ubuntu-latest: + name: Ruby 3.4.1 - capistrano2 needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: @@ -1993,15 +1993,16 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &35 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/redis-5.gemfile - ruby_3-4-1__sidekiq-7_ubuntu-latest: - name: Ruby 3.4.1 - sidekiq-7 + BUNDLE_GEMFILE: gemfiles/capistrano2.gemfile + ruby_3-4-1__capistrano2-collector_ubuntu-latest: + name: Ruby 3.4.1 - capistrano2-collector needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: @@ -2020,15 +2021,14 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *35 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/sidekiq-7.gemfile - ruby_3-4-1__sidekiq-8_ubuntu-latest: - name: Ruby 3.4.1 - sidekiq-8 + BUNDLE_GEMFILE: gemfiles/capistrano2-collector.gemfile + ruby_3-4-1__capistrano3_ubuntu-latest: + name: Ruby 3.4.1 - capistrano3 needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: @@ -2047,17 +2047,18 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &36 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/sidekiq-8.gemfile - ruby_3-4-1_macos-14: - name: Ruby 3.4.1 (macos-14) - needs: validation - runs-on: macos-14 + BUNDLE_GEMFILE: gemfiles/capistrano3.gemfile + ruby_3-4-1__capistrano3-collector_ubuntu-latest: + name: Ruby 3.4.1 - capistrano3-collector + needs: ruby_3-4-1_ubuntu-latest + runs-on: ubuntu-latest steps: - name: Check out repository uses: actions/checkout@v4 @@ -2074,18 +2075,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" - - name: Run tests without extension - run: "./script/bundler_wrapper exec rake test:failure" + - *36 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/no_dependencies.gemfile - ruby_3-3-4_ubuntu-latest: - name: Ruby 3.3.4 - needs: validation + BUNDLE_GEMFILE: gemfiles/capistrano3-collector.gemfile + ruby_3-4-1__code_ownership_ubuntu-latest: + name: Ruby 3.4.1 - code_ownership + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -2093,7 +2091,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.3.4 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -2103,18 +2101,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &37 + name: Run tests run: "./script/bundler_wrapper exec rake test" - - name: Run tests without extension - run: "./script/bundler_wrapper exec rake test:failure" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/no_dependencies.gemfile - ruby_3-3-4__capistrano2_ubuntu-latest: - name: Ruby 3.3.4 - capistrano2 - needs: ruby_3-3-4_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/code_ownership.gemfile + ruby_3-4-1__code_ownership-collector_ubuntu-latest: + name: Ruby 3.4.1 - code_ownership-collector + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -2122,7 +2119,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.3.4 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -2132,16 +2129,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *37 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/capistrano2.gemfile - ruby_3-3-4__capistrano3_ubuntu-latest: - name: Ruby 3.3.4 - capistrano3 - needs: ruby_3-3-4_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/code_ownership-collector.gemfile + ruby_3-4-1__delayed_job_ubuntu-latest: + name: Ruby 3.4.1 - delayed_job + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -2149,7 +2145,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.3.4 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -2159,16 +2155,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &38 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/capistrano3.gemfile - ruby_3-3-4__code_ownership_ubuntu-latest: - name: Ruby 3.3.4 - code_ownership - needs: ruby_3-3-4_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/delayed_job.gemfile + ruby_3-4-1__delayed_job-collector_ubuntu-latest: + name: Ruby 3.4.1 - delayed_job-collector + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -2176,7 +2173,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.3.4 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -2186,16 +2183,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *38 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/code_ownership.gemfile - ruby_3-3-4__delayed_job_ubuntu-latest: - name: Ruby 3.3.4 - delayed_job - needs: ruby_3-3-4_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/delayed_job-collector.gemfile + ruby_3-4-1__dry-monitor_ubuntu-latest: + name: Ruby 3.4.1 - dry-monitor + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -2203,7 +2199,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.3.4 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -2213,16 +2209,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &39 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/delayed_job.gemfile - ruby_3-3-4__dry-monitor_ubuntu-latest: - name: Ruby 3.3.4 - dry-monitor - needs: ruby_3-3-4_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/dry-monitor.gemfile + ruby_3-4-1__dry-monitor-collector_ubuntu-latest: + name: Ruby 3.4.1 - dry-monitor-collector + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -2230,7 +2227,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.3.4 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -2240,16 +2237,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *39 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/dry-monitor.gemfile - ruby_3-3-4__excon_ubuntu-latest: - name: Ruby 3.3.4 - excon - needs: ruby_3-3-4_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/dry-monitor-collector.gemfile + ruby_3-4-1__excon_ubuntu-latest: + name: Ruby 3.4.1 - excon + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -2257,7 +2253,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.3.4 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -2267,16 +2263,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &40 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' BUNDLE_GEMFILE: gemfiles/excon.gemfile - ruby_3-3-4__faraday-1_ubuntu-latest: - name: Ruby 3.3.4 - faraday-1 - needs: ruby_3-3-4_ubuntu-latest + ruby_3-4-1__excon-collector_ubuntu-latest: + name: Ruby 3.4.1 - excon-collector + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -2284,7 +2281,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.3.4 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -2294,16 +2291,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *40 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/faraday-1.gemfile - ruby_3-3-4__faraday-2_ubuntu-latest: - name: Ruby 3.3.4 - faraday-2 - needs: ruby_3-3-4_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/excon-collector.gemfile + ruby_3-4-1__faraday-1_ubuntu-latest: + name: Ruby 3.4.1 - faraday-1 + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -2311,7 +2307,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.3.4 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -2321,16 +2317,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &41 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/faraday-2.gemfile - ruby_3-3-4__grape_ubuntu-latest: - name: Ruby 3.3.4 - grape - needs: ruby_3-3-4_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/faraday-1.gemfile + ruby_3-4-1__faraday-1-collector_ubuntu-latest: + name: Ruby 3.4.1 - faraday-1-collector + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -2338,7 +2335,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.3.4 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -2348,16 +2345,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *41 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/grape.gemfile - ruby_3-3-4__hanami-2-0_ubuntu-latest: - name: Ruby 3.3.4 - hanami-2.0 - needs: ruby_3-3-4_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/faraday-1-collector.gemfile + ruby_3-4-1__faraday-2_ubuntu-latest: + name: Ruby 3.4.1 - faraday-2 + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -2365,7 +2361,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.3.4 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -2375,16 +2371,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &42 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/hanami-2.0.gemfile - ruby_3-3-4__hanami-2-1_ubuntu-latest: - name: Ruby 3.3.4 - hanami-2.1 - needs: ruby_3-3-4_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/faraday-2.gemfile + ruby_3-4-1__faraday-2-collector_ubuntu-latest: + name: Ruby 3.4.1 - faraday-2-collector + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -2392,7 +2389,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.3.4 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -2402,16 +2399,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *42 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/hanami-2.1.gemfile - ruby_3-3-4__hanami-2-2_ubuntu-latest: - name: Ruby 3.3.4 - hanami-2.2 - needs: ruby_3-3-4_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/faraday-2-collector.gemfile + ruby_3-4-1__grape_ubuntu-latest: + name: Ruby 3.4.1 - grape + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -2419,7 +2415,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.3.4 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -2429,16 +2425,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &43 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/hanami-2.2.gemfile - ruby_3-3-4__http5_ubuntu-latest: - name: Ruby 3.3.4 - http5 - needs: ruby_3-3-4_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/grape.gemfile + ruby_3-4-1__grape-collector_ubuntu-latest: + name: Ruby 3.4.1 - grape-collector + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -2446,7 +2443,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.3.4 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -2456,16 +2453,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *43 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/http5.gemfile - ruby_3-3-4__http6_ubuntu-latest: - name: Ruby 3.3.4 - http6 - needs: ruby_3-3-4_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/grape-collector.gemfile + ruby_3-4-1__hanami-2-0_ubuntu-latest: + name: Ruby 3.4.1 - hanami-2.0 + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -2473,7 +2469,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.3.4 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -2483,16 +2479,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &44 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/http6.gemfile - ruby_3-3-4__mongo_ubuntu-latest: - name: Ruby 3.3.4 - mongo - needs: ruby_3-3-4_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/hanami-2.0.gemfile + ruby_3-4-1__hanami-2-0-collector_ubuntu-latest: + name: Ruby 3.4.1 - hanami-2.0-collector + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -2500,7 +2497,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.3.4 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -2510,16 +2507,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *44 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/mongo.gemfile - ruby_3-3-4__ownership_ubuntu-latest: - name: Ruby 3.3.4 - ownership - needs: ruby_3-3-4_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/hanami-2.0-collector.gemfile + ruby_3-4-1__hanami-2-1_ubuntu-latest: + name: Ruby 3.4.1 - hanami-2.1 + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -2527,7 +2523,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.3.4 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -2537,16 +2533,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &45 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/ownership.gemfile - ruby_3-3-4__padrino_ubuntu-latest: - name: Ruby 3.3.4 - padrino - needs: ruby_3-3-4_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/hanami-2.1.gemfile + ruby_3-4-1__hanami-2-1-collector_ubuntu-latest: + name: Ruby 3.4.1 - hanami-2.1-collector + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -2554,7 +2551,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.3.4 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -2564,16 +2561,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *45 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/padrino.gemfile - ruby_3-3-4__psych-3_ubuntu-latest: - name: Ruby 3.3.4 - psych-3 - needs: ruby_3-3-4_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/hanami-2.1-collector.gemfile + ruby_3-4-1__hanami-2-2_ubuntu-latest: + name: Ruby 3.4.1 - hanami-2.2 + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -2581,7 +2577,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.3.4 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -2591,16 +2587,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &46 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/psych-3.gemfile - ruby_3-3-4__psych-4_ubuntu-latest: - name: Ruby 3.3.4 - psych-4 - needs: ruby_3-3-4_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/hanami-2.2.gemfile + ruby_3-4-1__hanami-2-2-collector_ubuntu-latest: + name: Ruby 3.4.1 - hanami-2.2-collector + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -2608,7 +2605,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.3.4 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -2618,16 +2615,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *46 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/psych-4.gemfile - ruby_3-3-4__que-0-14_ubuntu-latest: - name: Ruby 3.3.4 - que-0.14 - needs: ruby_3-3-4_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/hanami-2.2-collector.gemfile + ruby_3-4-1__http5_ubuntu-latest: + name: Ruby 3.4.1 - http5 + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -2635,7 +2631,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.3.4 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -2645,16 +2641,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &47 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/que-0.14.gemfile - ruby_3-3-4__que-1_ubuntu-latest: - name: Ruby 3.3.4 - que-1 - needs: ruby_3-3-4_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/http5.gemfile + ruby_3-4-1__http5-collector_ubuntu-latest: + name: Ruby 3.4.1 - http5-collector + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -2662,7 +2659,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.3.4 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -2672,16 +2669,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *47 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/que-1.gemfile - ruby_3-3-4__que-2_ubuntu-latest: - name: Ruby 3.3.4 - que-2 - needs: ruby_3-3-4_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/http5-collector.gemfile + ruby_3-4-1__http6_ubuntu-latest: + name: Ruby 3.4.1 - http6 + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -2689,7 +2685,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.3.4 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -2699,16 +2695,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &48 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/que-2.gemfile - ruby_3-3-4__rails-6-1_ubuntu-latest: - name: Ruby 3.3.4 - rails-6.1 - needs: ruby_3-3-4_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/http6.gemfile + ruby_3-4-1__http6-collector_ubuntu-latest: + name: Ruby 3.4.1 - http6-collector + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -2716,7 +2713,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.3.4 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -2726,16 +2723,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *48 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/rails-6.1.gemfile - ruby_3-3-4__rails-7-0_ubuntu-latest: - name: Ruby 3.3.4 - rails-7.0 - needs: ruby_3-3-4_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/http6-collector.gemfile + ruby_3-4-1__mongo_ubuntu-latest: + name: Ruby 3.4.1 - mongo + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -2743,7 +2739,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.3.4 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -2753,16 +2749,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &49 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/rails-7.0.gemfile - ruby_3-3-4__rails-7-1_ubuntu-latest: - name: Ruby 3.3.4 - rails-7.1 - needs: ruby_3-3-4_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/mongo.gemfile + ruby_3-4-1__mongo-collector_ubuntu-latest: + name: Ruby 3.4.1 - mongo-collector + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -2770,7 +2767,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.3.4 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -2780,16 +2777,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *49 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/rails-7.1.gemfile - ruby_3-3-4__rails-7-2_ubuntu-latest: - name: Ruby 3.3.4 - rails-7.2 - needs: ruby_3-3-4_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/mongo-collector.gemfile + ruby_3-4-1__ownership_ubuntu-latest: + name: Ruby 3.4.1 - ownership + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -2797,7 +2793,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.3.4 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -2807,16 +2803,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &50 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/rails-7.2.gemfile - ruby_3-3-4__rails-8-0_ubuntu-latest: - name: Ruby 3.3.4 - rails-8.0 - needs: ruby_3-3-4_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/ownership.gemfile + ruby_3-4-1__ownership-collector_ubuntu-latest: + name: Ruby 3.4.1 - ownership-collector + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -2824,7 +2821,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.3.4 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -2834,16 +2831,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *50 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/rails-8.0.gemfile - ruby_3-3-4__rails-8-1_ubuntu-latest: - name: Ruby 3.3.4 - rails-8.1 - needs: ruby_3-3-4_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/ownership-collector.gemfile + ruby_3-4-1__padrino_ubuntu-latest: + name: Ruby 3.4.1 - padrino + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -2851,7 +2847,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.3.4 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -2861,16 +2857,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &51 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/rails-8.1.gemfile - ruby_3-3-4__resque-2_ubuntu-latest: - name: Ruby 3.3.4 - resque-2 - needs: ruby_3-3-4_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/padrino.gemfile + ruby_3-4-1__padrino-collector_ubuntu-latest: + name: Ruby 3.4.1 - padrino-collector + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -2878,7 +2875,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.3.4 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -2888,16 +2885,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *51 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/resque-2.gemfile - ruby_3-3-4__resque-3_ubuntu-latest: - name: Ruby 3.3.4 - resque-3 - needs: ruby_3-3-4_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/padrino-collector.gemfile + ruby_3-4-1__psych-3_ubuntu-latest: + name: Ruby 3.4.1 - psych-3 + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -2905,7 +2901,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.3.4 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -2915,16 +2911,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &52 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/resque-3.gemfile - ruby_3-3-4__sequel_ubuntu-latest: - name: Ruby 3.3.4 - sequel - needs: ruby_3-3-4_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/psych-3.gemfile + ruby_3-4-1__psych-3-collector_ubuntu-latest: + name: Ruby 3.4.1 - psych-3-collector + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -2932,7 +2929,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.3.4 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -2942,16 +2939,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *52 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/sequel.gemfile - ruby_3-3-4__shoryuken-7_ubuntu-latest: - name: Ruby 3.3.4 - shoryuken-7 - needs: ruby_3-3-4_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/psych-3-collector.gemfile + ruby_3-4-1__psych-4_ubuntu-latest: + name: Ruby 3.4.1 - psych-4 + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -2959,7 +2955,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.3.4 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -2969,16 +2965,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &53 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/shoryuken-7.gemfile - ruby_3-3-4__sinatra_ubuntu-latest: - name: Ruby 3.3.4 - sinatra - needs: ruby_3-3-4_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/psych-4.gemfile + ruby_3-4-1__psych-4-collector_ubuntu-latest: + name: Ruby 3.4.1 - psych-4-collector + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -2986,7 +2983,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.3.4 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -2996,16 +2993,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *53 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/sinatra.gemfile - ruby_3-3-4__webmachine2_ubuntu-latest: - name: Ruby 3.3.4 - webmachine2 - needs: ruby_3-3-4_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/psych-4-collector.gemfile + ruby_3-4-1__que-0-14_ubuntu-latest: + name: Ruby 3.4.1 - que-0.14 + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -3013,7 +3009,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.3.4 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -3023,16 +3019,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &54 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/webmachine2.gemfile - ruby_3-3-4__redis-4_ubuntu-latest: - name: Ruby 3.3.4 - redis-4 - needs: ruby_3-3-4_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/que-0.14.gemfile + ruby_3-4-1__que-0-14-collector_ubuntu-latest: + name: Ruby 3.4.1 - que-0.14-collector + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -3040,7 +3037,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.3.4 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -3050,16 +3047,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *54 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/redis-4.gemfile - ruby_3-3-4__redis-5_ubuntu-latest: - name: Ruby 3.3.4 - redis-5 - needs: ruby_3-3-4_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/que-0.14-collector.gemfile + ruby_3-4-1__que-1_ubuntu-latest: + name: Ruby 3.4.1 - que-1 + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -3067,7 +3063,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.3.4 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -3077,24 +3073,25 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &55 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/redis-5.gemfile - ruby_3-3-4_macos-14: - name: Ruby 3.3.4 (macos-14) - needs: validation - runs-on: macos-14 + BUNDLE_GEMFILE: gemfiles/que-1.gemfile + ruby_3-4-1__que-1-collector_ubuntu-latest: + name: Ruby 3.4.1 - que-1-collector + needs: ruby_3-4-1_ubuntu-latest + runs-on: ubuntu-latest steps: - name: Check out repository uses: actions/checkout@v4 - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.3.4 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -3104,18 +3101,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" - - name: Run tests without extension - run: "./script/bundler_wrapper exec rake test:failure" + - *55 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/no_dependencies.gemfile - ruby_3-2-5_ubuntu-latest: - name: Ruby 3.2.5 - needs: validation + BUNDLE_GEMFILE: gemfiles/que-1-collector.gemfile + ruby_3-4-1__que-2_ubuntu-latest: + name: Ruby 3.4.1 - que-2 + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -3123,7 +3117,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.2.5 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -3133,18 +3127,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &56 + name: Run tests run: "./script/bundler_wrapper exec rake test" - - name: Run tests without extension - run: "./script/bundler_wrapper exec rake test:failure" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/no_dependencies.gemfile - ruby_3-2-5__capistrano2_ubuntu-latest: - name: Ruby 3.2.5 - capistrano2 - needs: ruby_3-2-5_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/que-2.gemfile + ruby_3-4-1__que-2-collector_ubuntu-latest: + name: Ruby 3.4.1 - que-2-collector + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -3152,7 +3145,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.2.5 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -3162,16 +3155,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *56 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/capistrano2.gemfile - ruby_3-2-5__capistrano3_ubuntu-latest: - name: Ruby 3.2.5 - capistrano3 - needs: ruby_3-2-5_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/que-2-collector.gemfile + ruby_3-4-1__rails-7-0_ubuntu-latest: + name: Ruby 3.4.1 - rails-7.0 + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -3179,7 +3171,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.2.5 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -3189,16 +3181,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &57 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/capistrano3.gemfile - ruby_3-2-5__delayed_job_ubuntu-latest: - name: Ruby 3.2.5 - delayed_job - needs: ruby_3-2-5_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/rails-7.0.gemfile + ruby_3-4-1__rails-7-0-collector_ubuntu-latest: + name: Ruby 3.4.1 - rails-7.0-collector + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -3206,7 +3199,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.2.5 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -3216,16 +3209,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *57 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/delayed_job.gemfile - ruby_3-2-5__dry-monitor_ubuntu-latest: - name: Ruby 3.2.5 - dry-monitor - needs: ruby_3-2-5_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/rails-7.0-collector.gemfile + ruby_3-4-1__rails-7-1_ubuntu-latest: + name: Ruby 3.4.1 - rails-7.1 + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -3233,7 +3225,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.2.5 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -3243,16 +3235,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &58 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/dry-monitor.gemfile - ruby_3-2-5__excon_ubuntu-latest: - name: Ruby 3.2.5 - excon - needs: ruby_3-2-5_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/rails-7.1.gemfile + ruby_3-4-1__rails-7-1-collector_ubuntu-latest: + name: Ruby 3.4.1 - rails-7.1-collector + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -3260,7 +3253,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.2.5 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -3270,16 +3263,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *58 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/excon.gemfile - ruby_3-2-5__faraday-1_ubuntu-latest: - name: Ruby 3.2.5 - faraday-1 - needs: ruby_3-2-5_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/rails-7.1-collector.gemfile + ruby_3-4-1__rails-7-2_ubuntu-latest: + name: Ruby 3.4.1 - rails-7.2 + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -3287,7 +3279,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.2.5 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -3297,16 +3289,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &59 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/faraday-1.gemfile - ruby_3-2-5__faraday-2_ubuntu-latest: - name: Ruby 3.2.5 - faraday-2 - needs: ruby_3-2-5_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/rails-7.2.gemfile + ruby_3-4-1__rails-7-2-collector_ubuntu-latest: + name: Ruby 3.4.1 - rails-7.2-collector + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -3314,7 +3307,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.2.5 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -3324,16 +3317,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *59 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/faraday-2.gemfile - ruby_3-2-5__grape_ubuntu-latest: - name: Ruby 3.2.5 - grape - needs: ruby_3-2-5_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/rails-7.2-collector.gemfile + ruby_3-4-1__rails-8-0_ubuntu-latest: + name: Ruby 3.4.1 - rails-8.0 + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -3341,7 +3333,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.2.5 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -3351,16 +3343,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &60 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/grape.gemfile - ruby_3-2-5__hanami-2-0_ubuntu-latest: - name: Ruby 3.2.5 - hanami-2.0 - needs: ruby_3-2-5_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/rails-8.0.gemfile + ruby_3-4-1__rails-8-0-collector_ubuntu-latest: + name: Ruby 3.4.1 - rails-8.0-collector + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -3368,7 +3361,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.2.5 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -3378,16 +3371,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *60 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/hanami-2.0.gemfile - ruby_3-2-5__hanami-2-1_ubuntu-latest: - name: Ruby 3.2.5 - hanami-2.1 - needs: ruby_3-2-5_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/rails-8.0-collector.gemfile + ruby_3-4-1__rails-8-1_ubuntu-latest: + name: Ruby 3.4.1 - rails-8.1 + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -3395,7 +3387,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.2.5 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -3405,16 +3397,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &61 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/hanami-2.1.gemfile - ruby_3-2-5__hanami-2-2_ubuntu-latest: - name: Ruby 3.2.5 - hanami-2.2 - needs: ruby_3-2-5_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/rails-8.1.gemfile + ruby_3-4-1__rails-8-1-collector_ubuntu-latest: + name: Ruby 3.4.1 - rails-8.1-collector + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -3422,7 +3415,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.2.5 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -3432,16 +3425,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *61 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/hanami-2.2.gemfile - ruby_3-2-5__http5_ubuntu-latest: - name: Ruby 3.2.5 - http5 - needs: ruby_3-2-5_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/rails-8.1-collector.gemfile + ruby_3-4-1__resque-2_ubuntu-latest: + name: Ruby 3.4.1 - resque-2 + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -3449,7 +3441,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.2.5 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -3459,16 +3451,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &62 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/http5.gemfile - ruby_3-2-5__http6_ubuntu-latest: - name: Ruby 3.2.5 - http6 - needs: ruby_3-2-5_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/resque-2.gemfile + ruby_3-4-1__resque-2-collector_ubuntu-latest: + name: Ruby 3.4.1 - resque-2-collector + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -3476,7 +3469,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.2.5 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -3486,16 +3479,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *62 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/http6.gemfile - ruby_3-2-5__mongo_ubuntu-latest: - name: Ruby 3.2.5 - mongo - needs: ruby_3-2-5_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/resque-2-collector.gemfile + ruby_3-4-1__resque-3_ubuntu-latest: + name: Ruby 3.4.1 - resque-3 + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -3503,7 +3495,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.2.5 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -3513,16 +3505,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &63 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/mongo.gemfile - ruby_3-2-5__ownership_ubuntu-latest: - name: Ruby 3.2.5 - ownership - needs: ruby_3-2-5_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/resque-3.gemfile + ruby_3-4-1__resque-3-collector_ubuntu-latest: + name: Ruby 3.4.1 - resque-3-collector + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -3530,7 +3523,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.2.5 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -3540,16 +3533,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *63 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/ownership.gemfile - ruby_3-2-5__padrino_ubuntu-latest: - name: Ruby 3.2.5 - padrino - needs: ruby_3-2-5_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/resque-3-collector.gemfile + ruby_3-4-1__sequel_ubuntu-latest: + name: Ruby 3.4.1 - sequel + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -3557,7 +3549,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.2.5 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -3567,16 +3559,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &64 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/padrino.gemfile - ruby_3-2-5__psych-3_ubuntu-latest: - name: Ruby 3.2.5 - psych-3 - needs: ruby_3-2-5_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/sequel.gemfile + ruby_3-4-1__sequel-collector_ubuntu-latest: + name: Ruby 3.4.1 - sequel-collector + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -3584,7 +3577,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.2.5 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -3594,16 +3587,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *64 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/psych-3.gemfile - ruby_3-2-5__psych-4_ubuntu-latest: - name: Ruby 3.2.5 - psych-4 - needs: ruby_3-2-5_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/sequel-collector.gemfile + ruby_3-4-1__shoryuken-7_ubuntu-latest: + name: Ruby 3.4.1 - shoryuken-7 + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -3611,7 +3603,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.2.5 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -3621,16 +3613,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &65 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/psych-4.gemfile - ruby_3-2-5__que-0-14_ubuntu-latest: - name: Ruby 3.2.5 - que-0.14 - needs: ruby_3-2-5_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/shoryuken-7.gemfile + ruby_3-4-1__shoryuken-7-collector_ubuntu-latest: + name: Ruby 3.4.1 - shoryuken-7-collector + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -3638,7 +3631,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.2.5 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -3648,16 +3641,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *65 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/que-0.14.gemfile - ruby_3-2-5__que-1_ubuntu-latest: - name: Ruby 3.2.5 - que-1 - needs: ruby_3-2-5_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/shoryuken-7-collector.gemfile + ruby_3-4-1__sinatra_ubuntu-latest: + name: Ruby 3.4.1 - sinatra + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -3665,7 +3657,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.2.5 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -3675,16 +3667,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &66 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/que-1.gemfile - ruby_3-2-5__que-2_ubuntu-latest: - name: Ruby 3.2.5 - que-2 - needs: ruby_3-2-5_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/sinatra.gemfile + ruby_3-4-1__sinatra-collector_ubuntu-latest: + name: Ruby 3.4.1 - sinatra-collector + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -3692,7 +3685,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.2.5 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -3702,16 +3695,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *66 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/que-2.gemfile - ruby_3-2-5__rails-6-1_ubuntu-latest: - name: Ruby 3.2.5 - rails-6.1 - needs: ruby_3-2-5_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/sinatra-collector.gemfile + ruby_3-4-1__webmachine2_ubuntu-latest: + name: Ruby 3.4.1 - webmachine2 + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -3719,7 +3711,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.2.5 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -3729,16 +3721,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &67 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/rails-6.1.gemfile - ruby_3-2-5__rails-7-0_ubuntu-latest: - name: Ruby 3.2.5 - rails-7.0 - needs: ruby_3-2-5_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/webmachine2.gemfile + ruby_3-4-1__webmachine2-collector_ubuntu-latest: + name: Ruby 3.4.1 - webmachine2-collector + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -3746,7 +3739,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.2.5 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -3756,16 +3749,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *67 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/rails-7.0.gemfile - ruby_3-2-5__rails-7-1_ubuntu-latest: - name: Ruby 3.2.5 - rails-7.1 - needs: ruby_3-2-5_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/webmachine2-collector.gemfile + ruby_3-4-1__redis-4_ubuntu-latest: + name: Ruby 3.4.1 - redis-4 + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -3773,7 +3765,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.2.5 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -3783,16 +3775,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &68 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/rails-7.1.gemfile - ruby_3-2-5__rails-7-2_ubuntu-latest: - name: Ruby 3.2.5 - rails-7.2 - needs: ruby_3-2-5_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/redis-4.gemfile + ruby_3-4-1__redis-4-collector_ubuntu-latest: + name: Ruby 3.4.1 - redis-4-collector + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -3800,7 +3793,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.2.5 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -3810,16 +3803,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *68 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/rails-7.2.gemfile - ruby_3-2-5__rails-8-0_ubuntu-latest: - name: Ruby 3.2.5 - rails-8.0 - needs: ruby_3-2-5_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/redis-4-collector.gemfile + ruby_3-4-1__redis-5_ubuntu-latest: + name: Ruby 3.4.1 - redis-5 + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -3827,7 +3819,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.2.5 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -3837,16 +3829,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &69 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/rails-8.0.gemfile - ruby_3-2-5__rails-8-1_ubuntu-latest: - name: Ruby 3.2.5 - rails-8.1 - needs: ruby_3-2-5_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/redis-5.gemfile + ruby_3-4-1__redis-5-collector_ubuntu-latest: + name: Ruby 3.4.1 - redis-5-collector + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -3854,7 +3847,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.2.5 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -3864,16 +3857,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *69 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/rails-8.1.gemfile - ruby_3-2-5__resque-2_ubuntu-latest: - name: Ruby 3.2.5 - resque-2 - needs: ruby_3-2-5_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/redis-5-collector.gemfile + ruby_3-4-1__sidekiq-7_ubuntu-latest: + name: Ruby 3.4.1 - sidekiq-7 + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -3881,7 +3873,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.2.5 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -3891,16 +3883,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &70 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/resque-2.gemfile - ruby_3-2-5__resque-3_ubuntu-latest: - name: Ruby 3.2.5 - resque-3 - needs: ruby_3-2-5_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/sidekiq-7.gemfile + ruby_3-4-1__sidekiq-7-collector_ubuntu-latest: + name: Ruby 3.4.1 - sidekiq-7-collector + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -3908,7 +3901,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.2.5 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -3918,16 +3911,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *70 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/resque-3.gemfile - ruby_3-2-5__sequel_ubuntu-latest: - name: Ruby 3.2.5 - sequel - needs: ruby_3-2-5_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/sidekiq-7-collector.gemfile + ruby_3-4-1__sidekiq-8_ubuntu-latest: + name: Ruby 3.4.1 - sidekiq-8 + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -3935,7 +3927,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.2.5 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -3945,16 +3937,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &71 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/sequel.gemfile - ruby_3-2-5__shoryuken-7_ubuntu-latest: - name: Ruby 3.2.5 - shoryuken-7 - needs: ruby_3-2-5_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/sidekiq-8.gemfile + ruby_3-4-1__sidekiq-8-collector_ubuntu-latest: + name: Ruby 3.4.1 - sidekiq-8-collector + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -3962,7 +3955,33 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.2.5 + ruby-version: 3.4.1 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *71 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/sidekiq-8-collector.gemfile + ruby_3-4-1_macos-14: + name: Ruby 3.4.1 (macos-14) + needs: validation + runs-on: macos-14 + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -3974,14 +3993,4778 @@ jobs: found'" - name: Run tests run: "./script/bundler_wrapper exec rake test" + - name: Run tests without extension + run: "./script/bundler_wrapper exec rake test:failure" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/shoryuken-7.gemfile - ruby_3-2-5__sinatra_ubuntu-latest: - name: Ruby 3.2.5 - sinatra - needs: ruby_3-2-5_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/no_dependencies.gemfile + ruby_3-3-4_ubuntu-latest: + name: Ruby 3.3.4 + needs: validation + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &72 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + - name: Run tests without extension + run: "./script/bundler_wrapper exec rake test:failure" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/no_dependencies.gemfile + ruby_3-3-4__no_dependencies-collector_ubuntu-latest: + name: Ruby 3.3.4 - no_dependencies-collector + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *72 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/no_dependencies-collector.gemfile + ruby_3-3-4__capistrano2_ubuntu-latest: + name: Ruby 3.3.4 - capistrano2 + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &73 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/capistrano2.gemfile + ruby_3-3-4__capistrano2-collector_ubuntu-latest: + name: Ruby 3.3.4 - capistrano2-collector + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *73 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/capistrano2-collector.gemfile + ruby_3-3-4__capistrano3_ubuntu-latest: + name: Ruby 3.3.4 - capistrano3 + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &74 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/capistrano3.gemfile + ruby_3-3-4__capistrano3-collector_ubuntu-latest: + name: Ruby 3.3.4 - capistrano3-collector + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *74 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/capistrano3-collector.gemfile + ruby_3-3-4__code_ownership_ubuntu-latest: + name: Ruby 3.3.4 - code_ownership + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &75 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/code_ownership.gemfile + ruby_3-3-4__code_ownership-collector_ubuntu-latest: + name: Ruby 3.3.4 - code_ownership-collector + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *75 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/code_ownership-collector.gemfile + ruby_3-3-4__delayed_job_ubuntu-latest: + name: Ruby 3.3.4 - delayed_job + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &76 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/delayed_job.gemfile + ruby_3-3-4__delayed_job-collector_ubuntu-latest: + name: Ruby 3.3.4 - delayed_job-collector + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *76 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/delayed_job-collector.gemfile + ruby_3-3-4__dry-monitor_ubuntu-latest: + name: Ruby 3.3.4 - dry-monitor + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &77 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/dry-monitor.gemfile + ruby_3-3-4__dry-monitor-collector_ubuntu-latest: + name: Ruby 3.3.4 - dry-monitor-collector + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *77 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/dry-monitor-collector.gemfile + ruby_3-3-4__excon_ubuntu-latest: + name: Ruby 3.3.4 - excon + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &78 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/excon.gemfile + ruby_3-3-4__excon-collector_ubuntu-latest: + name: Ruby 3.3.4 - excon-collector + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *78 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/excon-collector.gemfile + ruby_3-3-4__faraday-1_ubuntu-latest: + name: Ruby 3.3.4 - faraday-1 + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &79 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/faraday-1.gemfile + ruby_3-3-4__faraday-1-collector_ubuntu-latest: + name: Ruby 3.3.4 - faraday-1-collector + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *79 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/faraday-1-collector.gemfile + ruby_3-3-4__faraday-2_ubuntu-latest: + name: Ruby 3.3.4 - faraday-2 + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &80 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/faraday-2.gemfile + ruby_3-3-4__faraday-2-collector_ubuntu-latest: + name: Ruby 3.3.4 - faraday-2-collector + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *80 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/faraday-2-collector.gemfile + ruby_3-3-4__grape_ubuntu-latest: + name: Ruby 3.3.4 - grape + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &81 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/grape.gemfile + ruby_3-3-4__grape-collector_ubuntu-latest: + name: Ruby 3.3.4 - grape-collector + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *81 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/grape-collector.gemfile + ruby_3-3-4__hanami-2-0_ubuntu-latest: + name: Ruby 3.3.4 - hanami-2.0 + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &82 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/hanami-2.0.gemfile + ruby_3-3-4__hanami-2-0-collector_ubuntu-latest: + name: Ruby 3.3.4 - hanami-2.0-collector + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *82 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/hanami-2.0-collector.gemfile + ruby_3-3-4__hanami-2-1_ubuntu-latest: + name: Ruby 3.3.4 - hanami-2.1 + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &83 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/hanami-2.1.gemfile + ruby_3-3-4__hanami-2-1-collector_ubuntu-latest: + name: Ruby 3.3.4 - hanami-2.1-collector + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *83 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/hanami-2.1-collector.gemfile + ruby_3-3-4__hanami-2-2_ubuntu-latest: + name: Ruby 3.3.4 - hanami-2.2 + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &84 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/hanami-2.2.gemfile + ruby_3-3-4__hanami-2-2-collector_ubuntu-latest: + name: Ruby 3.3.4 - hanami-2.2-collector + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *84 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/hanami-2.2-collector.gemfile + ruby_3-3-4__http5_ubuntu-latest: + name: Ruby 3.3.4 - http5 + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &85 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/http5.gemfile + ruby_3-3-4__http5-collector_ubuntu-latest: + name: Ruby 3.3.4 - http5-collector + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *85 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/http5-collector.gemfile + ruby_3-3-4__http6_ubuntu-latest: + name: Ruby 3.3.4 - http6 + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &86 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/http6.gemfile + ruby_3-3-4__http6-collector_ubuntu-latest: + name: Ruby 3.3.4 - http6-collector + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *86 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/http6-collector.gemfile + ruby_3-3-4__mongo_ubuntu-latest: + name: Ruby 3.3.4 - mongo + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &87 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/mongo.gemfile + ruby_3-3-4__mongo-collector_ubuntu-latest: + name: Ruby 3.3.4 - mongo-collector + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *87 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/mongo-collector.gemfile + ruby_3-3-4__ownership_ubuntu-latest: + name: Ruby 3.3.4 - ownership + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &88 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/ownership.gemfile + ruby_3-3-4__ownership-collector_ubuntu-latest: + name: Ruby 3.3.4 - ownership-collector + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *88 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/ownership-collector.gemfile + ruby_3-3-4__padrino_ubuntu-latest: + name: Ruby 3.3.4 - padrino + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &89 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/padrino.gemfile + ruby_3-3-4__padrino-collector_ubuntu-latest: + name: Ruby 3.3.4 - padrino-collector + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *89 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/padrino-collector.gemfile + ruby_3-3-4__psych-3_ubuntu-latest: + name: Ruby 3.3.4 - psych-3 + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &90 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/psych-3.gemfile + ruby_3-3-4__psych-3-collector_ubuntu-latest: + name: Ruby 3.3.4 - psych-3-collector + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *90 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/psych-3-collector.gemfile + ruby_3-3-4__psych-4_ubuntu-latest: + name: Ruby 3.3.4 - psych-4 + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &91 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/psych-4.gemfile + ruby_3-3-4__psych-4-collector_ubuntu-latest: + name: Ruby 3.3.4 - psych-4-collector + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *91 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/psych-4-collector.gemfile + ruby_3-3-4__que-0-14_ubuntu-latest: + name: Ruby 3.3.4 - que-0.14 + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &92 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/que-0.14.gemfile + ruby_3-3-4__que-0-14-collector_ubuntu-latest: + name: Ruby 3.3.4 - que-0.14-collector + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *92 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/que-0.14-collector.gemfile + ruby_3-3-4__que-1_ubuntu-latest: + name: Ruby 3.3.4 - que-1 + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &93 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/que-1.gemfile + ruby_3-3-4__que-1-collector_ubuntu-latest: + name: Ruby 3.3.4 - que-1-collector + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *93 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/que-1-collector.gemfile + ruby_3-3-4__que-2_ubuntu-latest: + name: Ruby 3.3.4 - que-2 + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &94 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/que-2.gemfile + ruby_3-3-4__que-2-collector_ubuntu-latest: + name: Ruby 3.3.4 - que-2-collector + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *94 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/que-2-collector.gemfile + ruby_3-3-4__rails-6-1_ubuntu-latest: + name: Ruby 3.3.4 - rails-6.1 + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &95 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/rails-6.1.gemfile + ruby_3-3-4__rails-6-1-collector_ubuntu-latest: + name: Ruby 3.3.4 - rails-6.1-collector + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *95 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/rails-6.1-collector.gemfile + ruby_3-3-4__rails-7-0_ubuntu-latest: + name: Ruby 3.3.4 - rails-7.0 + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &96 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/rails-7.0.gemfile + ruby_3-3-4__rails-7-0-collector_ubuntu-latest: + name: Ruby 3.3.4 - rails-7.0-collector + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *96 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/rails-7.0-collector.gemfile + ruby_3-3-4__rails-7-1_ubuntu-latest: + name: Ruby 3.3.4 - rails-7.1 + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &97 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/rails-7.1.gemfile + ruby_3-3-4__rails-7-1-collector_ubuntu-latest: + name: Ruby 3.3.4 - rails-7.1-collector + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *97 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/rails-7.1-collector.gemfile + ruby_3-3-4__rails-7-2_ubuntu-latest: + name: Ruby 3.3.4 - rails-7.2 + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &98 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/rails-7.2.gemfile + ruby_3-3-4__rails-7-2-collector_ubuntu-latest: + name: Ruby 3.3.4 - rails-7.2-collector + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *98 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/rails-7.2-collector.gemfile + ruby_3-3-4__rails-8-0_ubuntu-latest: + name: Ruby 3.3.4 - rails-8.0 + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &99 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/rails-8.0.gemfile + ruby_3-3-4__rails-8-0-collector_ubuntu-latest: + name: Ruby 3.3.4 - rails-8.0-collector + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *99 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/rails-8.0-collector.gemfile + ruby_3-3-4__rails-8-1_ubuntu-latest: + name: Ruby 3.3.4 - rails-8.1 + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &100 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/rails-8.1.gemfile + ruby_3-3-4__rails-8-1-collector_ubuntu-latest: + name: Ruby 3.3.4 - rails-8.1-collector + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *100 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/rails-8.1-collector.gemfile + ruby_3-3-4__resque-2_ubuntu-latest: + name: Ruby 3.3.4 - resque-2 + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &101 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/resque-2.gemfile + ruby_3-3-4__resque-2-collector_ubuntu-latest: + name: Ruby 3.3.4 - resque-2-collector + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *101 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/resque-2-collector.gemfile + ruby_3-3-4__resque-3_ubuntu-latest: + name: Ruby 3.3.4 - resque-3 + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &102 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/resque-3.gemfile + ruby_3-3-4__resque-3-collector_ubuntu-latest: + name: Ruby 3.3.4 - resque-3-collector + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *102 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/resque-3-collector.gemfile + ruby_3-3-4__sequel_ubuntu-latest: + name: Ruby 3.3.4 - sequel + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &103 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/sequel.gemfile + ruby_3-3-4__sequel-collector_ubuntu-latest: + name: Ruby 3.3.4 - sequel-collector + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *103 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/sequel-collector.gemfile + ruby_3-3-4__shoryuken-7_ubuntu-latest: + name: Ruby 3.3.4 - shoryuken-7 + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &104 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/shoryuken-7.gemfile + ruby_3-3-4__shoryuken-7-collector_ubuntu-latest: + name: Ruby 3.3.4 - shoryuken-7-collector + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *104 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/shoryuken-7-collector.gemfile + ruby_3-3-4__sinatra_ubuntu-latest: + name: Ruby 3.3.4 - sinatra + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &105 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/sinatra.gemfile + ruby_3-3-4__sinatra-collector_ubuntu-latest: + name: Ruby 3.3.4 - sinatra-collector + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *105 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/sinatra-collector.gemfile + ruby_3-3-4__webmachine2_ubuntu-latest: + name: Ruby 3.3.4 - webmachine2 + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &106 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/webmachine2.gemfile + ruby_3-3-4__webmachine2-collector_ubuntu-latest: + name: Ruby 3.3.4 - webmachine2-collector + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *106 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/webmachine2-collector.gemfile + ruby_3-3-4__redis-4_ubuntu-latest: + name: Ruby 3.3.4 - redis-4 + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &107 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/redis-4.gemfile + ruby_3-3-4__redis-4-collector_ubuntu-latest: + name: Ruby 3.3.4 - redis-4-collector + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *107 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/redis-4-collector.gemfile + ruby_3-3-4__redis-5_ubuntu-latest: + name: Ruby 3.3.4 - redis-5 + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &108 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/redis-5.gemfile + ruby_3-3-4__redis-5-collector_ubuntu-latest: + name: Ruby 3.3.4 - redis-5-collector + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *108 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/redis-5-collector.gemfile + ruby_3-3-4_macos-14: + name: Ruby 3.3.4 (macos-14) + needs: validation + runs-on: macos-14 + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - name: Run tests + run: "./script/bundler_wrapper exec rake test" + - name: Run tests without extension + run: "./script/bundler_wrapper exec rake test:failure" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/no_dependencies.gemfile + ruby_3-2-5_ubuntu-latest: + name: Ruby 3.2.5 + needs: validation + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &109 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + - name: Run tests without extension + run: "./script/bundler_wrapper exec rake test:failure" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/no_dependencies.gemfile + ruby_3-2-5__no_dependencies-collector_ubuntu-latest: + name: Ruby 3.2.5 - no_dependencies-collector + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *109 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/no_dependencies-collector.gemfile + ruby_3-2-5__capistrano2_ubuntu-latest: + name: Ruby 3.2.5 - capistrano2 + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &110 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/capistrano2.gemfile + ruby_3-2-5__capistrano2-collector_ubuntu-latest: + name: Ruby 3.2.5 - capistrano2-collector + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *110 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/capistrano2-collector.gemfile + ruby_3-2-5__capistrano3_ubuntu-latest: + name: Ruby 3.2.5 - capistrano3 + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &111 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/capistrano3.gemfile + ruby_3-2-5__capistrano3-collector_ubuntu-latest: + name: Ruby 3.2.5 - capistrano3-collector + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *111 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/capistrano3-collector.gemfile + ruby_3-2-5__delayed_job_ubuntu-latest: + name: Ruby 3.2.5 - delayed_job + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &112 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/delayed_job.gemfile + ruby_3-2-5__delayed_job-collector_ubuntu-latest: + name: Ruby 3.2.5 - delayed_job-collector + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *112 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/delayed_job-collector.gemfile + ruby_3-2-5__dry-monitor_ubuntu-latest: + name: Ruby 3.2.5 - dry-monitor + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &113 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/dry-monitor.gemfile + ruby_3-2-5__dry-monitor-collector_ubuntu-latest: + name: Ruby 3.2.5 - dry-monitor-collector + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *113 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/dry-monitor-collector.gemfile + ruby_3-2-5__excon_ubuntu-latest: + name: Ruby 3.2.5 - excon + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &114 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/excon.gemfile + ruby_3-2-5__excon-collector_ubuntu-latest: + name: Ruby 3.2.5 - excon-collector + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *114 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/excon-collector.gemfile + ruby_3-2-5__faraday-1_ubuntu-latest: + name: Ruby 3.2.5 - faraday-1 + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &115 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/faraday-1.gemfile + ruby_3-2-5__faraday-1-collector_ubuntu-latest: + name: Ruby 3.2.5 - faraday-1-collector + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *115 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/faraday-1-collector.gemfile + ruby_3-2-5__faraday-2_ubuntu-latest: + name: Ruby 3.2.5 - faraday-2 + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &116 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/faraday-2.gemfile + ruby_3-2-5__faraday-2-collector_ubuntu-latest: + name: Ruby 3.2.5 - faraday-2-collector + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *116 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/faraday-2-collector.gemfile + ruby_3-2-5__grape_ubuntu-latest: + name: Ruby 3.2.5 - grape + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &117 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/grape.gemfile + ruby_3-2-5__grape-collector_ubuntu-latest: + name: Ruby 3.2.5 - grape-collector + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *117 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/grape-collector.gemfile + ruby_3-2-5__hanami-2-0_ubuntu-latest: + name: Ruby 3.2.5 - hanami-2.0 + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &118 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/hanami-2.0.gemfile + ruby_3-2-5__hanami-2-0-collector_ubuntu-latest: + name: Ruby 3.2.5 - hanami-2.0-collector + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *118 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/hanami-2.0-collector.gemfile + ruby_3-2-5__hanami-2-1_ubuntu-latest: + name: Ruby 3.2.5 - hanami-2.1 + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &119 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/hanami-2.1.gemfile + ruby_3-2-5__hanami-2-1-collector_ubuntu-latest: + name: Ruby 3.2.5 - hanami-2.1-collector + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *119 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/hanami-2.1-collector.gemfile + ruby_3-2-5__hanami-2-2_ubuntu-latest: + name: Ruby 3.2.5 - hanami-2.2 + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &120 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/hanami-2.2.gemfile + ruby_3-2-5__hanami-2-2-collector_ubuntu-latest: + name: Ruby 3.2.5 - hanami-2.2-collector + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *120 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/hanami-2.2-collector.gemfile + ruby_3-2-5__http5_ubuntu-latest: + name: Ruby 3.2.5 - http5 + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &121 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/http5.gemfile + ruby_3-2-5__http5-collector_ubuntu-latest: + name: Ruby 3.2.5 - http5-collector + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *121 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/http5-collector.gemfile + ruby_3-2-5__http6_ubuntu-latest: + name: Ruby 3.2.5 - http6 + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &122 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/http6.gemfile + ruby_3-2-5__http6-collector_ubuntu-latest: + name: Ruby 3.2.5 - http6-collector + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *122 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/http6-collector.gemfile + ruby_3-2-5__mongo_ubuntu-latest: + name: Ruby 3.2.5 - mongo + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &123 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/mongo.gemfile + ruby_3-2-5__mongo-collector_ubuntu-latest: + name: Ruby 3.2.5 - mongo-collector + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *123 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/mongo-collector.gemfile + ruby_3-2-5__ownership_ubuntu-latest: + name: Ruby 3.2.5 - ownership + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &124 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/ownership.gemfile + ruby_3-2-5__ownership-collector_ubuntu-latest: + name: Ruby 3.2.5 - ownership-collector + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *124 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/ownership-collector.gemfile + ruby_3-2-5__padrino_ubuntu-latest: + name: Ruby 3.2.5 - padrino + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &125 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/padrino.gemfile + ruby_3-2-5__padrino-collector_ubuntu-latest: + name: Ruby 3.2.5 - padrino-collector + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *125 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/padrino-collector.gemfile + ruby_3-2-5__psych-3_ubuntu-latest: + name: Ruby 3.2.5 - psych-3 + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &126 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/psych-3.gemfile + ruby_3-2-5__psych-3-collector_ubuntu-latest: + name: Ruby 3.2.5 - psych-3-collector + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *126 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/psych-3-collector.gemfile + ruby_3-2-5__psych-4_ubuntu-latest: + name: Ruby 3.2.5 - psych-4 + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &127 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/psych-4.gemfile + ruby_3-2-5__psych-4-collector_ubuntu-latest: + name: Ruby 3.2.5 - psych-4-collector + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *127 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/psych-4-collector.gemfile + ruby_3-2-5__que-0-14_ubuntu-latest: + name: Ruby 3.2.5 - que-0.14 + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &128 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/que-0.14.gemfile + ruby_3-2-5__que-0-14-collector_ubuntu-latest: + name: Ruby 3.2.5 - que-0.14-collector + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *128 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/que-0.14-collector.gemfile + ruby_3-2-5__que-1_ubuntu-latest: + name: Ruby 3.2.5 - que-1 + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &129 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/que-1.gemfile + ruby_3-2-5__que-1-collector_ubuntu-latest: + name: Ruby 3.2.5 - que-1-collector + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *129 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/que-1-collector.gemfile + ruby_3-2-5__que-2_ubuntu-latest: + name: Ruby 3.2.5 - que-2 + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &130 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/que-2.gemfile + ruby_3-2-5__que-2-collector_ubuntu-latest: + name: Ruby 3.2.5 - que-2-collector + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *130 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/que-2-collector.gemfile + ruby_3-2-5__rails-6-1_ubuntu-latest: + name: Ruby 3.2.5 - rails-6.1 + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &131 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/rails-6.1.gemfile + ruby_3-2-5__rails-6-1-collector_ubuntu-latest: + name: Ruby 3.2.5 - rails-6.1-collector + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *131 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/rails-6.1-collector.gemfile + ruby_3-2-5__rails-7-0_ubuntu-latest: + name: Ruby 3.2.5 - rails-7.0 + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &132 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/rails-7.0.gemfile + ruby_3-2-5__rails-7-0-collector_ubuntu-latest: + name: Ruby 3.2.5 - rails-7.0-collector + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *132 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/rails-7.0-collector.gemfile + ruby_3-2-5__rails-7-1_ubuntu-latest: + name: Ruby 3.2.5 - rails-7.1 + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &133 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/rails-7.1.gemfile + ruby_3-2-5__rails-7-1-collector_ubuntu-latest: + name: Ruby 3.2.5 - rails-7.1-collector + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *133 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/rails-7.1-collector.gemfile + ruby_3-2-5__rails-7-2_ubuntu-latest: + name: Ruby 3.2.5 - rails-7.2 + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &134 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/rails-7.2.gemfile + ruby_3-2-5__rails-7-2-collector_ubuntu-latest: + name: Ruby 3.2.5 - rails-7.2-collector + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *134 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/rails-7.2-collector.gemfile + ruby_3-2-5__rails-8-0_ubuntu-latest: + name: Ruby 3.2.5 - rails-8.0 + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &135 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/rails-8.0.gemfile + ruby_3-2-5__rails-8-0-collector_ubuntu-latest: + name: Ruby 3.2.5 - rails-8.0-collector + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *135 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/rails-8.0-collector.gemfile + ruby_3-2-5__rails-8-1_ubuntu-latest: + name: Ruby 3.2.5 - rails-8.1 + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &136 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/rails-8.1.gemfile + ruby_3-2-5__rails-8-1-collector_ubuntu-latest: + name: Ruby 3.2.5 - rails-8.1-collector + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *136 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/rails-8.1-collector.gemfile + ruby_3-2-5__resque-2_ubuntu-latest: + name: Ruby 3.2.5 - resque-2 + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &137 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/resque-2.gemfile + ruby_3-2-5__resque-2-collector_ubuntu-latest: + name: Ruby 3.2.5 - resque-2-collector + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *137 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/resque-2-collector.gemfile + ruby_3-2-5__resque-3_ubuntu-latest: + name: Ruby 3.2.5 - resque-3 + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &138 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/resque-3.gemfile + ruby_3-2-5__resque-3-collector_ubuntu-latest: + name: Ruby 3.2.5 - resque-3-collector + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *138 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/resque-3-collector.gemfile + ruby_3-2-5__sequel_ubuntu-latest: + name: Ruby 3.2.5 - sequel + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &139 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/sequel.gemfile + ruby_3-2-5__sequel-collector_ubuntu-latest: + name: Ruby 3.2.5 - sequel-collector + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *139 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/sequel-collector.gemfile + ruby_3-2-5__shoryuken-7_ubuntu-latest: + name: Ruby 3.2.5 - shoryuken-7 + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &140 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/shoryuken-7.gemfile + ruby_3-2-5__shoryuken-7-collector_ubuntu-latest: + name: Ruby 3.2.5 - shoryuken-7-collector + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *140 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/shoryuken-7-collector.gemfile + ruby_3-2-5__sinatra_ubuntu-latest: + name: Ruby 3.2.5 - sinatra + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &141 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/sinatra.gemfile + ruby_3-2-5__sinatra-collector_ubuntu-latest: + name: Ruby 3.2.5 - sinatra-collector + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *141 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/sinatra-collector.gemfile + ruby_3-2-5__webmachine2_ubuntu-latest: + name: Ruby 3.2.5 - webmachine2 + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &142 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/webmachine2.gemfile + ruby_3-2-5__webmachine2-collector_ubuntu-latest: + name: Ruby 3.2.5 - webmachine2-collector + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *142 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/webmachine2-collector.gemfile + ruby_3-2-5__redis-4_ubuntu-latest: + name: Ruby 3.2.5 - redis-4 + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &143 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/redis-4.gemfile + ruby_3-2-5__redis-4-collector_ubuntu-latest: + name: Ruby 3.2.5 - redis-4-collector + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *143 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/redis-4-collector.gemfile + ruby_3-2-5__redis-5_ubuntu-latest: + name: Ruby 3.2.5 - redis-5 + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &144 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/redis-5.gemfile + ruby_3-2-5__redis-5-collector_ubuntu-latest: + name: Ruby 3.2.5 - redis-5-collector + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *144 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/redis-5-collector.gemfile + ruby_3-2-5_macos-14: + name: Ruby 3.2.5 (macos-14) + needs: validation + runs-on: macos-14 + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - name: Run tests + run: "./script/bundler_wrapper exec rake test" + - name: Run tests without extension + run: "./script/bundler_wrapper exec rake test:failure" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/no_dependencies.gemfile + ruby_3-1-6_ubuntu-latest: + name: Ruby 3.1.6 + needs: validation + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.1.6 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &145 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + - name: Run tests without extension + run: "./script/bundler_wrapper exec rake test:failure" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/no_dependencies.gemfile + ruby_3-1-6__no_dependencies-collector_ubuntu-latest: + name: Ruby 3.1.6 - no_dependencies-collector + needs: ruby_3-1-6_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.1.6 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *145 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/no_dependencies-collector.gemfile + ruby_3-1-6__capistrano2_ubuntu-latest: + name: Ruby 3.1.6 - capistrano2 + needs: ruby_3-1-6_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.1.6 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &146 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/capistrano2.gemfile + ruby_3-1-6__capistrano2-collector_ubuntu-latest: + name: Ruby 3.1.6 - capistrano2-collector + needs: ruby_3-1-6_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.1.6 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *146 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/capistrano2-collector.gemfile + ruby_3-1-6__capistrano3_ubuntu-latest: + name: Ruby 3.1.6 - capistrano3 + needs: ruby_3-1-6_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.1.6 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &147 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/capistrano3.gemfile + ruby_3-1-6__capistrano3-collector_ubuntu-latest: + name: Ruby 3.1.6 - capistrano3-collector + needs: ruby_3-1-6_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.1.6 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *147 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/capistrano3-collector.gemfile + ruby_3-1-6__delayed_job_ubuntu-latest: + name: Ruby 3.1.6 - delayed_job + needs: ruby_3-1-6_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.1.6 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &148 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/delayed_job.gemfile + ruby_3-1-6__delayed_job-collector_ubuntu-latest: + name: Ruby 3.1.6 - delayed_job-collector + needs: ruby_3-1-6_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.1.6 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *148 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/delayed_job-collector.gemfile + ruby_3-1-6__dry-monitor_ubuntu-latest: + name: Ruby 3.1.6 - dry-monitor + needs: ruby_3-1-6_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.1.6 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &149 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/dry-monitor.gemfile + ruby_3-1-6__dry-monitor-collector_ubuntu-latest: + name: Ruby 3.1.6 - dry-monitor-collector + needs: ruby_3-1-6_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.1.6 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *149 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/dry-monitor-collector.gemfile + ruby_3-1-6__excon_ubuntu-latest: + name: Ruby 3.1.6 - excon + needs: ruby_3-1-6_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.1.6 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &150 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/excon.gemfile + ruby_3-1-6__excon-collector_ubuntu-latest: + name: Ruby 3.1.6 - excon-collector + needs: ruby_3-1-6_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.1.6 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *150 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/excon-collector.gemfile + ruby_3-1-6__faraday-1_ubuntu-latest: + name: Ruby 3.1.6 - faraday-1 + needs: ruby_3-1-6_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.1.6 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &151 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/faraday-1.gemfile + ruby_3-1-6__faraday-1-collector_ubuntu-latest: + name: Ruby 3.1.6 - faraday-1-collector + needs: ruby_3-1-6_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.1.6 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *151 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/faraday-1-collector.gemfile + ruby_3-1-6__faraday-2_ubuntu-latest: + name: Ruby 3.1.6 - faraday-2 + needs: ruby_3-1-6_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.1.6 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &152 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/faraday-2.gemfile + ruby_3-1-6__faraday-2-collector_ubuntu-latest: + name: Ruby 3.1.6 - faraday-2-collector + needs: ruby_3-1-6_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.1.6 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *152 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/faraday-2-collector.gemfile + ruby_3-1-6__grape_ubuntu-latest: + name: Ruby 3.1.6 - grape + needs: ruby_3-1-6_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.1.6 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &153 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/grape.gemfile + ruby_3-1-6__grape-collector_ubuntu-latest: + name: Ruby 3.1.6 - grape-collector + needs: ruby_3-1-6_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.1.6 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *153 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/grape-collector.gemfile + ruby_3-1-6__hanami-2-0_ubuntu-latest: + name: Ruby 3.1.6 - hanami-2.0 + needs: ruby_3-1-6_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.1.6 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &154 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/hanami-2.0.gemfile + ruby_3-1-6__hanami-2-0-collector_ubuntu-latest: + name: Ruby 3.1.6 - hanami-2.0-collector + needs: ruby_3-1-6_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.1.6 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *154 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/hanami-2.0-collector.gemfile + ruby_3-1-6__hanami-2-1_ubuntu-latest: + name: Ruby 3.1.6 - hanami-2.1 + needs: ruby_3-1-6_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.1.6 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &155 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/hanami-2.1.gemfile + ruby_3-1-6__hanami-2-1-collector_ubuntu-latest: + name: Ruby 3.1.6 - hanami-2.1-collector + needs: ruby_3-1-6_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.1.6 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *155 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/hanami-2.1-collector.gemfile + ruby_3-1-6__hanami-2-2_ubuntu-latest: + name: Ruby 3.1.6 - hanami-2.2 + needs: ruby_3-1-6_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.1.6 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &156 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/hanami-2.2.gemfile + ruby_3-1-6__hanami-2-2-collector_ubuntu-latest: + name: Ruby 3.1.6 - hanami-2.2-collector + needs: ruby_3-1-6_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.1.6 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *156 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/hanami-2.2-collector.gemfile + ruby_3-1-6__http5_ubuntu-latest: + name: Ruby 3.1.6 - http5 + needs: ruby_3-1-6_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.1.6 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &157 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/http5.gemfile + ruby_3-1-6__http5-collector_ubuntu-latest: + name: Ruby 3.1.6 - http5-collector + needs: ruby_3-1-6_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.1.6 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *157 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/http5-collector.gemfile + ruby_3-1-6__mongo_ubuntu-latest: + name: Ruby 3.1.6 - mongo + needs: ruby_3-1-6_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.1.6 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &158 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/mongo.gemfile + ruby_3-1-6__mongo-collector_ubuntu-latest: + name: Ruby 3.1.6 - mongo-collector + needs: ruby_3-1-6_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.1.6 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *158 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/mongo-collector.gemfile + ruby_3-1-6__ownership_ubuntu-latest: + name: Ruby 3.1.6 - ownership + needs: ruby_3-1-6_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -3989,7 +8772,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.2.5 + ruby-version: 3.1.6 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -3999,16 +8782,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &159 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/sinatra.gemfile - ruby_3-2-5__webmachine2_ubuntu-latest: - name: Ruby 3.2.5 - webmachine2 - needs: ruby_3-2-5_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/ownership.gemfile + ruby_3-1-6__ownership-collector_ubuntu-latest: + name: Ruby 3.1.6 - ownership-collector + needs: ruby_3-1-6_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -4016,7 +8800,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.2.5 + ruby-version: 3.1.6 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -4026,16 +8810,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *159 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/webmachine2.gemfile - ruby_3-2-5__redis-4_ubuntu-latest: - name: Ruby 3.2.5 - redis-4 - needs: ruby_3-2-5_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/ownership-collector.gemfile + ruby_3-1-6__padrino_ubuntu-latest: + name: Ruby 3.1.6 - padrino + needs: ruby_3-1-6_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -4043,7 +8826,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.2.5 + ruby-version: 3.1.6 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -4053,16 +8836,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &160 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/redis-4.gemfile - ruby_3-2-5__redis-5_ubuntu-latest: - name: Ruby 3.2.5 - redis-5 - needs: ruby_3-2-5_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/padrino.gemfile + ruby_3-1-6__padrino-collector_ubuntu-latest: + name: Ruby 3.1.6 - padrino-collector + needs: ruby_3-1-6_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -4070,7 +8854,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.2.5 + ruby-version: 3.1.6 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -4080,24 +8864,23 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *160 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/redis-5.gemfile - ruby_3-2-5_macos-14: - name: Ruby 3.2.5 (macos-14) - needs: validation - runs-on: macos-14 + BUNDLE_GEMFILE: gemfiles/padrino-collector.gemfile + ruby_3-1-6__psych-3_ubuntu-latest: + name: Ruby 3.1.6 - psych-3 + needs: ruby_3-1-6_ubuntu-latest + runs-on: ubuntu-latest steps: - name: Check out repository uses: actions/checkout@v4 - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.2.5 + ruby-version: 3.1.6 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -4107,18 +8890,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &161 + name: Run tests run: "./script/bundler_wrapper exec rake test" - - name: Run tests without extension - run: "./script/bundler_wrapper exec rake test:failure" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/no_dependencies.gemfile - ruby_3-1-6_ubuntu-latest: - name: Ruby 3.1.6 - needs: validation + BUNDLE_GEMFILE: gemfiles/psych-3.gemfile + ruby_3-1-6__psych-3-collector_ubuntu-latest: + name: Ruby 3.1.6 - psych-3-collector + needs: ruby_3-1-6_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -4136,17 +8918,14 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" - - name: Run tests without extension - run: "./script/bundler_wrapper exec rake test:failure" + - *161 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/no_dependencies.gemfile - ruby_3-1-6__capistrano2_ubuntu-latest: - name: Ruby 3.1.6 - capistrano2 + BUNDLE_GEMFILE: gemfiles/psych-3-collector.gemfile + ruby_3-1-6__psych-4_ubuntu-latest: + name: Ruby 3.1.6 - psych-4 needs: ruby_3-1-6_ubuntu-latest runs-on: ubuntu-latest steps: @@ -4165,15 +8944,16 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &162 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/capistrano2.gemfile - ruby_3-1-6__capistrano3_ubuntu-latest: - name: Ruby 3.1.6 - capistrano3 + BUNDLE_GEMFILE: gemfiles/psych-4.gemfile + ruby_3-1-6__psych-4-collector_ubuntu-latest: + name: Ruby 3.1.6 - psych-4-collector needs: ruby_3-1-6_ubuntu-latest runs-on: ubuntu-latest steps: @@ -4192,15 +8972,14 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *162 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/capistrano3.gemfile - ruby_3-1-6__delayed_job_ubuntu-latest: - name: Ruby 3.1.6 - delayed_job + BUNDLE_GEMFILE: gemfiles/psych-4-collector.gemfile + ruby_3-1-6__que-0-14_ubuntu-latest: + name: Ruby 3.1.6 - que-0.14 needs: ruby_3-1-6_ubuntu-latest runs-on: ubuntu-latest steps: @@ -4219,15 +8998,16 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &163 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/delayed_job.gemfile - ruby_3-1-6__dry-monitor_ubuntu-latest: - name: Ruby 3.1.6 - dry-monitor + BUNDLE_GEMFILE: gemfiles/que-0.14.gemfile + ruby_3-1-6__que-0-14-collector_ubuntu-latest: + name: Ruby 3.1.6 - que-0.14-collector needs: ruby_3-1-6_ubuntu-latest runs-on: ubuntu-latest steps: @@ -4246,15 +9026,14 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *163 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/dry-monitor.gemfile - ruby_3-1-6__excon_ubuntu-latest: - name: Ruby 3.1.6 - excon + BUNDLE_GEMFILE: gemfiles/que-0.14-collector.gemfile + ruby_3-1-6__que-1_ubuntu-latest: + name: Ruby 3.1.6 - que-1 needs: ruby_3-1-6_ubuntu-latest runs-on: ubuntu-latest steps: @@ -4273,15 +9052,16 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &164 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/excon.gemfile - ruby_3-1-6__faraday-1_ubuntu-latest: - name: Ruby 3.1.6 - faraday-1 + BUNDLE_GEMFILE: gemfiles/que-1.gemfile + ruby_3-1-6__que-1-collector_ubuntu-latest: + name: Ruby 3.1.6 - que-1-collector needs: ruby_3-1-6_ubuntu-latest runs-on: ubuntu-latest steps: @@ -4300,15 +9080,14 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *164 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/faraday-1.gemfile - ruby_3-1-6__faraday-2_ubuntu-latest: - name: Ruby 3.1.6 - faraday-2 + BUNDLE_GEMFILE: gemfiles/que-1-collector.gemfile + ruby_3-1-6__que-2_ubuntu-latest: + name: Ruby 3.1.6 - que-2 needs: ruby_3-1-6_ubuntu-latest runs-on: ubuntu-latest steps: @@ -4327,15 +9106,16 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &165 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/faraday-2.gemfile - ruby_3-1-6__grape_ubuntu-latest: - name: Ruby 3.1.6 - grape + BUNDLE_GEMFILE: gemfiles/que-2.gemfile + ruby_3-1-6__que-2-collector_ubuntu-latest: + name: Ruby 3.1.6 - que-2-collector needs: ruby_3-1-6_ubuntu-latest runs-on: ubuntu-latest steps: @@ -4354,15 +9134,14 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *165 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/grape.gemfile - ruby_3-1-6__hanami-2-0_ubuntu-latest: - name: Ruby 3.1.6 - hanami-2.0 + BUNDLE_GEMFILE: gemfiles/que-2-collector.gemfile + ruby_3-1-6__rails-6-1_ubuntu-latest: + name: Ruby 3.1.6 - rails-6.1 needs: ruby_3-1-6_ubuntu-latest runs-on: ubuntu-latest steps: @@ -4381,15 +9160,16 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &166 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/hanami-2.0.gemfile - ruby_3-1-6__hanami-2-1_ubuntu-latest: - name: Ruby 3.1.6 - hanami-2.1 + BUNDLE_GEMFILE: gemfiles/rails-6.1.gemfile + ruby_3-1-6__rails-6-1-collector_ubuntu-latest: + name: Ruby 3.1.6 - rails-6.1-collector needs: ruby_3-1-6_ubuntu-latest runs-on: ubuntu-latest steps: @@ -4408,15 +9188,14 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *166 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/hanami-2.1.gemfile - ruby_3-1-6__hanami-2-2_ubuntu-latest: - name: Ruby 3.1.6 - hanami-2.2 + BUNDLE_GEMFILE: gemfiles/rails-6.1-collector.gemfile + ruby_3-1-6__rails-7-0_ubuntu-latest: + name: Ruby 3.1.6 - rails-7.0 needs: ruby_3-1-6_ubuntu-latest runs-on: ubuntu-latest steps: @@ -4435,15 +9214,16 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &167 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/hanami-2.2.gemfile - ruby_3-1-6__http5_ubuntu-latest: - name: Ruby 3.1.6 - http5 + BUNDLE_GEMFILE: gemfiles/rails-7.0.gemfile + ruby_3-1-6__rails-7-0-collector_ubuntu-latest: + name: Ruby 3.1.6 - rails-7.0-collector needs: ruby_3-1-6_ubuntu-latest runs-on: ubuntu-latest steps: @@ -4462,15 +9242,14 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *167 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/http5.gemfile - ruby_3-1-6__mongo_ubuntu-latest: - name: Ruby 3.1.6 - mongo + BUNDLE_GEMFILE: gemfiles/rails-7.0-collector.gemfile + ruby_3-1-6__rails-7-1_ubuntu-latest: + name: Ruby 3.1.6 - rails-7.1 needs: ruby_3-1-6_ubuntu-latest runs-on: ubuntu-latest steps: @@ -4489,15 +9268,16 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &168 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/mongo.gemfile - ruby_3-1-6__ownership_ubuntu-latest: - name: Ruby 3.1.6 - ownership + BUNDLE_GEMFILE: gemfiles/rails-7.1.gemfile + ruby_3-1-6__rails-7-1-collector_ubuntu-latest: + name: Ruby 3.1.6 - rails-7.1-collector needs: ruby_3-1-6_ubuntu-latest runs-on: ubuntu-latest steps: @@ -4516,15 +9296,14 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *168 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/ownership.gemfile - ruby_3-1-6__padrino_ubuntu-latest: - name: Ruby 3.1.6 - padrino + BUNDLE_GEMFILE: gemfiles/rails-7.1-collector.gemfile + ruby_3-1-6__rails-7-2_ubuntu-latest: + name: Ruby 3.1.6 - rails-7.2 needs: ruby_3-1-6_ubuntu-latest runs-on: ubuntu-latest steps: @@ -4543,15 +9322,16 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &169 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/padrino.gemfile - ruby_3-1-6__psych-3_ubuntu-latest: - name: Ruby 3.1.6 - psych-3 + BUNDLE_GEMFILE: gemfiles/rails-7.2.gemfile + ruby_3-1-6__rails-7-2-collector_ubuntu-latest: + name: Ruby 3.1.6 - rails-7.2-collector needs: ruby_3-1-6_ubuntu-latest runs-on: ubuntu-latest steps: @@ -4570,15 +9350,14 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *169 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/psych-3.gemfile - ruby_3-1-6__psych-4_ubuntu-latest: - name: Ruby 3.1.6 - psych-4 + BUNDLE_GEMFILE: gemfiles/rails-7.2-collector.gemfile + ruby_3-1-6__resque-2_ubuntu-latest: + name: Ruby 3.1.6 - resque-2 needs: ruby_3-1-6_ubuntu-latest runs-on: ubuntu-latest steps: @@ -4597,15 +9376,16 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &170 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/psych-4.gemfile - ruby_3-1-6__que-0-14_ubuntu-latest: - name: Ruby 3.1.6 - que-0.14 + BUNDLE_GEMFILE: gemfiles/resque-2.gemfile + ruby_3-1-6__resque-2-collector_ubuntu-latest: + name: Ruby 3.1.6 - resque-2-collector needs: ruby_3-1-6_ubuntu-latest runs-on: ubuntu-latest steps: @@ -4624,15 +9404,14 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *170 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/que-0.14.gemfile - ruby_3-1-6__que-1_ubuntu-latest: - name: Ruby 3.1.6 - que-1 + BUNDLE_GEMFILE: gemfiles/resque-2-collector.gemfile + ruby_3-1-6__resque-3_ubuntu-latest: + name: Ruby 3.1.6 - resque-3 needs: ruby_3-1-6_ubuntu-latest runs-on: ubuntu-latest steps: @@ -4651,15 +9430,16 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &171 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/que-1.gemfile - ruby_3-1-6__que-2_ubuntu-latest: - name: Ruby 3.1.6 - que-2 + BUNDLE_GEMFILE: gemfiles/resque-3.gemfile + ruby_3-1-6__resque-3-collector_ubuntu-latest: + name: Ruby 3.1.6 - resque-3-collector needs: ruby_3-1-6_ubuntu-latest runs-on: ubuntu-latest steps: @@ -4678,15 +9458,14 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *171 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/que-2.gemfile - ruby_3-1-6__rails-6-1_ubuntu-latest: - name: Ruby 3.1.6 - rails-6.1 + BUNDLE_GEMFILE: gemfiles/resque-3-collector.gemfile + ruby_3-1-6__sequel_ubuntu-latest: + name: Ruby 3.1.6 - sequel needs: ruby_3-1-6_ubuntu-latest runs-on: ubuntu-latest steps: @@ -4705,15 +9484,16 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &172 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/rails-6.1.gemfile - ruby_3-1-6__rails-7-0_ubuntu-latest: - name: Ruby 3.1.6 - rails-7.0 + BUNDLE_GEMFILE: gemfiles/sequel.gemfile + ruby_3-1-6__sequel-collector_ubuntu-latest: + name: Ruby 3.1.6 - sequel-collector needs: ruby_3-1-6_ubuntu-latest runs-on: ubuntu-latest steps: @@ -4732,15 +9512,14 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *172 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/rails-7.0.gemfile - ruby_3-1-6__rails-7-1_ubuntu-latest: - name: Ruby 3.1.6 - rails-7.1 + BUNDLE_GEMFILE: gemfiles/sequel-collector.gemfile + ruby_3-1-6__shoryuken-6_ubuntu-latest: + name: Ruby 3.1.6 - shoryuken-6 needs: ruby_3-1-6_ubuntu-latest runs-on: ubuntu-latest steps: @@ -4759,15 +9538,16 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &173 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/rails-7.1.gemfile - ruby_3-1-6__rails-7-2_ubuntu-latest: - name: Ruby 3.1.6 - rails-7.2 + BUNDLE_GEMFILE: gemfiles/shoryuken-6.gemfile + ruby_3-1-6__shoryuken-6-collector_ubuntu-latest: + name: Ruby 3.1.6 - shoryuken-6-collector needs: ruby_3-1-6_ubuntu-latest runs-on: ubuntu-latest steps: @@ -4786,15 +9566,14 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *173 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/rails-7.2.gemfile - ruby_3-1-6__resque-2_ubuntu-latest: - name: Ruby 3.1.6 - resque-2 + BUNDLE_GEMFILE: gemfiles/shoryuken-6-collector.gemfile + ruby_3-1-6__sinatra_ubuntu-latest: + name: Ruby 3.1.6 - sinatra needs: ruby_3-1-6_ubuntu-latest runs-on: ubuntu-latest steps: @@ -4813,15 +9592,16 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &174 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/resque-2.gemfile - ruby_3-1-6__resque-3_ubuntu-latest: - name: Ruby 3.1.6 - resque-3 + BUNDLE_GEMFILE: gemfiles/sinatra.gemfile + ruby_3-1-6__sinatra-collector_ubuntu-latest: + name: Ruby 3.1.6 - sinatra-collector needs: ruby_3-1-6_ubuntu-latest runs-on: ubuntu-latest steps: @@ -4840,15 +9620,14 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *174 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/resque-3.gemfile - ruby_3-1-6__sequel_ubuntu-latest: - name: Ruby 3.1.6 - sequel + BUNDLE_GEMFILE: gemfiles/sinatra-collector.gemfile + ruby_3-1-6__webmachine2_ubuntu-latest: + name: Ruby 3.1.6 - webmachine2 needs: ruby_3-1-6_ubuntu-latest runs-on: ubuntu-latest steps: @@ -4867,15 +9646,16 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &175 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/sequel.gemfile - ruby_3-1-6__shoryuken-6_ubuntu-latest: - name: Ruby 3.1.6 - shoryuken-6 + BUNDLE_GEMFILE: gemfiles/webmachine2.gemfile + ruby_3-1-6__webmachine2-collector_ubuntu-latest: + name: Ruby 3.1.6 - webmachine2-collector needs: ruby_3-1-6_ubuntu-latest runs-on: ubuntu-latest steps: @@ -4894,15 +9674,14 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *175 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/shoryuken-6.gemfile - ruby_3-1-6__sinatra_ubuntu-latest: - name: Ruby 3.1.6 - sinatra + BUNDLE_GEMFILE: gemfiles/webmachine2-collector.gemfile + ruby_3-1-6__redis-4_ubuntu-latest: + name: Ruby 3.1.6 - redis-4 needs: ruby_3-1-6_ubuntu-latest runs-on: ubuntu-latest steps: @@ -4921,15 +9700,16 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &176 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/sinatra.gemfile - ruby_3-1-6__webmachine2_ubuntu-latest: - name: Ruby 3.1.6 - webmachine2 + BUNDLE_GEMFILE: gemfiles/redis-4.gemfile + ruby_3-1-6__redis-4-collector_ubuntu-latest: + name: Ruby 3.1.6 - redis-4-collector needs: ruby_3-1-6_ubuntu-latest runs-on: ubuntu-latest steps: @@ -4948,15 +9728,14 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *176 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/webmachine2.gemfile - ruby_3-1-6__redis-4_ubuntu-latest: - name: Ruby 3.1.6 - redis-4 + BUNDLE_GEMFILE: gemfiles/redis-4-collector.gemfile + ruby_3-1-6__redis-5_ubuntu-latest: + name: Ruby 3.1.6 - redis-5 needs: ruby_3-1-6_ubuntu-latest runs-on: ubuntu-latest steps: @@ -4975,15 +9754,16 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &177 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/redis-4.gemfile - ruby_3-1-6__redis-5_ubuntu-latest: - name: Ruby 3.1.6 - redis-5 + BUNDLE_GEMFILE: gemfiles/redis-5.gemfile + ruby_3-1-6__redis-5-collector_ubuntu-latest: + name: Ruby 3.1.6 - redis-5-collector needs: ruby_3-1-6_ubuntu-latest runs-on: ubuntu-latest steps: @@ -5002,13 +9782,12 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *177 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/redis-5.gemfile + BUNDLE_GEMFILE: gemfiles/redis-5-collector.gemfile ruby_3-1-6_macos-14: name: Ruby 3.1.6 (macos-14) needs: validation diff --git a/Rakefile b/Rakefile index 021e7cf76..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 f84f04458..363a2f370 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" 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/faraday-1-collector.gemfile b/gemfiles/faraday-1-collector.gemfile new file mode 100644 index 000000000..252188545 --- /dev/null +++ b/gemfiles/faraday-1-collector.gemfile @@ -0,0 +1,6 @@ +# DO NOT EDIT +# This is a generated file by the `rake build_matrix:gemfiles:generate` task. +# It layers the optional OpenTelemetry gems (gemfiles/collector.rb) on top of faraday-1.gemfile. + +eval_gemfile File.expand_path("faraday-1.gemfile", __dir__) +eval_gemfile File.expand_path("collector.rb", __dir__) diff --git a/gemfiles/faraday-2-collector.gemfile b/gemfiles/faraday-2-collector.gemfile new file mode 100644 index 000000000..4af7281ed --- /dev/null +++ b/gemfiles/faraday-2-collector.gemfile @@ -0,0 +1,6 @@ +# DO NOT EDIT +# This is a generated file by the `rake build_matrix:gemfiles:generate` task. +# It layers the optional OpenTelemetry gems (gemfiles/collector.rb) on top of faraday-2.gemfile. + +eval_gemfile File.expand_path("faraday-2.gemfile", __dir__) +eval_gemfile File.expand_path("collector.rb", __dir__) diff --git a/gemfiles/grape-collector.gemfile b/gemfiles/grape-collector.gemfile new file mode 100644 index 000000000..84298ef55 --- /dev/null +++ b/gemfiles/grape-collector.gemfile @@ -0,0 +1,6 @@ +# DO NOT EDIT +# This is a generated file by the `rake build_matrix:gemfiles:generate` task. +# It layers the optional OpenTelemetry gems (gemfiles/collector.rb) on top of grape.gemfile. + +eval_gemfile File.expand_path("grape.gemfile", __dir__) +eval_gemfile File.expand_path("collector.rb", __dir__) diff --git a/gemfiles/hanami-2.0-collector.gemfile b/gemfiles/hanami-2.0-collector.gemfile new file mode 100644 index 000000000..61d138330 --- /dev/null +++ b/gemfiles/hanami-2.0-collector.gemfile @@ -0,0 +1,6 @@ +# DO NOT EDIT +# This is a generated file by the `rake build_matrix:gemfiles:generate` task. +# It layers the optional OpenTelemetry gems (gemfiles/collector.rb) on top of hanami-2.0.gemfile. + +eval_gemfile File.expand_path("hanami-2.0.gemfile", __dir__) +eval_gemfile File.expand_path("collector.rb", __dir__) diff --git a/gemfiles/hanami-2.1-collector.gemfile b/gemfiles/hanami-2.1-collector.gemfile new file mode 100644 index 000000000..eae8eadfe --- /dev/null +++ b/gemfiles/hanami-2.1-collector.gemfile @@ -0,0 +1,6 @@ +# DO NOT EDIT +# This is a generated file by the `rake build_matrix:gemfiles:generate` task. +# It layers the optional OpenTelemetry gems (gemfiles/collector.rb) on top of hanami-2.1.gemfile. + +eval_gemfile File.expand_path("hanami-2.1.gemfile", __dir__) +eval_gemfile File.expand_path("collector.rb", __dir__) diff --git a/gemfiles/hanami-2.2-collector.gemfile b/gemfiles/hanami-2.2-collector.gemfile new file mode 100644 index 000000000..2dbec63ea --- /dev/null +++ b/gemfiles/hanami-2.2-collector.gemfile @@ -0,0 +1,6 @@ +# DO NOT EDIT +# This is a generated file by the `rake build_matrix:gemfiles:generate` task. +# It layers the optional OpenTelemetry gems (gemfiles/collector.rb) on top of hanami-2.2.gemfile. + +eval_gemfile File.expand_path("hanami-2.2.gemfile", __dir__) +eval_gemfile File.expand_path("collector.rb", __dir__) diff --git a/gemfiles/http5-collector.gemfile b/gemfiles/http5-collector.gemfile new file mode 100644 index 000000000..5e72919bf --- /dev/null +++ b/gemfiles/http5-collector.gemfile @@ -0,0 +1,6 @@ +# DO NOT EDIT +# This is a generated file by the `rake build_matrix:gemfiles:generate` task. +# It layers the optional OpenTelemetry gems (gemfiles/collector.rb) on top of http5.gemfile. + +eval_gemfile File.expand_path("http5.gemfile", __dir__) +eval_gemfile File.expand_path("collector.rb", __dir__) diff --git a/gemfiles/http6-collector.gemfile b/gemfiles/http6-collector.gemfile new file mode 100644 index 000000000..eda44c063 --- /dev/null +++ b/gemfiles/http6-collector.gemfile @@ -0,0 +1,6 @@ +# DO NOT EDIT +# This is a generated file by the `rake build_matrix:gemfiles:generate` task. +# It layers the optional OpenTelemetry gems (gemfiles/collector.rb) on top of http6.gemfile. + +eval_gemfile File.expand_path("http6.gemfile", __dir__) +eval_gemfile File.expand_path("collector.rb", __dir__) diff --git a/gemfiles/mongo-collector.gemfile b/gemfiles/mongo-collector.gemfile new file mode 100644 index 000000000..b1cbafcf6 --- /dev/null +++ b/gemfiles/mongo-collector.gemfile @@ -0,0 +1,6 @@ +# DO NOT EDIT +# This is a generated file by the `rake build_matrix:gemfiles:generate` task. +# It layers the optional OpenTelemetry gems (gemfiles/collector.rb) on top of mongo.gemfile. + +eval_gemfile File.expand_path("mongo.gemfile", __dir__) +eval_gemfile File.expand_path("collector.rb", __dir__) diff --git a/gemfiles/no_dependencies-collector.gemfile b/gemfiles/no_dependencies-collector.gemfile new file mode 100644 index 000000000..04f8ff2ba --- /dev/null +++ b/gemfiles/no_dependencies-collector.gemfile @@ -0,0 +1,6 @@ +# DO NOT EDIT +# This is a generated file by the `rake build_matrix:gemfiles:generate` task. +# It layers the optional OpenTelemetry gems (gemfiles/collector.rb) on top of no_dependencies.gemfile. + +eval_gemfile File.expand_path("no_dependencies.gemfile", __dir__) +eval_gemfile File.expand_path("collector.rb", __dir__) diff --git a/gemfiles/ownership-collector.gemfile b/gemfiles/ownership-collector.gemfile new file mode 100644 index 000000000..964401c18 --- /dev/null +++ b/gemfiles/ownership-collector.gemfile @@ -0,0 +1,6 @@ +# DO NOT EDIT +# This is a generated file by the `rake build_matrix:gemfiles:generate` task. +# It layers the optional OpenTelemetry gems (gemfiles/collector.rb) on top of ownership.gemfile. + +eval_gemfile File.expand_path("ownership.gemfile", __dir__) +eval_gemfile File.expand_path("collector.rb", __dir__) diff --git a/gemfiles/padrino-collector.gemfile b/gemfiles/padrino-collector.gemfile new file mode 100644 index 000000000..8c04046d8 --- /dev/null +++ b/gemfiles/padrino-collector.gemfile @@ -0,0 +1,6 @@ +# DO NOT EDIT +# This is a generated file by the `rake build_matrix:gemfiles:generate` task. +# It layers the optional OpenTelemetry gems (gemfiles/collector.rb) on top of padrino.gemfile. + +eval_gemfile File.expand_path("padrino.gemfile", __dir__) +eval_gemfile File.expand_path("collector.rb", __dir__) diff --git a/gemfiles/psych-3-collector.gemfile b/gemfiles/psych-3-collector.gemfile new file mode 100644 index 000000000..570c8704c --- /dev/null +++ b/gemfiles/psych-3-collector.gemfile @@ -0,0 +1,6 @@ +# DO NOT EDIT +# This is a generated file by the `rake build_matrix:gemfiles:generate` task. +# It layers the optional OpenTelemetry gems (gemfiles/collector.rb) on top of psych-3.gemfile. + +eval_gemfile File.expand_path("psych-3.gemfile", __dir__) +eval_gemfile File.expand_path("collector.rb", __dir__) diff --git a/gemfiles/psych-4-collector.gemfile b/gemfiles/psych-4-collector.gemfile new file mode 100644 index 000000000..55546c9e4 --- /dev/null +++ b/gemfiles/psych-4-collector.gemfile @@ -0,0 +1,6 @@ +# DO NOT EDIT +# This is a generated file by the `rake build_matrix:gemfiles:generate` task. +# It layers the optional OpenTelemetry gems (gemfiles/collector.rb) on top of psych-4.gemfile. + +eval_gemfile File.expand_path("psych-4.gemfile", __dir__) +eval_gemfile File.expand_path("collector.rb", __dir__) diff --git a/gemfiles/que-0.14-collector.gemfile b/gemfiles/que-0.14-collector.gemfile new file mode 100644 index 000000000..825bceab2 --- /dev/null +++ b/gemfiles/que-0.14-collector.gemfile @@ -0,0 +1,6 @@ +# DO NOT EDIT +# This is a generated file by the `rake build_matrix:gemfiles:generate` task. +# It layers the optional OpenTelemetry gems (gemfiles/collector.rb) on top of que-0.14.gemfile. + +eval_gemfile File.expand_path("que-0.14.gemfile", __dir__) +eval_gemfile File.expand_path("collector.rb", __dir__) diff --git a/gemfiles/que-1-collector.gemfile b/gemfiles/que-1-collector.gemfile new file mode 100644 index 000000000..df75ac571 --- /dev/null +++ b/gemfiles/que-1-collector.gemfile @@ -0,0 +1,6 @@ +# DO NOT EDIT +# This is a generated file by the `rake build_matrix:gemfiles:generate` task. +# It layers the optional OpenTelemetry gems (gemfiles/collector.rb) on top of que-1.gemfile. + +eval_gemfile File.expand_path("que-1.gemfile", __dir__) +eval_gemfile File.expand_path("collector.rb", __dir__) diff --git a/gemfiles/que-2-collector.gemfile b/gemfiles/que-2-collector.gemfile new file mode 100644 index 000000000..ed1b038b1 --- /dev/null +++ b/gemfiles/que-2-collector.gemfile @@ -0,0 +1,6 @@ +# DO NOT EDIT +# This is a generated file by the `rake build_matrix:gemfiles:generate` task. +# It layers the optional OpenTelemetry gems (gemfiles/collector.rb) on top of que-2.gemfile. + +eval_gemfile File.expand_path("que-2.gemfile", __dir__) +eval_gemfile File.expand_path("collector.rb", __dir__) diff --git a/gemfiles/rails-6.0-collector.gemfile b/gemfiles/rails-6.0-collector.gemfile new file mode 100644 index 000000000..5e4a313e9 --- /dev/null +++ b/gemfiles/rails-6.0-collector.gemfile @@ -0,0 +1,6 @@ +# DO NOT EDIT +# This is a generated file by the `rake build_matrix:gemfiles:generate` task. +# It layers the optional OpenTelemetry gems (gemfiles/collector.rb) on top of rails-6.0.gemfile. + +eval_gemfile File.expand_path("rails-6.0.gemfile", __dir__) +eval_gemfile File.expand_path("collector.rb", __dir__) diff --git a/gemfiles/rails-6.1-collector.gemfile b/gemfiles/rails-6.1-collector.gemfile new file mode 100644 index 000000000..b690ee546 --- /dev/null +++ b/gemfiles/rails-6.1-collector.gemfile @@ -0,0 +1,6 @@ +# DO NOT EDIT +# This is a generated file by the `rake build_matrix:gemfiles:generate` task. +# It layers the optional OpenTelemetry gems (gemfiles/collector.rb) on top of rails-6.1.gemfile. + +eval_gemfile File.expand_path("rails-6.1.gemfile", __dir__) +eval_gemfile File.expand_path("collector.rb", __dir__) diff --git a/gemfiles/rails-7.0-collector.gemfile b/gemfiles/rails-7.0-collector.gemfile new file mode 100644 index 000000000..63b692381 --- /dev/null +++ b/gemfiles/rails-7.0-collector.gemfile @@ -0,0 +1,6 @@ +# DO NOT EDIT +# This is a generated file by the `rake build_matrix:gemfiles:generate` task. +# It layers the optional OpenTelemetry gems (gemfiles/collector.rb) on top of rails-7.0.gemfile. + +eval_gemfile File.expand_path("rails-7.0.gemfile", __dir__) +eval_gemfile File.expand_path("collector.rb", __dir__) diff --git a/gemfiles/rails-7.1-collector.gemfile b/gemfiles/rails-7.1-collector.gemfile new file mode 100644 index 000000000..733c2dba5 --- /dev/null +++ b/gemfiles/rails-7.1-collector.gemfile @@ -0,0 +1,6 @@ +# DO NOT EDIT +# This is a generated file by the `rake build_matrix:gemfiles:generate` task. +# It layers the optional OpenTelemetry gems (gemfiles/collector.rb) on top of rails-7.1.gemfile. + +eval_gemfile File.expand_path("rails-7.1.gemfile", __dir__) +eval_gemfile File.expand_path("collector.rb", __dir__) diff --git a/gemfiles/rails-7.2-collector.gemfile b/gemfiles/rails-7.2-collector.gemfile new file mode 100644 index 000000000..458c31cf4 --- /dev/null +++ b/gemfiles/rails-7.2-collector.gemfile @@ -0,0 +1,6 @@ +# DO NOT EDIT +# This is a generated file by the `rake build_matrix:gemfiles:generate` task. +# It layers the optional OpenTelemetry gems (gemfiles/collector.rb) on top of rails-7.2.gemfile. + +eval_gemfile File.expand_path("rails-7.2.gemfile", __dir__) +eval_gemfile File.expand_path("collector.rb", __dir__) diff --git a/gemfiles/rails-8.0-collector.gemfile b/gemfiles/rails-8.0-collector.gemfile new file mode 100644 index 000000000..7ea545dc3 --- /dev/null +++ b/gemfiles/rails-8.0-collector.gemfile @@ -0,0 +1,6 @@ +# DO NOT EDIT +# This is a generated file by the `rake build_matrix:gemfiles:generate` task. +# It layers the optional OpenTelemetry gems (gemfiles/collector.rb) on top of rails-8.0.gemfile. + +eval_gemfile File.expand_path("rails-8.0.gemfile", __dir__) +eval_gemfile File.expand_path("collector.rb", __dir__) diff --git a/gemfiles/rails-8.1-collector.gemfile b/gemfiles/rails-8.1-collector.gemfile new file mode 100644 index 000000000..2d67888a2 --- /dev/null +++ b/gemfiles/rails-8.1-collector.gemfile @@ -0,0 +1,6 @@ +# DO NOT EDIT +# This is a generated file by the `rake build_matrix:gemfiles:generate` task. +# It layers the optional OpenTelemetry gems (gemfiles/collector.rb) on top of rails-8.1.gemfile. + +eval_gemfile File.expand_path("rails-8.1.gemfile", __dir__) +eval_gemfile File.expand_path("collector.rb", __dir__) diff --git a/gemfiles/redis-4-collector.gemfile b/gemfiles/redis-4-collector.gemfile new file mode 100644 index 000000000..2e5d42742 --- /dev/null +++ b/gemfiles/redis-4-collector.gemfile @@ -0,0 +1,6 @@ +# DO NOT EDIT +# This is a generated file by the `rake build_matrix:gemfiles:generate` task. +# It layers the optional OpenTelemetry gems (gemfiles/collector.rb) on top of redis-4.gemfile. + +eval_gemfile File.expand_path("redis-4.gemfile", __dir__) +eval_gemfile File.expand_path("collector.rb", __dir__) diff --git a/gemfiles/redis-5-collector.gemfile b/gemfiles/redis-5-collector.gemfile new file mode 100644 index 000000000..dfeb0015a --- /dev/null +++ b/gemfiles/redis-5-collector.gemfile @@ -0,0 +1,6 @@ +# DO NOT EDIT +# This is a generated file by the `rake build_matrix:gemfiles:generate` task. +# It layers the optional OpenTelemetry gems (gemfiles/collector.rb) on top of redis-5.gemfile. + +eval_gemfile File.expand_path("redis-5.gemfile", __dir__) +eval_gemfile File.expand_path("collector.rb", __dir__) diff --git a/gemfiles/resque-2-collector.gemfile b/gemfiles/resque-2-collector.gemfile new file mode 100644 index 000000000..49486175a --- /dev/null +++ b/gemfiles/resque-2-collector.gemfile @@ -0,0 +1,6 @@ +# DO NOT EDIT +# This is a generated file by the `rake build_matrix:gemfiles:generate` task. +# It layers the optional OpenTelemetry gems (gemfiles/collector.rb) on top of resque-2.gemfile. + +eval_gemfile File.expand_path("resque-2.gemfile", __dir__) +eval_gemfile File.expand_path("collector.rb", __dir__) diff --git a/gemfiles/resque-3-collector.gemfile b/gemfiles/resque-3-collector.gemfile new file mode 100644 index 000000000..f88745316 --- /dev/null +++ b/gemfiles/resque-3-collector.gemfile @@ -0,0 +1,6 @@ +# DO NOT EDIT +# This is a generated file by the `rake build_matrix:gemfiles:generate` task. +# It layers the optional OpenTelemetry gems (gemfiles/collector.rb) on top of resque-3.gemfile. + +eval_gemfile File.expand_path("resque-3.gemfile", __dir__) +eval_gemfile File.expand_path("collector.rb", __dir__) diff --git a/gemfiles/sequel-collector.gemfile b/gemfiles/sequel-collector.gemfile new file mode 100644 index 000000000..884a85b96 --- /dev/null +++ b/gemfiles/sequel-collector.gemfile @@ -0,0 +1,6 @@ +# DO NOT EDIT +# This is a generated file by the `rake build_matrix:gemfiles:generate` task. +# It layers the optional OpenTelemetry gems (gemfiles/collector.rb) on top of sequel.gemfile. + +eval_gemfile File.expand_path("sequel.gemfile", __dir__) +eval_gemfile File.expand_path("collector.rb", __dir__) diff --git a/gemfiles/shoryuken-6-collector.gemfile b/gemfiles/shoryuken-6-collector.gemfile new file mode 100644 index 000000000..cfb5bb853 --- /dev/null +++ b/gemfiles/shoryuken-6-collector.gemfile @@ -0,0 +1,6 @@ +# DO NOT EDIT +# This is a generated file by the `rake build_matrix:gemfiles:generate` task. +# It layers the optional OpenTelemetry gems (gemfiles/collector.rb) on top of shoryuken-6.gemfile. + +eval_gemfile File.expand_path("shoryuken-6.gemfile", __dir__) +eval_gemfile File.expand_path("collector.rb", __dir__) diff --git a/gemfiles/shoryuken-7-collector.gemfile b/gemfiles/shoryuken-7-collector.gemfile new file mode 100644 index 000000000..92a40d573 --- /dev/null +++ b/gemfiles/shoryuken-7-collector.gemfile @@ -0,0 +1,6 @@ +# DO NOT EDIT +# This is a generated file by the `rake build_matrix:gemfiles:generate` task. +# It layers the optional OpenTelemetry gems (gemfiles/collector.rb) on top of shoryuken-7.gemfile. + +eval_gemfile File.expand_path("shoryuken-7.gemfile", __dir__) +eval_gemfile File.expand_path("collector.rb", __dir__) diff --git a/gemfiles/sidekiq-7-collector.gemfile b/gemfiles/sidekiq-7-collector.gemfile new file mode 100644 index 000000000..b8971bbcd --- /dev/null +++ b/gemfiles/sidekiq-7-collector.gemfile @@ -0,0 +1,6 @@ +# DO NOT EDIT +# This is a generated file by the `rake build_matrix:gemfiles:generate` task. +# It layers the optional OpenTelemetry gems (gemfiles/collector.rb) on top of sidekiq-7.gemfile. + +eval_gemfile File.expand_path("sidekiq-7.gemfile", __dir__) +eval_gemfile File.expand_path("collector.rb", __dir__) diff --git a/gemfiles/sidekiq-8-collector.gemfile b/gemfiles/sidekiq-8-collector.gemfile new file mode 100644 index 000000000..26781c4e3 --- /dev/null +++ b/gemfiles/sidekiq-8-collector.gemfile @@ -0,0 +1,6 @@ +# DO NOT EDIT +# This is a generated file by the `rake build_matrix:gemfiles:generate` task. +# It layers the optional OpenTelemetry gems (gemfiles/collector.rb) on top of sidekiq-8.gemfile. + +eval_gemfile File.expand_path("sidekiq-8.gemfile", __dir__) +eval_gemfile File.expand_path("collector.rb", __dir__) diff --git a/gemfiles/sinatra-collector.gemfile b/gemfiles/sinatra-collector.gemfile new file mode 100644 index 000000000..921768d1e --- /dev/null +++ b/gemfiles/sinatra-collector.gemfile @@ -0,0 +1,6 @@ +# DO NOT EDIT +# This is a generated file by the `rake build_matrix:gemfiles:generate` task. +# It layers the optional OpenTelemetry gems (gemfiles/collector.rb) on top of sinatra.gemfile. + +eval_gemfile File.expand_path("sinatra.gemfile", __dir__) +eval_gemfile File.expand_path("collector.rb", __dir__) diff --git a/gemfiles/webmachine2-collector.gemfile b/gemfiles/webmachine2-collector.gemfile new file mode 100644 index 000000000..6f62919d2 --- /dev/null +++ b/gemfiles/webmachine2-collector.gemfile @@ -0,0 +1,6 @@ +# DO NOT EDIT +# This is a generated file by the `rake build_matrix:gemfiles:generate` task. +# It layers the optional OpenTelemetry gems (gemfiles/collector.rb) on top of webmachine2.gemfile. + +eval_gemfile File.expand_path("webmachine2.gemfile", __dir__) +eval_gemfile File.expand_path("collector.rb", __dir__) diff --git a/lib/appsignal.rb b/lib/appsignal.rb index 754182108..75c6ef40a 100644 --- a/lib/appsignal.rb +++ b/lib/appsignal.rb @@ -8,6 +8,8 @@ require "appsignal/utils/stdout_and_logger_message" require "appsignal/helpers/instrumentation" require "appsignal/helpers/metrics" +require "appsignal/backends" +require "appsignal/opentelemetry" # AppSignal for Ruby gem's main module. # @@ -143,6 +145,8 @@ def start Appsignal::Hooks.load_hooks Appsignal::Loaders.start + Appsignal::OpenTelemetry.configure(config) if config.collector_mode_configured? + if config[:enable_allocation_tracking] && !Appsignal::System.jruby? Appsignal::Extension.install_allocation_event_hook Appsignal::Environment.report_enabled("allocation_tracking") @@ -241,6 +245,10 @@ def _load_config!(env_param = nil, &block) # @return [void] # @since 1.0.0 def stop(called_by = nil) + # Wrapped in `Thread.new ... .join` so this is safe to call from a + # `Signal.trap` block: `Mutex#synchronize` (used by + # `CheckIn::Scheduler`) is unsafe in trap handlers, and running on a + # separate thread sidesteps that restriction. See PR #1295. Thread.new do if called_by internal_logger.info("Stopping AppSignal (#{called_by})") @@ -250,6 +258,7 @@ def stop(called_by = nil) Appsignal::Extension.stop Appsignal::Probes.stop Appsignal::CheckIn.stop + Appsignal::OpenTelemetry.shutdown end.join nil end diff --git a/lib/appsignal/backends.rb b/lib/appsignal/backends.rb new file mode 100644 index 000000000..66b5e001d --- /dev/null +++ b/lib/appsignal/backends.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +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 + private + + def collector? + Appsignal.config&.collector_mode? || false + end + end + end +end diff --git a/lib/appsignal/config.rb b/lib/appsignal/config.rb index f688a3f13..5b5f78328 100644 --- a/lib/appsignal/config.rb +++ b/lib/appsignal/config.rb @@ -91,6 +91,7 @@ def dsl_config_file? DEFAULT_CONFIG = { :activejob_report_errors => "all", :ca_file_path => File.expand_path(File.join("../../../resources/cacert.pem"), __FILE__), + :collector_endpoint => nil, :dns_servers => [], :enable_allocation_tracking => true, :enable_at_exit_hook => "on_error", @@ -107,8 +108,12 @@ def dsl_config_file? :enable_rake_performance_instrumentation => false, :endpoint => "https://push.appsignal.com", :files_world_accessible => true, + :filter_attributes => [], + :filter_function_parameters => [], :filter_metadata => [], :filter_parameters => [], + :filter_request_payload => [], + :filter_request_query_parameters => [], :filter_session_data => [], :ignore_actions => [], :ignore_errors => [], @@ -139,9 +144,14 @@ def dsl_config_file? REQUEST_METHOD REQUEST_PATH SERVER_NAME SERVER_PORT SERVER_PROTOCOL ], + :response_headers => [], :send_environment_metadata => true, + :send_function_parameters => nil, :send_params => true, + :send_request_payload => nil, + :send_request_query_parameters => nil, :send_session_data => true, + :service_name => nil, :sidekiq_report_errors => "all", :default_tags => {} }.freeze @@ -167,6 +177,7 @@ def dsl_config_file? :name => "APPSIGNAL_APP_NAME", :bind_address => "APPSIGNAL_BIND_ADDRESS", :ca_file_path => "APPSIGNAL_CA_FILE_PATH", + :collector_endpoint => "APPSIGNAL_COLLECTOR_ENDPOINT", :enable_at_exit_hook => "APPSIGNAL_ENABLE_AT_EXIT_HOOK", :hostname => "APPSIGNAL_HOSTNAME", :host_role => "APPSIGNAL_HOST_ROLE", @@ -177,6 +188,7 @@ def dsl_config_file? :logging_endpoint => "APPSIGNAL_LOGGING_ENDPOINT", :endpoint => "APPSIGNAL_PUSH_API_ENDPOINT", :push_api_key => "APPSIGNAL_PUSH_API_KEY", + :service_name => "APPSIGNAL_SERVICE_NAME", :sidekiq_report_errors => "APPSIGNAL_SIDEKIQ_REPORT_ERRORS", :statsd_port => "APPSIGNAL_STATSD_PORT", :nginx_port => "APPSIGNAL_NGINX_PORT", @@ -221,21 +233,29 @@ def dsl_config_file? :ownership_set_namespace => "APPSIGNAL_OWNERSHIP_SET_NAMESPACE", :running_in_container => "APPSIGNAL_RUNNING_IN_CONTAINER", :send_environment_metadata => "APPSIGNAL_SEND_ENVIRONMENT_METADATA", + :send_function_parameters => "APPSIGNAL_SEND_FUNCTION_PARAMETERS", :send_params => "APPSIGNAL_SEND_PARAMS", + :send_request_payload => "APPSIGNAL_SEND_REQUEST_PAYLOAD", + :send_request_query_parameters => "APPSIGNAL_SEND_REQUEST_QUERY_PARAMETERS", :send_session_data => "APPSIGNAL_SEND_SESSION_DATA" }.freeze # @!visibility private ARRAY_OPTIONS = { :dns_servers => "APPSIGNAL_DNS_SERVERS", + :filter_attributes => "APPSIGNAL_FILTER_ATTRIBUTES", + :filter_function_parameters => "APPSIGNAL_FILTER_FUNCTION_PARAMETERS", :filter_metadata => "APPSIGNAL_FILTER_METADATA", :filter_parameters => "APPSIGNAL_FILTER_PARAMETERS", + :filter_request_payload => "APPSIGNAL_FILTER_REQUEST_PAYLOAD", + :filter_request_query_parameters => "APPSIGNAL_FILTER_REQUEST_QUERY_PARAMETERS", :filter_session_data => "APPSIGNAL_FILTER_SESSION_DATA", :ignore_actions => "APPSIGNAL_IGNORE_ACTIONS", :ignore_errors => "APPSIGNAL_IGNORE_ERRORS", :ignore_logs => "APPSIGNAL_IGNORE_LOGS", :ignore_namespaces => "APPSIGNAL_IGNORE_NAMESPACES", - :request_headers => "APPSIGNAL_REQUEST_HEADERS" + :request_headers => "APPSIGNAL_REQUEST_HEADERS", + :response_headers => "APPSIGNAL_RESPONSE_HEADERS" }.freeze # @!visibility private @@ -248,6 +268,39 @@ def dsl_config_file? :default_tags => "APPSIGNAL_DEFAULT_TAGS" }.freeze + # Collector mode requires Ruby 3.1+. The OpenTelemetry Ruby SDK relies on + # `Process._fork` (introduced in Ruby 3.1) for its fork hooks, without + # which background reader threads don't restart in child processes and + # buffered telemetry is lost after a fork. + # @!visibility private + MIN_RUBY_VERSION_FOR_COLLECTOR_MODE = "3.1" + + # Configuration options that only have an effect when the integration is + # in collector mode. When the agent is in use, setting any of these emits + # a warning at startup. + # @!visibility private + COLLECTOR_ONLY_OPTIONS = [ + :filter_attributes, + :filter_function_parameters, + :filter_request_payload, + :filter_request_query_parameters, + :response_headers, + :send_function_parameters, + :send_request_payload, + :send_request_query_parameters, + :service_name + ].freeze + + # Existing AppSignal options that only affect the agent's handling of + # trace data. In collector mode these don't filter anything; users need + # their collector-mode equivalents (see COLLECTOR_ONLY_OPTIONS). + # @!visibility private + AGENT_ONLY_TRACE_OPTIONS = [ + :filter_metadata, + :filter_parameters, + :send_params + ].freeze + # @!visibility private attr_reader :root_path, :env, :config_hash @@ -452,6 +505,57 @@ def active? valid? && active_for_env? end + # Check if collector mode is configured. + # + # Returns true when a non-empty `collector_endpoint` is set and the + # running Ruby version is at least {MIN_RUBY_VERSION_FOR_COLLECTOR_MODE}. + # On older Rubies, `collector_endpoint` is ignored (with a warning) and + # the AppSignal agent is used instead. + # + # This is the *intent* check — it answers "did the user ask for + # collector mode, and could we honor it?". It does not say whether the + # OpenTelemetry SDK actually booted. See {#collector_mode?} for that. + # + # Memoised: the result is cached on first call so hot paths avoid + # re-running the string-strip predicate, and so the unsupported-Ruby + # warning is emitted at most once per `Config` instance. + # + # @return [Boolean] True if collector mode is configured. + def collector_mode_configured? + return @collector_mode_configured if defined?(@collector_mode_configured) + + endpoint = config_hash[:collector_endpoint] + configured = !endpoint.nil? && !endpoint.to_s.strip.empty? + + if configured && Gem::Version.new(RUBY_VERSION) < + Gem::Version.new(MIN_RUBY_VERSION_FOR_COLLECTOR_MODE) + Appsignal::Utils::StdoutAndLoggerMessage.warning( + "Collector mode requires Ruby #{MIN_RUBY_VERSION_FOR_COLLECTOR_MODE} or higher " \ + "(running Ruby #{RUBY_VERSION}). The `collector_endpoint` option will be " \ + "ignored and the AppSignal agent will be used instead." + ) + @collector_mode_configured = false + else + @collector_mode_configured = configured + end + end + + # Check if AppSignal is actively running in collector mode. + # + # True only if collector mode is {#collector_mode_configured? configured} + # *and* `Appsignal::OpenTelemetry.configure` has successfully booted the + # SDK in this process. Use this for backend dispatch on hot paths + # (metric and log emits): if the OTel boot failed, callers fall back to + # the agent backend rather than silently dropping data into no-op + # providers. + # + # @return [Boolean] True if collector mode is configured and started. + def collector_mode? + collector_mode_configured? && + defined?(Appsignal::OpenTelemetry) && + Appsignal::OpenTelemetry.started? + end + # @!visibility private def write_to_environment ENV["_APPSIGNAL_ACTIVE"] = active?.to_s @@ -528,6 +632,30 @@ def validate else @valid = true end + + warn_for_mode_mismatch + end + + # Emit warnings when a configuration option is set that has no effect in + # the current mode (collector vs. agent). + # + # Uses {#collector_mode_configured?} (intent) rather than + # {#collector_mode?} so the warnings fire based on what the user asked + # for, independent of whether the OpenTelemetry SDK successfully booted. + # @!visibility private + def warn_for_mode_mismatch + if collector_mode_configured? + warn_user_modified(AGENT_ONLY_TRACE_OPTIONS) do |option| + "The collector is in use. The '#{option}' configuration option is " \ + "only used by the agent for trace data and will be ignored." + end + else + warn_user_modified(COLLECTOR_ONLY_OPTIONS) do |option| + "The agent is in use. The '#{option}' configuration option is " \ + "only used by the collector and will be ignored. Set " \ + "'collector_endpoint' to use the collector." + end + end end # Deep freeze the config object so it cannot be modified during the runtime @@ -551,6 +679,17 @@ def yml_config_file? private + # Yield a warning for each option in `options` whose effective value + # differs from the default. Setting an option to its default value is + # a no-op, so we don't warn about it. + def warn_user_modified(options) + options.each do |option| + next if config_hash[option] == DEFAULT_CONFIG[option] + + logger.warn(yield(option)) + end + end + def logger Appsignal.internal_logger end diff --git a/lib/appsignal/opentelemetry.rb b/lib/appsignal/opentelemetry.rb new file mode 100644 index 000000000..a2ca99c6b --- /dev/null +++ b/lib/appsignal/opentelemetry.rb @@ -0,0 +1,281 @@ +# 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 in `config[:collector_endpoint]`. + # + # The SDK and exporter gems are required lazily, so an application not in + # collector mode does not pay the load cost. Sets `@started`, which + # {.started?} reads to decide whether to route through the OTel backends. + def configure(config) + # The OTel Ruby SDK exposes no programmatic knob for the default + # aggregation temporality; this env var is the only way to set + # it. We pick `:delta` to match the Python integration. (Note: + # the Ruby SDK keeps `UpDownCounter` cumulative regardless of + # this preference, per the OTel spec.) + ENV["OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE"] ||= "delta" + + # With the metrics and logs SDK gems loaded, `SDK.configure` below + # auto-installs a metrics reader and a log processor from these env vars, + # each with its own background thread. Both providers are replaced right + # after, which would leave those threads running and unreachable by any + # shutdown. Set unconditionally, so a user-set "otlp" cannot slip past + # and reintroduce them. + ENV["OTEL_METRICS_EXPORTER"] = "none" + ENV["OTEL_LOGS_EXPORTER"] = "none" + + require_sdk_gems + + # The OpenTelemetry gems are optional and installed by the user (not + # declared in the gemspec). If they're present but older than the + # versions we support, fall back to the agent rather than booting an + # SDK that may misbehave (e.g. a metrics SDK without fork hooks). + return unless required_gem_versions_met? + + endpoint = config[:collector_endpoint].to_s.sub(%r{/+\z}, "") + # Merge with the SDK's default resource so all three signal types + # carry the same `telemetry.sdk.*` and `process.*` attributes that + # `SDK.configure` would have added on its own. `MeterProvider` and + # `LoggerProvider` take a `resource:` kwarg that replaces (not + # merges), so we do the merge ourselves and use the same merged + # resource for the tracer provider to keep all three in sync. + resource = ::OpenTelemetry::SDK::Resources::Resource.default.merge(build_resource(config)) + + ::OpenTelemetry::SDK.configure do |c| + c.resource = resource + c.add_span_processor( + ::OpenTelemetry::SDK::Trace::Export::BatchSpanProcessor.new( + ::OpenTelemetry::Exporter::OTLP::Exporter.new( + :endpoint => "#{endpoint}/v1/traces" + ) + ) + ) + end + + # Wrap the OTLP MetricsExporter in a PeriodicMetricReader so that + # `MeterProvider#force_flush` actually triggers an export. The OTLP + # exporter itself is also a MetricReader but its inherited + # `force_flush` is a no-op. + ::OpenTelemetry.meter_provider = + ::OpenTelemetry::SDK::Metrics::MeterProvider.new(:resource => resource) + ::OpenTelemetry.meter_provider.add_metric_reader( + ::OpenTelemetry::SDK::Metrics::Export::PeriodicMetricReader.new( + :exporter => ::OpenTelemetry::Exporter::OTLP::Metrics::MetricsExporter.new( + :endpoint => "#{endpoint}/v1/metrics" + ) + ) + ) + + ::OpenTelemetry.logger_provider = + ::OpenTelemetry::SDK::Logs::LoggerProvider.new(:resource => resource) + ::OpenTelemetry.logger_provider.add_log_record_processor( + ::OpenTelemetry::SDK::Logs::Export::BatchLogRecordProcessor.new( + ::OpenTelemetry::Exporter::OTLP::Logs::LogsExporter.new( + :endpoint => "#{endpoint}/v1/logs" + ) + ) + ) + + @started = true + rescue LoadError => e + @started = false + Appsignal::Utils::StdoutAndLoggerMessage.error( + "Cannot configure OpenTelemetry SDK for collector mode: #{e.class}: #{e.message}" + ) + rescue => e + @started = false + Appsignal::Utils::StdoutAndLoggerMessage.error( + "Error configuring OpenTelemetry SDK for collector mode: " \ + "#{e.class}: #{e.message}\n#{e.backtrace&.join("\n")}" + ) + end + + # Whether {.configure} has successfully booted the OpenTelemetry SDK + # for this process. Returns `false` before {.configure} runs and + # `false` if it ran but raised. + def started? + defined?(@started) ? @started : false + end + + # Write the current trace context onto an outgoing carrier (HTTP request, + # job hash, ...) with the configured propagator, so a downstream service + # joins the same trace. + # + # A no-op unless the SDK has booted. The carrier is injected from whatever + # span is current at call time, which inside an `Appsignal.instrument` + # block is the event's own span. + def inject_context(carrier) + if_started do + ::OpenTelemetry.propagation.inject(carrier) + end + end + + # Read the trace context off an incoming Rack request env using the + # globally configured propagator, so an AppSignal transaction created for + # the request can continue the upstream trace. Returns an + # `OpenTelemetry::Context` (its current span is the remote parent), or + # `nil` when the SDK has not booted -- outside collector mode there is + # nothing to continue. `rack_env_getter` reads the `HTTP_*`-mangled header + # names Rack puts in the env. + def extract_rack_context(env) + if_started do + ::OpenTelemetry.propagation.extract( + env, + :getter => ::OpenTelemetry::Common::Propagation.rack_env_getter + ) + end + end + + # Read the trace context off an incoming background job hash, so the + # transaction can link back to the enqueuer. Returns an + # `OpenTelemetry::Context`, or `nil` when the SDK has not booted. + # + # Reads both carriers a job can arrive with: top-level `traceparent` and + # `tracestate` keys, as OpenTelemetry's Sidekiq instrumentation injects + # them, and a nested `__otel_headers`, as its Active Job one does. Active + # Job puts that through its argument serializer, so it can arrive as an + # array of pairs rather than a hash. The nested keys win, being the more + # specific layer. + def extract_job_context(item) + if_started do + carrier = item + nested = item["__otel_headers"] + nested = nested.to_h if otel_header_pairs?(nested) + carrier = item.merge(nested) if nested.is_a?(Hash) + ::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/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 51bf7397a..4d20e763a 100644 --- a/sig/appsignal.rbi +++ b/sig/appsignal.rbi @@ -1041,6 +1041,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 diff --git a/sig/appsignal.rbs b/sig/appsignal.rbs index 7e08531b7..c134ff495 100644 --- a/sig/appsignal.rbs +++ b/sig/appsignal.rbs @@ -991,6 +991,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. 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/diagnose b/spec/integration/diagnose index 8a4bcb7f7..a30de805d 160000 --- a/spec/integration/diagnose +++ b/spec/integration/diagnose @@ -1 +1 @@ -Subproject commit 8a4bcb7f70090cf8439ad204d366389b88593350 +Subproject commit a30de805d4ff158afc4252b46211e81da238a12e diff --git a/spec/integration/runner.rb b/spec/integration/runner.rb index aa1d9d7be..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/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/config_spec.rb b/spec/lib/appsignal/config_spec.rb index 27d3fa446..38e164d22 100644 --- a/spec/lib/appsignal/config_spec.rb +++ b/spec/lib/appsignal/config_spec.rb @@ -709,6 +709,7 @@ def on_load :activejob_report_errors => "all", :bind_address => "0.0.0.0", :ca_file_path => "/some/path", + :collector_endpoint => "http://collector.example.test:4318", :cpu_count => 1.5, :dns_servers => ["8.8.8.8", "8.8.4.4"], :enable_allocation_tracking => false, @@ -726,8 +727,12 @@ def on_load :enable_statsd => false, :endpoint => "https://test.appsignal.com", :files_world_accessible => false, + :filter_attributes => ["attr1", "attr2"], + :filter_function_parameters => ["fn1", "fn2"], :filter_metadata => ["key1", "key2"], :filter_parameters => ["param1", "param2"], + :filter_request_payload => ["payload1", "payload2"], + :filter_request_query_parameters => ["query1", "query2"], :filter_session_data => ["session1", "session2"], :host_role => "my host role", :hostname => "my hostname", @@ -759,11 +764,16 @@ def on_load :ownership_set_namespace => true, :push_api_key => "aaa-bbb-ccc", :request_headers => ["accept", "accept-charset"], + :response_headers => ["x-response-1", "x-response-2"], :revision => "v2.5.1", :running_in_container => true, :send_environment_metadata => false, + :send_function_parameters => true, :send_params => false, + :send_request_payload => true, + :send_request_query_parameters => true, :send_session_data => false, + :service_name => "my-service", :sidekiq_report_errors => "all", :statsd_port => "7890", :working_directory_path => working_directory_path, @@ -777,6 +787,7 @@ def on_load "APPSIGNAL_APP_NAME" => "App name", "APPSIGNAL_BIND_ADDRESS" => "0.0.0.0", "APPSIGNAL_CA_FILE_PATH" => "/some/path", + "APPSIGNAL_COLLECTOR_ENDPOINT" => "http://collector.example.test:4318", "APPSIGNAL_ENABLE_AT_EXIT_HOOK" => "never", "APPSIGNAL_HOSTNAME" => "my hostname", "APPSIGNAL_HOST_ROLE" => "my host role", @@ -787,6 +798,7 @@ def on_load "APPSIGNAL_LOG_PATH" => "/tmp/something", "APPSIGNAL_PUSH_API_ENDPOINT" => "https://test.appsignal.com", "APPSIGNAL_PUSH_API_KEY" => "aaa-bbb-ccc", + "APPSIGNAL_SERVICE_NAME" => "my-service", "APPSIGNAL_SIDEKIQ_REPORT_ERRORS" => "all", "APPSIGNAL_STATSD_PORT" => "7890", "APPSIGNAL_NGINX_PORT" => "4321", @@ -826,19 +838,27 @@ def on_load "APPSIGNAL_OWNERSHIP_SET_NAMESPACE" => "true", "APPSIGNAL_RUNNING_IN_CONTAINER" => "true", "APPSIGNAL_SEND_ENVIRONMENT_METADATA" => "false", + "APPSIGNAL_SEND_FUNCTION_PARAMETERS" => "true", "APPSIGNAL_SEND_PARAMS" => "false", + "APPSIGNAL_SEND_REQUEST_PAYLOAD" => "true", + "APPSIGNAL_SEND_REQUEST_QUERY_PARAMETERS" => "true", "APPSIGNAL_SEND_SESSION_DATA" => "false", # Arrays "APPSIGNAL_DNS_SERVERS" => "8.8.8.8,8.8.4.4", + "APPSIGNAL_FILTER_ATTRIBUTES" => "attr1,attr2", + "APPSIGNAL_FILTER_FUNCTION_PARAMETERS" => "fn1,fn2", "APPSIGNAL_FILTER_METADATA" => "key1,key2", "APPSIGNAL_FILTER_PARAMETERS" => "param1,param2", + "APPSIGNAL_FILTER_REQUEST_PAYLOAD" => "payload1,payload2", + "APPSIGNAL_FILTER_REQUEST_QUERY_PARAMETERS" => "query1,query2", "APPSIGNAL_FILTER_SESSION_DATA" => "session1,session2", "APPSIGNAL_IGNORE_ACTIONS" => "action1,action2", "APPSIGNAL_IGNORE_ERRORS" => "ExampleStandardError,AnotherError", "APPSIGNAL_IGNORE_LOGS" => "^start$,^Completed 2.* in .*ms (.*)", "APPSIGNAL_IGNORE_NAMESPACES" => "admin,private_namespace", "APPSIGNAL_REQUEST_HEADERS" => "accept,accept-charset", + "APPSIGNAL_RESPONSE_HEADERS" => "x-response-1,x-response-2", # Floats "APPSIGNAL_CPU_COUNT" => "1.5" @@ -937,6 +957,7 @@ def on_load :active => true, :activejob_report_errors => "all", :ca_file_path => File.join(resources_dir, "cacert.pem"), + :collector_endpoint => nil, :dns_servers => [], :enable_allocation_tracking => true, :enable_at_exit_hook => "on_error", @@ -953,8 +974,12 @@ def on_load :enable_rake_performance_instrumentation => false, :endpoint => "https://push.appsignal.com", :files_world_accessible => true, + :filter_attributes => [], + :filter_function_parameters => [], :filter_metadata => [], :filter_parameters => [], + :filter_request_payload => [], + :filter_request_query_parameters => [], :filter_session_data => [], :ignore_actions => [], :ignore_errors => [], @@ -981,10 +1006,15 @@ def on_load :ownership_set_namespace => false, :push_api_key => "abc", :request_headers => [], + :response_headers => [], :revision => "v2.5.1", :send_environment_metadata => true, + :send_function_parameters => nil, :send_params => true, + :send_request_payload => nil, + :send_request_query_parameters => nil, :send_session_data => true, + :service_name => nil, :sidekiq_report_errors => "all", :default_tags => {} ) @@ -1636,6 +1666,197 @@ def log_file_path end end + describe "#collector_mode_configured?" do + let(:options) { {} } + let(:config) { build_config(:root_path => "", :env => nil, :options => options) } + subject { config.collector_mode_configured? } + # Stub to the gate's minimum so the "happy path" contexts pass on Ruby < 3.1. + # The "when running on Ruby older..." context below stubs to an older version + # to exercise the gate path on every CI Ruby. + before { stub_const("RUBY_VERSION", Appsignal::Config::MIN_RUBY_VERSION_FOR_COLLECTOR_MODE) } + + context "when :collector_endpoint is not set" do + it { is_expected.to be(false) } + end + + context "when :collector_endpoint is nil" do + let(:options) { { :collector_endpoint => nil } } + it { is_expected.to be(false) } + end + + context "when :collector_endpoint is an empty string" do + let(:options) { { :collector_endpoint => "" } } + it { is_expected.to be(false) } + end + + context "when :collector_endpoint is whitespace only" do + let(:options) { { :collector_endpoint => " " } } + it { is_expected.to be(false) } + end + + context "when :collector_endpoint is set" do + let(:options) { { :collector_endpoint => "http://127.0.0.1:9090" } } + it { is_expected.to be(true) } + end + + context "when :collector_endpoint is set via APPSIGNAL_COLLECTOR_ENDPOINT" do + let(:config) do + ENV["APPSIGNAL_COLLECTOR_ENDPOINT"] = "http://127.0.0.1:9090" + build_config(:root_path => "", :env => nil, :options => {}) + end + + it { is_expected.to be(true) } + end + + context "when running on Ruby older than the minimum supported version" do + let(:options) { { :collector_endpoint => "http://127.0.0.1:9090" } } + let(:err_stream) { std_stream } + before { stub_const("RUBY_VERSION", "3.0.7") } + + it "forces collector mode off and warns the user" do + logs = + capture_logs do + capture_std_streams(std_stream, err_stream) do + expect(config.collector_mode_configured?).to be(false) + end + end + + message = + "Collector mode requires Ruby " \ + "#{Appsignal::Config::MIN_RUBY_VERSION_FOR_COLLECTOR_MODE} or higher " \ + "(running Ruby 3.0.7)" + expect(logs).to include(message) + expect(err_stream.read).to include("appsignal WARNING: #{message}") + end + + it "memoizes the result so the warning is emitted at most once" do + logs = + capture_logs do + capture_std_streams(std_stream, err_stream) do + 3.times { config.collector_mode_configured? } + end + end + + expect(logs.scan("Collector mode requires").length).to eq(1) + end + end + end + + describe "#collector_mode?" do + let(:options) { { :collector_endpoint => "http://127.0.0.1:9090" } } + let(:config) { build_config(:root_path => "", :env => nil, :options => options) } + subject { config.collector_mode? } + before { stub_const("RUBY_VERSION", Appsignal::Config::MIN_RUBY_VERSION_FOR_COLLECTOR_MODE) } + + context "when collector mode is configured and OpenTelemetry has started" do + before { allow(Appsignal::OpenTelemetry).to receive(:started?).and_return(true) } + + it { is_expected.to be(true) } + end + + context "when collector mode is configured but OpenTelemetry hasn't started" do + before { allow(Appsignal::OpenTelemetry).to receive(:started?).and_return(false) } + + it { is_expected.to be(false) } + end + + context "when collector mode is not configured" do + let(:options) { {} } + it { is_expected.to be(false) } + end + end + + describe "#warn_for_mode_mismatch" do + let(:options) { {} } + let(:config) { build_config(:options => options) } + + context "when in collector mode" do + let(:collector_options) do + { :collector_endpoint => "http://127.0.0.1:9090" } + end + before { stub_const("RUBY_VERSION", Appsignal::Config::MIN_RUBY_VERSION_FOR_COLLECTOR_MODE) } + + it "warns when filter_parameters is set" do + logs = + capture_logs do + build_config(:options => collector_options.merge(:filter_parameters => ["password"])) + end + expect(logs).to include("filter_parameters") + expect(logs).to include("only used by the agent") + end + + it "warns when send_params is set" do + logs = + capture_logs do + build_config(:options => collector_options.merge(:send_params => false)) + end + expect(logs).to include("send_params") + expect(logs).to include("only used by the agent") + end + + it "does not warn when only filter_attributes is set" do + logs = + capture_logs do + build_config(:options => collector_options.merge(:filter_attributes => ["password"])) + end + expect(logs).to_not include("only used by the agent") + expect(logs).to_not include("only used by the collector") + end + + it "does not warn when an agent-only option is explicitly set to its default" do + # send_params defaults to true; setting it to true is a no-op and + # shouldn't trigger a mode-mismatch warning. + logs = + capture_logs do + build_config(:options => collector_options.merge(:send_params => true)) + end + expect(logs).to_not include("only used by the agent") + expect(logs).to_not include("only used by the collector") + end + end + + context "when not in collector mode" do + it "warns when a collector-only option is set" do + logs = + capture_logs do + build_config(:options => { :filter_attributes => ["password"] }) + end + expect(logs).to include("filter_attributes") + expect(logs).to include("only used by the collector") + end + + it "warns when service_name is set" do + logs = + capture_logs do + build_config(:options => { :service_name => "my-service" }) + end + expect(logs).to include("service_name") + expect(logs).to include("only used by the collector") + end + + it "does not warn when only filter_parameters is set" do + logs = + capture_logs do + build_config(:options => { :filter_parameters => ["password"] }) + end + expect(logs).to_not include("only used by the agent") + expect(logs).to_not include("only used by the collector") + end + + it "does not warn when a collector-only option is explicitly set to its default" do + # filter_attributes defaults to []; setting it to [] is a no-op and + # shouldn't trigger a mode-mismatch warning even though the source + # dictionary recorded the assignment. + logs = + capture_logs do + build_config(:options => { :filter_attributes => [] }) + end + expect(logs).to_not include("only used by the agent") + expect(logs).to_not include("only used by the collector") + end + end + end + describe Appsignal::Config::ConfigDSL do let(:env) { :production } let(:options) { {} } diff --git a/spec/lib/appsignal/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_spec.rb b/spec/lib/appsignal_spec.rb index 423ee99ed..c88739716 100644 --- a/spec/lib/appsignal_spec.rb +++ b/spec/lib/appsignal_spec.rb @@ -897,9 +897,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 +950,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 diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index d4708cadf..b3ebcbcae 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -29,6 +29,9 @@ Dir[File.join(APPSIGNAL_SPEC_DIR, "support/shared_examples", "*.rb")].sort.each do |f| require f end +Dir[File.join(APPSIGNAL_SPEC_DIR, "support/shared_contexts", "*.rb")].sort.each do |f| + require f +end if DependencyHelper.rails_present? require File.join(ConfigHelpers.rails_project_fixture_path, "config/application.rb") end @@ -78,7 +81,11 @@ def clear_subscribers config.exclude_pattern = "spec/integration/diagnose/**/*_spec.rb" config.filter_run_excluding( :extension_installation_failure => true, - :jruby => !DependencyHelper.running_jruby? + :jruby => !DependencyHelper.running_jruby?, + # The OpenTelemetry gems are optional. When they're not installed (e.g. on + # Ruby 2.7, or any non-`-collector` gemfile), skip every collector-mode + # example rather than crashing on missing constants. + :collector_mode => !DependencyHelper.opentelemetry_present? ) config.mock_with :rspec do |mocks| mocks.syntax = :expect @@ -92,7 +99,23 @@ def spec_system_tmp_dir end config.before :suite do - WebMock.disable_net_connect! + # The OTLP mock server is only needed by collector-mode specs, which only + # run when the OpenTelemetry gems are installed. Always disable real network + # connections; when those specs run, relax the rule just enough to reach the + # mock server bound by `OTLPCollectorServer`. + if DependencyHelper.opentelemetry_present? + # Boot first: the server binds an OS-assigned port, so its address is only + # known afterwards. + OTLPCollectorServer.boot! + WebMock.disable_net_connect!(:allow => "127.0.0.1:#{OTLPCollectorServer.port}") + else + WebMock.disable_net_connect! + end + end + + config.after do + OTLPCollectorServer.clear if defined?(OTLPCollectorServer) + Appsignal::OpenTelemetry.reset! end config.before :context do diff --git a/spec/support/helpers/dependency_helper.rb b/spec/support/helpers/dependency_helper.rb index 8384aa44c..e7c786b5c 100644 --- a/spec/support/helpers/dependency_helper.rb +++ b/spec/support/helpers/dependency_helper.rb @@ -25,6 +25,24 @@ def running_jruby? Appsignal::System.jruby? end + # Whether the optional OpenTelemetry gems collector mode needs are + # installable in this bundle. They're no longer gemspec dependencies, so + # the OTel specs only run under the `-collector` gemfiles (Ruby 3.1+). This + # actually requires the gems (idempotent) so the guarded specs can use them. + def opentelemetry_present? + return @opentelemetry_present if defined?(@opentelemetry_present) + + @opentelemetry_present = + begin + require "opentelemetry/sdk" + require "opentelemetry-metrics-sdk" + require "opentelemetry-logs-sdk" + true + rescue LoadError + false + end + end + def rails_present? dependency_present? "rails" end diff --git a/spec/support/helpers/mode_helpers.rb b/spec/support/helpers/mode_helpers.rb new file mode 100644 index 000000000..837b27856 --- /dev/null +++ b/spec/support/helpers/mode_helpers.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module ModeHelpers + # Defines the same example in both agent and collector mode. Pass an optional + # description; it is suffixed with " in agent mode" / " in collector mode". + # + # Per the dual-mode start principle, each generated example starts its own + # agent in the body: agent mode via `start_agent`, collector mode via + # `start_collector_agent`, before running the shared block. So the block must + # NOT start the agent itself, and any mode-dependent arrangement (e.g. + # `set_current_transaction`, building a transaction) belongs inside the block + # — which runs after the start — rather than in a `before` hook. + def it_in_both_modes(description = nil, &block) + it([description, "in agent mode"].compact.join(" "), :agent_mode) do + start_agent(**(defined?(start_agent_args) ? start_agent_args : {})) + instance_exec(&block) + end + it([description, "in collector mode"].compact.join(" "), :collector_mode) do + start_collector_agent + instance_exec(&block) + end + end +end + +RSpec.configure { |config| config.extend ModeHelpers } diff --git a/spec/support/helpers/otlp_collector_server.rb b/spec/support/helpers/otlp_collector_server.rb new file mode 100644 index 000000000..db4ef05ff --- /dev/null +++ b/spec/support/helpers/otlp_collector_server.rb @@ -0,0 +1,148 @@ +# frozen_string_literal: true + +require "socket" +require "stringio" +require "timeout" +require "zlib" + +# A mock OTLP/HTTP collector used by the collector-mode integration spec. +# +# Accepts POSTs to `/v1/traces`, `/v1/metrics` and `/v1/logs` and stores the +# raw protobuf-encoded request body in a per-path Queue. Tests call +# `OTLPCollectorServer.listen_to("/v1/traces")` to block until a request +# arrives and then decode it with the proto stubs that ship inside the +# `opentelemetry-exporter-otlp` gem. +# +# Hand-rolled on top of `TCPServer` rather than Sinatra/WEBrick so the spec +# suite doesn't drag those gems into every framework gemfile via the gemspec. +module OTLPCollectorServer + PATHS = %w[/v1/traces /v1/metrics /v1/logs].freeze + + @received = Hash.new { |h, k| h[k] = Queue.new } + @booted = false + @port = nil + + class << self + attr_reader :received + + # The port the mock server is bound to. Assigned by `boot!`, which binds to + # an OS-assigned free port rather than a fixed one, so concurrent suite runs + # on the same machine don't collide. `nil` until booted. + attr_reader :port + + def endpoint + "http://127.0.0.1:#{port}" + end + + # Env vars that put a spawned runner into collector mode, pointed at this + # mock server. Returns a plain Hash so callers can merge in other env + # vars, e.g. `OTLPCollectorServer.env.merge("OTEL_..." => "...")`. + def env + { "APPSIGNAL_COLLECTOR_ENDPOINT" => endpoint } + end + + def listen_to(path, timeout: 10) + Timeout.timeout(timeout) { received[path].pop } + rescue Timeout::Error + raise "Timed out after #{timeout}s waiting for OTLP request to #{path}. " \ + "Other received paths so far: " \ + "#{received.transform_values(&:size).reject { |_, s| s.zero? }.inspect}" + end + + def clear + received.each_value(&:clear) + end + + def boot! + return if @booted + + # Port 0 lets the OS pick a free port; read the assigned one back so + # `endpoint`/`env` can hand it to the spawned runners. + @server = TCPServer.new("127.0.0.1", 0) + @port = @server.addr[1] + @booted = true + @thread = Thread.new do + Thread.current.abort_on_exception = false + accept_loop + end + end + + private + + def accept_loop + loop do + client = @server.accept + Thread.new(client) { |c| handle(c) } + end + rescue IOError, Errno::EBADF + # Server socket was closed; exit the loop. + end + + def handle(client) + request_line = client.gets + return unless request_line + + method, path, _ = request_line.strip.split(" ", 3) + headers = read_headers(client) + + length = headers["content-length"].to_i + raw_body = length.positive? ? client.read(length) : "" + body = + if headers["content-encoding"] == "gzip" + Zlib::GzipReader.new(StringIO.new(raw_body)).read + else + raw_body + end + + if method == "POST" && PATHS.include?(path) + received[path] << { :headers => rack_style_headers(headers), :body => body } + write_response(client, 200, "application/x-protobuf", "") + else + write_response(client, 404, "text/plain", "") + end + rescue StandardError + # Swallow per-connection errors so a malformed request doesn't bring + # down the accept loop for the rest of the suite. + ensure + begin + client&.close + rescue StandardError + # ignore + end + end + + def read_headers(client) + headers = {} + while (line = client.gets) && line != "\r\n" + key, _, value = line.strip.partition(":") + headers[key.downcase] = value.strip + end + headers + end + + # Mimic the rack env header keys the previous Sinatra-based server + # exposed so any future spec that introspects `:headers` finds the + # same shape. + def rack_style_headers(headers) + headers.each_with_object({}) do |(k, v), h| + env_key = + if k == "content-type" + "CONTENT_TYPE" + else + "HTTP_#{k.upcase.tr("-", "_")}" + end + h[env_key] = v + end + end + + def write_response(client, status, content_type, body) + reason = status == 200 ? "OK" : "Not Found" + client.write("HTTP/1.1 #{status} #{reason}\r\n") + client.write("Content-Type: #{content_type}\r\n") + client.write("Content-Length: #{body.bytesize}\r\n") + client.write("Connection: close\r\n") + client.write("\r\n") + client.write(body) + end + end +end diff --git a/spec/support/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 From db42338b93d34075038a4c0f4c3cc71c1f446cc8 Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Wed, 8 Jul 2026 17:24:05 +0200 Subject: [PATCH 02/69] Emit OpenTelemetry logs in collector mode `Appsignal::Logger` goes through a backend. The extension backend keeps the existing C extension logging, and an OpenTelemetry backend emits log records through the SDK's logger provider in collector mode. Ruby severities and formats are mapped to their OTLP equivalents. `appsignal.group` and `appsignal.format` are hard overrides, so a user attribute cannot spoof them. --- lib/appsignal/backends.rb | 11 + lib/appsignal/logger.rb | 22 +- lib/appsignal/logger/extension_backend.rb | 24 + lib/appsignal/logger/opentelemetry_backend.rb | 78 + spec/integration/collector_mode_logs_spec.rb | 45 + .../runners/collector_mode_logs.rb | 29 + spec/lib/appsignal/backends_spec.rb | 35 + .../logger/opentelemetry_backend_spec.rb | 153 ++ spec/lib/appsignal/logger_spec.rb | 1596 ++++++++++++----- .../support/shared_contexts/collector_mode.rb | 135 ++ 10 files changed, 1662 insertions(+), 466 deletions(-) create mode 100644 lib/appsignal/logger/extension_backend.rb create mode 100644 lib/appsignal/logger/opentelemetry_backend.rb create mode 100644 spec/integration/collector_mode_logs_spec.rb create mode 100644 spec/integration/runners/collector_mode_logs.rb create mode 100644 spec/lib/appsignal/backends_spec.rb create mode 100644 spec/lib/appsignal/logger/opentelemetry_backend_spec.rb create mode 100644 spec/support/shared_contexts/collector_mode.rb diff --git a/lib/appsignal/backends.rb b/lib/appsignal/backends.rb index 66b5e001d..13129845a 100644 --- a/lib/appsignal/backends.rb +++ b/lib/appsignal/backends.rb @@ -1,5 +1,8 @@ # frozen_string_literal: true +require "appsignal/logger/extension_backend" +require "appsignal/logger/opentelemetry_backend" + module Appsignal # @!visibility private # @@ -13,6 +16,14 @@ module Appsignal # in by adding one more lookup method here. module Backends class << self + def logger + if collector? + Appsignal::Logger::OpenTelemetryBackend + else + Appsignal::Logger::ExtensionBackend + end + end + private def collector? 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/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/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/lib/appsignal/backends_spec.rb b/spec/lib/appsignal/backends_spec.rb new file mode 100644 index 000000000..1b90814ee --- /dev/null +++ b/spec/lib/appsignal/backends_spec.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +describe Appsignal::Backends do + 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 +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/support/shared_contexts/collector_mode.rb b/spec/support/shared_contexts/collector_mode.rb new file mode 100644 index 000000000..5672c92b9 --- /dev/null +++ b/spec/support/shared_contexts/collector_mode.rb @@ -0,0 +1,135 @@ +# 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::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 From bc536ae2c2c1de6744fec54eff0cd04fd7a2c730 Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Wed, 8 Jul 2026 17:32:47 +0200 Subject: [PATCH 03/69] Emit OpenTelemetry metrics in collector mode The custom metric helpers go through a backend. The extension backend keeps the C extension path, including its out-of-range warning, and an OpenTelemetry backend records gauges, up/down counters and histograms through the SDK's meter provider in collector mode. With the metrics and logger backends both in place, `Appsignal.start` can fall back to the agent when the OpenTelemetry SDK fails to boot, so telemetry is never silently dropped. --- lib/appsignal/backends.rb | 10 + lib/appsignal/helpers/metrics.rb | 27 +- lib/appsignal/metrics/extension_backend.rb | 47 +++ .../metrics/opentelemetry_backend.rb | 89 +++++ sig/appsignal.rbi | 3 + sig/appsignal.rbs | 3 + spec/integration/collector_mode_fork_spec.rb | 40 +++ .../collector_mode_metrics_spec.rb | 50 +++ .../collector_mode_stop_flush_spec.rb | 31 ++ .../runners/collector_mode_fork.rb | 33 ++ .../runners/collector_mode_metrics.rb | 22 ++ .../runners/collector_mode_stop_flush.rb | 23 ++ spec/lib/appsignal/backends_spec.rb | 32 ++ .../metrics/opentelemetry_backend_spec.rb | 143 ++++++++ spec/lib/appsignal_spec.rb | 311 ++++++++++++------ .../support/shared_contexts/collector_mode.rb | 1 + 16 files changed, 741 insertions(+), 124 deletions(-) create mode 100644 lib/appsignal/metrics/extension_backend.rb create mode 100644 lib/appsignal/metrics/opentelemetry_backend.rb create mode 100644 spec/integration/collector_mode_fork_spec.rb create mode 100644 spec/integration/collector_mode_metrics_spec.rb create mode 100644 spec/integration/collector_mode_stop_flush_spec.rb create mode 100644 spec/integration/runners/collector_mode_fork.rb create mode 100644 spec/integration/runners/collector_mode_metrics.rb create mode 100644 spec/integration/runners/collector_mode_stop_flush.rb create mode 100644 spec/lib/appsignal/metrics/opentelemetry_backend_spec.rb diff --git a/lib/appsignal/backends.rb b/lib/appsignal/backends.rb index 13129845a..18f9979e0 100644 --- a/lib/appsignal/backends.rb +++ b/lib/appsignal/backends.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require "appsignal/metrics/extension_backend" +require "appsignal/metrics/opentelemetry_backend" require "appsignal/logger/extension_backend" require "appsignal/logger/opentelemetry_backend" @@ -16,6 +18,14 @@ module Appsignal # 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 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/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/sig/appsignal.rbi b/sig/appsignal.rbi index 4d20e763a..9553a2c1b 100644 --- a/sig/appsignal.rbi +++ b/sig/appsignal.rbi @@ -2697,6 +2697,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 c134ff495..1b067ba25 100644 --- a/sig/appsignal.rbs +++ b/sig/appsignal.rbs @@ -2507,6 +2507,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_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_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/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_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_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/lib/appsignal/backends_spec.rb b/spec/lib/appsignal/backends_spec.rb index 1b90814ee..186dacce0 100644 --- a/spec/lib/appsignal/backends_spec.rb +++ b/spec/lib/appsignal/backends_spec.rb @@ -1,6 +1,38 @@ # 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) } 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_spec.rb b/spec/lib/appsignal_spec.rb index c88739716..edf5c75c6 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 @@ -1535,106 +1568,6 @@ def on_start end end - describe "custom metrics" do - let(:tags) { { :foo => "bar" } } - - 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 "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) - 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) - 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") - - Appsignal.set_gauge("key", 10) - 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 - - 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) - 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 "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) - end - end - end - describe ".internal_logger" do subject { Appsignal.internal_logger } @@ -2127,6 +2060,184 @@ def on_start end end + 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 ".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 + + describe "with tags" do + def perform + Appsignal.increment_counter("key", 5, tags) + end + + it "in agent mode", :agent_mode do + start_agent + expect(Appsignal::Extension).to receive(:increment_counter) + .with("key", 5, Appsignal::Utils::Data.generate(tags)) + perform + end + + it "in collector mode", :collector_mode do + start_collector_agent + allow(Appsignal::Metrics::OpenTelemetryBackend).to receive(:increment_counter) + expect(Appsignal::Extension).not_to receive(:increment_counter) + perform + expect(Appsignal::Metrics::OpenTelemetryBackend).to have_received(:increment_counter) + .with("key", 5, tags) + end + end + + describe "with a symbol key" do + it "in agent mode", :agent_mode do + start_agent + expect(Appsignal::Extension).to receive(:increment_counter) + .with("key", 1, Appsignal::Extension.data_map_new) + Appsignal.increment_counter(:key) + end + end + + describe "with a count" do + it "in agent mode", :agent_mode do + start_agent + expect(Appsignal::Extension).to receive(:increment_counter) + .with("key", 5, Appsignal::Extension.data_map_new) + Appsignal.increment_counter("key", 5) + end + end + + describe "when the value is out of range" do + it "in agent mode", :agent_mode do + start_agent + expect(Appsignal::Extension).to receive(:increment_counter) + .with("key", 10, Appsignal::Extension.data_map_new).and_raise(RangeError) + expect(Appsignal.internal_logger).to receive(:warn) + .with("The counter value '10' for metric 'key' is too big") + + Appsignal.increment_counter("key", 10) + end + end + end + + describe ".add_distribution_value" do + describe "with a string key and float value" do + it "in agent mode", :agent_mode do + start_agent + expect(Appsignal::Extension).to receive(:add_distribution_value) + .with("key", 0.1, Appsignal::Extension.data_map_new) + Appsignal.add_distribution_value("key", 0.1) + end + end + + describe "with tags" do + def perform + Appsignal.add_distribution_value("key", 0.1, tags) + end + + it "in agent mode", :agent_mode do + start_agent + expect(Appsignal::Extension).to receive(:add_distribution_value) + .with("key", 0.1, Appsignal::Utils::Data.generate(tags)) + perform + end + + it "in collector mode", :collector_mode do + start_collector_agent + allow(Appsignal::Metrics::OpenTelemetryBackend).to receive(:add_distribution_value) + expect(Appsignal::Extension).not_to receive(:add_distribution_value) + perform + expect(Appsignal::Metrics::OpenTelemetryBackend).to have_received(:add_distribution_value) + .with("key", 0.1, tags) + end + end + + describe "with a symbol key and int value" do + it "in agent mode", :agent_mode do + start_agent + expect(Appsignal::Extension).to receive(:add_distribution_value) + .with("key", 1.0, Appsignal::Extension.data_map_new) + Appsignal.add_distribution_value(:key, 1) + end + end + + describe "when the value is out of range" do + it "in agent mode", :agent_mode do + start_agent + expect(Appsignal::Extension).to receive(:add_distribution_value) + .with("key", 10, Appsignal::Extension.data_map_new).and_raise(RangeError) + expect(Appsignal.internal_logger).to receive(:warn) + .with("The distribution value '10' for metric 'key' is too big") + + Appsignal.add_distribution_value("key", 10) + end + end + end + end + describe "._start_logger" do let(:out_stream) { std_stream } let(:output) { out_stream.read } diff --git a/spec/support/shared_contexts/collector_mode.rb b/spec/support/shared_contexts/collector_mode.rb index 5672c92b9..a5a796e7c 100644 --- a/spec/support/shared_contexts/collector_mode.rb +++ b/spec/support/shared_contexts/collector_mode.rb @@ -92,6 +92,7 @@ def start_collector_agent ::OpenTelemetry.tracer_provider = tracer_provider ::OpenTelemetry.meter_provider = meter_provider ::OpenTelemetry.logger_provider = logger_provider + Appsignal::Metrics::OpenTelemetryBackend.reset! Appsignal::Logger::OpenTelemetryBackend.reset! end From cb1d004bbcff1f53881c82476ef58d0bb32b1d4b Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Wed, 8 Jul 2026 17:33:26 +0200 Subject: [PATCH 04/69] Test the metric probes in collector mode The probes emit through the metric helpers, so they need no changes of their own. Running each metric example in both modes proves their output routes to OpenTelemetry when collector mode is active. --- spec/lib/appsignal/probes/gvl_spec.rb | 312 ++++++++++++++------ spec/lib/appsignal/probes/mri_spec.rb | 338 +++++++++++++++++----- spec/lib/appsignal/probes/sidekiq_spec.rb | 219 ++++++++++---- 3 files changed, 651 insertions(+), 218 deletions(-) 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 From 0b714837e058e3b4c6c456a1287c266c5cae6b0d Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Wed, 8 Jul 2026 17:43:33 +0200 Subject: [PATCH 05/69] Emit OpenTelemetry traces in collector mode `Appsignal::Transaction` goes through a backend. The extension backend keeps the existing C extension behaviour, and now owns the sample data serialization, the breadcrumb buffering and the agent's error cause shape. An OpenTelemetry backend maps a transaction to a root span and each event to a child span. One `Transaction` drives two error models, so the backend answers `records_errors_eagerly?`. The extension collects errors and then duplicates the transaction once per error at completion. The OpenTelemetry backend records each error as an `exception` span event straight away. Three mappings decide the shape of the exported trace: - A span's kind comes from the namespace. A web or Action Cable transaction is SERVER, and a background job is CONSUMER. A datastore client event passes `opentelemetry_kind: :client`. The kind is set at creation, because it cannot change afterwards. - An event span is named `title || name`, and the event name is kept as the `appsignal.category` attribute. A SQL body maps to `db.query.text`, and every other body to `appsignal.body`. - An error attaches to the currently open span, with its cause chain in one `appsignal.error_causes` JSON attribute. A discarded transaction sets `appsignal.ignore_subtrace`, which tells the collector to drop it. --- lib/appsignal/backends.rb | 11 + lib/appsignal/helpers/instrumentation.rb | 12 +- lib/appsignal/hooks/sequel.rb | 6 +- .../active_support_notifications.rb | 25 +- lib/appsignal/integrations/data_mapper.rb | 6 +- lib/appsignal/integrations/dry_monitor.rb | 7 +- .../integrations/mongo_ruby_driver.rb | 11 +- lib/appsignal/integrations/redis.rb | 7 +- lib/appsignal/integrations/redis_client.rb | 7 +- lib/appsignal/transaction.rb | 279 +- lib/appsignal/transaction/base_backend.rb | 85 + .../transaction/extension_backend.rb | 165 + .../transaction/opentelemetry_backend.rb | 418 +++ sig/appsignal.rbi | 10 +- sig/appsignal.rbs | 10 +- ...llector_mode_log_trace_correlation_spec.rb | 46 + .../collector_mode_mixed_api_spec.rb | 54 + .../integration/collector_mode_traces_spec.rb | 55 + .../collector_mode_log_trace_correlation.rb | 27 + .../runners/collector_mode_mixed_api.rb | 38 + .../runners/collector_mode_traces.rb | 30 + spec/lib/appsignal/backends_spec.rb | 32 + spec/lib/appsignal/rack/event_handler_spec.rb | 2 +- .../transaction/extension_backend_spec.rb | 226 ++ .../transaction/opentelemetry_backend_spec.rb | 941 +++++ spec/lib/appsignal/transaction_spec.rb | 3237 +++++++++++++---- spec/lib/appsignal_spec.rb | 942 ++++- spec/support/helpers/transaction_helpers.rb | 4 +- spec/support/matchers/transaction.rb | 9 +- spec/support/testing.rb | 27 +- 30 files changed, 5763 insertions(+), 966 deletions(-) create mode 100644 lib/appsignal/transaction/base_backend.rb create mode 100644 lib/appsignal/transaction/extension_backend.rb create mode 100644 lib/appsignal/transaction/opentelemetry_backend.rb create mode 100644 spec/integration/collector_mode_log_trace_correlation_spec.rb create mode 100644 spec/integration/collector_mode_mixed_api_spec.rb create mode 100644 spec/integration/collector_mode_traces_spec.rb create mode 100644 spec/integration/runners/collector_mode_log_trace_correlation.rb create mode 100644 spec/integration/runners/collector_mode_mixed_api.rb create mode 100644 spec/integration/runners/collector_mode_traces.rb create mode 100644 spec/lib/appsignal/transaction/extension_backend_spec.rb create mode 100644 spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb diff --git a/lib/appsignal/backends.rb b/lib/appsignal/backends.rb index 18f9979e0..e0d50425c 100644 --- a/lib/appsignal/backends.rb +++ b/lib/appsignal/backends.rb @@ -4,6 +4,9 @@ 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 @@ -34,6 +37,14 @@ def logger end end + def transaction + if collector? + Appsignal::Transaction::OpenTelemetryBackend + else + Appsignal::Transaction::ExtensionBackend + end + end + private def collector? 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/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/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 47e769853..9bb1f0dc4 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/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/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/transaction.rb b/lib/appsignal/transaction.rb index c901c275b..721a81047 100644 --- a/lib/appsignal/transaction.rb +++ b/lib/appsignal/transaction.rb @@ -37,7 +37,9 @@ 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) + ) else transaction = current # Otherwise, log the issue about trying to start another transaction @@ -160,7 +162,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) @transaction_id = id @action = nil @namespace = namespace @@ -168,7 +170,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 +180,10 @@ 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 + @namespace + ) run_after_create_hooks end @@ -205,9 +205,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 +233,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 @@ -524,14 +515,16 @@ def add_breadcrumb(category, action, message = "", metadata = {}, time = Time.no return end - @breadcrumbs.push( + # The backend owns how breadcrumbs are stored: the agent backend buffers + # them and flushes at completion, the OpenTelemetry backend emits each as a + # span event right away (by completion its target span has finished). + @backend.add_breadcrumb( :time => time.to_i, :category => category, :action => action, :message => message, :metadata => metadata ) - @breadcrumbs = @breadcrumbs.last(BREADCRUMB_LIMIT) end # Set an action name for the transaction. @@ -548,7 +541,7 @@ def set_action(action) return unless action @action = action - @ext.set_action(action) + @backend.set_action(action) end # Set an action name only if there is no current action set. @@ -596,7 +589,7 @@ def set_namespace(namespace) return unless namespace @namespace = namespace - @ext.set_namespace(namespace) + @backend.set_namespace(namespace) end # Set queue start time for transaction. @@ -610,7 +603,7 @@ def set_namespace(namespace) def set_queue_start(start) return unless start - @ext.set_queue_start(start) + @backend.set_queue_start(start) rescue RangeError Appsignal.internal_logger.warn("Queue start value #{start} is too big") end @@ -620,7 +613,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 @@ -653,10 +646,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 @@ -664,34 +657,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) @@ -699,34 +704,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) @@ -739,23 +752,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} " \ @@ -764,55 +853,10 @@ def _set_error(error) break end - causes << error + causes << cause end - causes_sample_data = causes.map do |e| - { - :name => e.class.name, - :message => cleaned_error_message(e), - :first_line => first_formatted_backtrace_line(e) - } - end - - causes_sample_data.last[:is_root_cause] = false if root_cause_missing - - set_sample_data( - "error_causes", - causes_sample_data - ) - end - - BACKTRACE_REGEX = - %r{(?[\w-]+ \(.+\) )?(?:?/?\w+?.+?):(?:?\d+)(?::in `(?.+)')?$}.freeze - private_constant :BACKTRACE_REGEX - - def first_formatted_backtrace_line(error) - backtrace = cleaned_backtrace(error.backtrace) - first_line = backtrace&.first - return unless first_line - - captures = BACKTRACE_REGEX.match(first_line) - return unless captures - - captures.named_captures - .merge("original" => first_line) - .tap do |c| - config = Appsignal.config - # Strip of whitespace at the end of the gem name - c["gem"] = c["gem"]&.strip - # Strip the app path from the path if present - root_path = config.root_path - if c["path"].start_with?(root_path) - c["path"].delete_prefix!(root_path) - # Relative paths shouldn't start with a slash - c["path"].delete_prefix!("/") - end - # Add revision for linking to the repository from the UI - c["revision"] = config[:revision] - # Convert line number to an integer - c["line"] = c["line"].to_i - end + [causes, root_cause_missing] end def set_sample_data(key, data) @@ -825,10 +869,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 @@ -848,7 +893,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) @@ -860,12 +904,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..add9b33a8 --- /dev/null +++ b/lib/appsignal/transaction/extension_backend.rb @@ -0,0 +1,165 @@ +# 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 + + def initialize(transaction_id, namespace, handle: nil) + 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..3a5f884af --- /dev/null +++ b/lib/appsignal/transaction/opentelemetry_backend.rb @@ -0,0 +1,418 @@ +# 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) + 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) + @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 -- rather + # than the OTel current span, which may belong to another instrumentation. + # + # Emitted immediately, because by completion the event span has finished + # and the SDK drops events added to an ended span. The breadcrumb's time + # becomes the event's timestamp, and the metadata Hash is a JSON string, + # because event attributes are flat. + # + # Capped at `BREADCRUMB_LIMIT` per transaction, keeping the first N where + # agent mode keeps the last N: a streamed event cannot be retracted. + def add_breadcrumb(breadcrumb) + return if @breadcrumb_count >= Appsignal::Transaction::BREADCRUMB_LIMIT + + @breadcrumb_count += 1 + current_span.add_event( + "appsignal.breadcrumb", + :timestamp => Time.at(breadcrumb[:time]), + :attributes => { + "category" => breadcrumb[:category], + "action" => breadcrumb[:action], + "message" => breadcrumb[:message], + "metadata" => JSON.generate(breadcrumb[:metadata] || {}) + } + ) + end + + # Returns `true` so `Transaction#complete` runs `sample_data`, flushing the + # params/session/custom-data/tags/etc. onto the still-open root span before + # `complete` finishes it. The OTel SDK makes its own sampling decision; the + # gem always populates the span. + def finish + true + end + + def complete + # `teardown` sets `@completed`, so this guard also makes the 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, flagged with + # `appsignal.ignore_subtrace` so the collector drops the whole subtrace. + # The flag has to be written before the span finishes, because attributes + # set on an ended span are dropped. Tearing the span down here also + # detaches the context, so a discarded transaction cannot leave its root + # span current on the thread. + def discard + return if @completed + + @span&.set_attribute("appsignal.ignore_subtrace", true) + teardown + end + + # Each error is recorded eagerly as its own `exception` event on the span + # current when it was added, so 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. A transaction is its own unit of work, + # so it starts a plain root span that ignores any ambient OTel context. + def start_transaction_span(namespace, kind) + tracer.start_root_span(placeholder_span_name(namespace), :kind => kind) + 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/sig/appsignal.rbi b/sig/appsignal.rbi index 9553a2c1b..389422232 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. # @@ -2521,7 +2522,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". # @@ -2610,10 +2611,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. # diff --git a/sig/appsignal.rbs b/sig/appsignal.rbs index 1b067ba25..41d1073a2 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. @@ -2348,7 +2349,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". # @@ -2432,7 +2433,8 @@ module Appsignal String name, ?String? title, ?String? body, - ?Integer body_format + ?Integer body_format, + ?opentelemetry_kind: untyped ) -> Object # Instrumentation helper for SQL queries. 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_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_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/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_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_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/lib/appsignal/backends_spec.rb b/spec/lib/appsignal/backends_spec.rb index 186dacce0..41f582dc2 100644 --- a/spec/lib/appsignal/backends_spec.rb +++ b/spec/lib/appsignal/backends_spec.rb @@ -64,4 +64,36 @@ 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/rack/event_handler_spec.rb b/spec/lib/appsignal/rack/event_handler_spec.rb index 65308e8eb..7a20aaaf6 100644 --- a/spec/lib/appsignal/rack/event_handler_spec.rb +++ b/spec/lib/appsignal/rack/event_handler_spec.rb @@ -115,7 +115,7 @@ def on_error(error) expect(Appsignal::Transaction.current).to be_kind_of(Appsignal::Transaction::NilTransaction) - expect(last_transaction.ext.queue_start).to eq(queue_start_time) + 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" 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..7f6e81026 --- /dev/null +++ b/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb @@ -0,0 +1,941 @@ +# 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 + + 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 eae3aed2d..c931db767 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", @@ -739,520 +920,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 @@ -1263,91 +2171,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", @@ -1357,10 +2340,13 @@ Time.parse("10-10-2010 10:00:00 UTC") ) end - transaction._sample end - it "stores last breadcrumbs on the transaction" do + it "stores last breadcrumbs on the transaction in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + transaction.complete + expect(transaction.to_h["sample_data"]["breadcrumbs"].length).to eql(20) expect(transaction.to_h["sample_data"]["breadcrumbs"][0]).to eq( "action" => "GET http://localhost", @@ -1377,13 +2363,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( @@ -1394,15 +2425,32 @@ be_between(timeframe_start, timeframe_end) ) end + + it "emits a breadcrumb event with defaults on the span in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + events = breadcrumb_events + expect(events.length).to eq(1) + expect(events.first.attributes).to include( + "category" => "user_action", + "action" => "clicked HOME", + "message" => "", + "metadata" => "{}" + ) + end end context "with metadata argument that's not a Hash" do - it "does not add the breadcrumb and logs and error" do - logs = - capture_logs do - transaction.add_breadcrumb("category", "action", "message", "invalid metadata") - end - transaction._sample + def perform + transaction.add_breadcrumb("category", "action", "message", "invalid metadata") + end + + it "does not add the breadcrumb and logs an error in agent mode", :agent_mode do + start_agent(**start_agent_args) + logs = capture_logs { perform } + transaction.complete expect(transaction).to_not include_breadcrumbs expect(logs).to contains_log( @@ -1410,30 +2458,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 @@ -1442,262 +2531,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 @@ -1762,47 +3169,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| @@ -1922,17 +3482,31 @@ def to_s "\"index_users_on_email\" DETAIL: Key (email)=(test@test.com) already exists." ) end - before do - stub_const("PG::UniqueViolation", Class.new(StandardError)) + let(:sanitized_message) do + "ERROR: duplicate key value violates unique constraint " \ + "\"index_users_on_email\" DETAIL: Key (email)=(?) already exists." + end + before { stub_const("PG::UniqueViolation", Class.new(StandardError)) } + + def perform transaction.add_error(error) end - it "returns a sanizited error message" do - expect(transaction).to have_error( - "PG::UniqueViolation", - "ERROR: duplicate key value violates unique constraint " \ - "\"index_users_on_email\" DETAIL: Key (email)=(?) already exists." - ) + it "returns a sanizited error message in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + + expect(transaction).to have_error("PG::UniqueViolation", sanitized_message) + end + + it "records a sanitized error message in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + event = exception_event + expect(event.attributes["exception.type"]).to eq("PG::UniqueViolation") + expect(event.attributes["exception.message"]).to eq(sanitized_message) end end @@ -1943,26 +3517,45 @@ def to_s "\"example_constraint\"\nDETAIL: Key (email)=(foo@example.com) already exists." ) end - before do - stub_const("ActiveRecord::RecordNotUnique", Class.new(StandardError)) + let(:sanitized_message) do + "PG::UniqueViolation: ERROR: duplicate key value violates unique constraint " \ + "\"example_constraint\"\nDETAIL: Key (email)=(?) already exists." + end + before { stub_const("ActiveRecord::RecordNotUnique", Class.new(StandardError)) } + + def perform transaction.add_error(error) end - it "returns a sanizited error message" do - expect(transaction).to have_error( - "ActiveRecord::RecordNotUnique", - "PG::UniqueViolation: ERROR: duplicate key value violates unique constraint " \ - "\"example_constraint\"\nDETAIL: Key (email)=(?) already exists." - ) + it "returns a sanizited error message in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + + expect(transaction).to have_error("ActiveRecord::RecordNotUnique", sanitized_message) + end + + it "records a sanitized error message in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + event = exception_event + expect(event.attributes["exception.type"]).to eq("ActiveRecord::RecordNotUnique") + expect(event.attributes["exception.message"]).to eq(sanitized_message) end end context "with Rails module but without backtrace_cleaner method" do - it "returns the backtrace uncleaned" do + def perform stub_const("Rails", Module.new) error = ExampleStandardError.new("error message") error.set_backtrace(["line 1", "line 2"]) transaction.add_error(error) + end + + it "returns the backtrace uncleaned in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform expect(last_transaction).to have_error( "ExampleStandardError", @@ -1970,6 +3563,15 @@ def to_s ["line 1", "line 2"] ) end + + it "records the backtrace uncleaned in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + event = exception_event + expect(event.attributes["exception.stacktrace"]).to eq("line 1\nline 2") + end end if rails_present? @@ -1988,18 +3590,40 @@ def to_s ::Rails.backtrace_cleaner.add_filter(&test_filter) end - it "cleans the backtrace with the Rails backtrace cleaner" do + def perform error = ExampleStandardError.new("error message") error.set_backtrace(["line 1", "line 2"]) transaction.add_error(error) + end + + it "cleans the backtrace with the Rails backtrace cleaner in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + expect(last_transaction).to have_error( "ExampleStandardError", "error message", ["line 1", "line ?"] ) end + + it "cleans the backtrace with the Rails backtrace cleaner in collector mode", + :collector_mode do + start_collector_agent + perform + transaction.complete + + event = exception_event + expect(event.attributes["exception.stacktrace"]).to eq("line 1\nline ?") + end end end + + # The completed root span's sole `exception` span-event, for asserting + # collector-mode error attributes. + def exception_event + root_span.events.find { |event| event.name == "exception" } + end end describe "#_set_error" do @@ -2011,6 +3635,11 @@ def to_s end end + # The completed root span's sole `exception` span-event. + def exception_event + root_span.events.find { |event| event.name == "exception" } + end + it "responds to add_exception for backwards compatibility" do expect(transaction).to respond_to(:add_exception) end @@ -2034,11 +3663,20 @@ def to_s end context "when the error has no causes" do - it "should set an empty causes array as sample data" do + it "should set an empty causes array as sample data", :agent_mode do + start_agent(**start_agent_args) transaction.send(:_set_error, error) expect(transaction).to include_error_causes([]) end + + it "sets no error causes attribute in collector mode", :collector_mode do + start_collector_agent + transaction.send(:_set_error, error) + transaction.complete + + expect(exception_event.attributes).not_to have_key("appsignal.error_causes") + end end context "when the error has multiple causes" do @@ -2076,7 +3714,8 @@ def to_s end let(:options) { { :revision => "my_revision" } } - it "sends the error causes information as sample data" do + it "sends the error causes information as sample data", :agent_mode do + start_agent(**start_agent_args) # Hide Rails so we can test the normal Ruby behavior. The Rails # behavior is tested in another spec. hide_const("Rails") @@ -2127,6 +3766,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) @@ -2298,7 +3976,8 @@ def to_s e end - it "sends only the first causes as sample data" do + it "sends only the first causes as sample data", :agent_mode do + start_agent(**start_agent_args) expected_error_causes = Array.new(10) do |i| { @@ -2324,6 +4003,33 @@ def to_s "will be reported." ) end + + it "records only the first causes on the exception event in collector mode", + :collector_mode do + start_collector_agent + expected_error_causes = + Array.new(10) do |i| + { + "name" => "ExampleStandardError", + "message" => "wrapper error #{9 - i}", + "lines" => [] + } + end + + logs = capture_logs do + transaction.send(:_set_error, error) + transaction.complete + end + + expect(JSON.parse(exception_event.attributes["appsignal.error_causes"])) + .to eq(expected_error_causes) + expect(logs).to contains_log( + :debug, + "Appsignal::Transaction#add_error: Error has more " \ + "than 10 error causes. Only the first 10 " \ + "will be reported." + ) + end end context "when error message is nil" do @@ -2338,7 +4044,8 @@ def to_s transaction.send(:_set_error, error) end - it "sets an error on the transaction without an error message" do + it "sets an error on the transaction without an error message", :agent_mode do + start_agent(**start_agent_args) transaction.send(:_set_error, error) expect(transaction).to have_error( @@ -2347,6 +4054,16 @@ def to_s ["line 1"] ) end + + it "records an empty error message on the exception event in collector mode", + :collector_mode do + start_collector_agent + transaction.send(:_set_error, error) + transaction.complete + + expect(exception_event.attributes["exception.type"]).to eq("ExampleStandardError") + expect(exception_event.attributes["exception.message"]).to eq("") + end end end @@ -2487,7 +4204,8 @@ def to_s end context "when the transaction has several errors" do - it "calls the given hook for each of the duplicate error transactions" do + it "calls the given hook for each of the duplicate error transactions", :agent_mode do + start_agent(**start_agent_args) block = proc do |transaction, error| transaction.set_action(error.message) end @@ -2512,6 +4230,26 @@ def to_s have_action("hook_error_second") ) end + + it "calls the hook once with the first error in collector mode", :collector_mode do + start_collector_agent + block = proc do |transaction, error| + transaction.set_action(error.message) + end + + Appsignal::Transaction.before_complete(&block) + + transaction = new_transaction + transaction.set_error(ExampleStandardError.new("hook_error_first")) + transaction.set_error(ExampleStandardError.new("hook_error_second")) + + expect(block).to receive(:call).once.and_call_original + + transaction.complete + + # One trace, so the hook runs once with the first error. + expect(root_span.name).to eq("hook_error_first") + end end context "when the transaction does not have an error" do @@ -2561,15 +4299,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 @@ -2578,15 +4324,13 @@ def to_s describe "#finish_event" do let(:transaction) { new_transaction } - let(:fake_gc_time) { 0 } it "should finish the event in the extension" do - expect(transaction.ext).to receive(:finish_event).with( + expect(transaction.backend).to receive(:finish_event).with( "name", "title", "body", - 1, - fake_gc_time + 1 ).and_call_original transaction.finish_event( @@ -2598,12 +4342,11 @@ def to_s end it "should finish the event in the extension with nil arguments" do - expect(transaction.ext).to receive(:finish_event).with( + expect(transaction.backend).to receive(:finish_event).with( "name", "", "", - 0, - fake_gc_time + 0 ).and_call_original transaction.finish_event( @@ -2617,7 +4360,7 @@ def to_s context "when transaction is paused" do it "does not finish the event" do transaction.pause! - expect(transaction.ext).to_not receive(:finish_event) + expect(transaction.backend).to_not receive(:finish_event) transaction.start_event end @@ -2626,16 +4369,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( @@ -2648,13 +4390,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( @@ -2669,7 +4411,7 @@ def to_s context "when transaction is paused" do it "does not record the event" do transaction.pause! - expect(transaction.ext).to_not receive(:record_event) + expect(transaction.backend).to_not receive(:record_event) transaction.record_event( "name", @@ -2680,6 +4422,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 @@ -2687,6 +4465,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 @@ -2711,7 +4668,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 edf5c75c6..b7b3924d7 100644 --- a/spec/lib/appsignal_spec.rb +++ b/spec/lib/appsignal_spec.rb @@ -1140,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 - expect(last_transaction).to have_action("custom") + it "in collector mode", :collector_mode do + start_collector_agent + perform + + 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 - expect(last_transaction).to have_action("custom") + 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(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 - expect(last_transaction).to_not have_action + 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(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 - expect(last_transaction).to_not have_action + it "in collector mode", :collector_mode do + start_collector_agent + perform + + 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 - expect(last_transaction).to have_error("ExampleException", "error message") + it "in collector mode", :collector_mode do + start_collector_agent + perform + + event = root_span.events.find { |e| e.name == "exception" } + expect(event).not_to be_nil + expect(event.attributes["exception.type"]).to eq("ExampleException") + expect(event.attributes["exception.message"]).to eq("error message") + expect(event.attributes["exception.stacktrace"]).to be_a(String) + expect(event.attributes["appsignal.alert_this_error"]).to eq(true) + expect(root_span.status.code).to eq(::OpenTelemetry::Trace::Status::ERROR) + end end - 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 = @@ -1243,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 - expect(transaction).to have_namespace(Appsignal::Transaction::HTTP_REQUEST) + it "in agent mode", :agent_mode do + start_agent + perform + + 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 - expect(transaction).to have_action("My action") + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + expect(root_span.name).to eq("My action") + expect(root_span.attributes["appsignal.action_name"]).to eq("My action") + 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 @@ -1295,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 @@ -1330,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") @@ -1393,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") @@ -1439,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") @@ -1485,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( @@ -1508,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") @@ -1533,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", @@ -1547,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", @@ -1557,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 @@ -1610,15 +1916,44 @@ def on_start keep_transactions { example.run } end - it "sends the error to AppSignal" do - expect { Appsignal.send_error(error) }.to(change { created_transactions.count }.by(1)) + describe "sending the error" do + def perform + Appsignal.send_error(error) + end - 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 + it "in agent mode", :agent_mode do + start_agent + expect { perform }.to(change { created_transactions.count }.by(1)) + + transaction = last_transaction + expect(transaction).to have_namespace(Appsignal::Transaction::HTTP_REQUEST) + expect(transaction).to_not have_action + expect(transaction).to have_error("ExampleException", "error message") + expect(transaction).to_not include_tags + expect(transaction).to be_completed + end + + it "in collector mode", :collector_mode do + start_collector_agent + # send_error completes its throwaway transaction inline, so the root + # span is already finished and exported. + perform + + expect(root_span).not_to be_nil + # HTTP_REQUEST maps to a SERVER span (a subtrace root). + expect(root_span.kind).to eq(:server) + expect(root_span.attributes["appsignal.namespace"]) + .to eq("web") + expect(root_span.attributes).not_to have_key("appsignal.action_name") + + event = root_span.events.find { |e| e.name == "exception" } + expect(event).not_to be_nil + expect(event.attributes["exception.type"]).to eq("ExampleException") + expect(event.attributes["exception.message"]).to eq("error message") + expect(event.attributes["exception.stacktrace"]).to be_a(String) + expect(event.attributes["appsignal.alert_this_error"]).to eq(true) + expect(root_span.status.code).to eq(::OpenTelemetry::Trace::Status::ERROR) + end end context "when given error is not an Exception" do @@ -1639,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 @@ -1695,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) @@ -1707,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 } @@ -1755,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 @@ -1778,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 @@ -1825,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 @@ -2001,63 +2468,6 @@ def on_start end end end - - describe ".instrument" do - it_behaves_like "instrument helper" do - let(:instrumenter) { Appsignal } - before { set_current_transaction(transaction) } - end - end - - describe ".instrument_sql" do - around { |example| keep_transactions { example.run } } - before { set_current_transaction(transaction) } - - it "creates an SQL event on the transaction" do - result = - Appsignal.instrument_sql "name", "title", "body" do - "return value" - end - - expect(result).to eq "return value" - expect(transaction).to include_event( - "name" => "name", - "title" => "title", - "body" => "body", - "body_format" => Appsignal::EventFormatter::SQL_BODY_FORMAT - ) - end - end - - describe ".ignore_instrumentation_events" do - around { |example| keep_transactions { example.run } } - let(:transaction) { http_request_transaction } - - context "with current transaction" do - before { set_current_transaction(transaction) } - - 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 - - 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 - end - - context "without current transaction" do - let(:transaction) { nil } - - it "does not crash" do - Appsignal.ignore_instrumentation_events { :do_nothing } - end - end - end end describe "custom metrics" do @@ -2238,6 +2648,192 @@ def perform 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::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 "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 + + 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 + + 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 + + it "in collector mode", :collector_mode do + start_collector_agent + set_current_transaction(transaction) + + 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 + describe "._start_logger" do let(:out_stream) { std_stream } let(:output) { out_stream.read } 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/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) From 9a611e9be9b19aaab3bb696df72212cf8a642244 Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Wed, 8 Jul 2026 17:49:55 +0200 Subject: [PATCH 06/69] Test integrations in collector mode Every existing event-emission example in the hook, integration, Rack and loader specs now runs in both agent and collector mode. The collector-mode half asserts the span shape: its name, category, body, kind and parent. No library code changes, so this exercises the trace backend through every integration. --- build_matrix.yml | 1 + spec/lib/appsignal/hooks/action_cable_spec.rb | 645 +++++--- .../lib/appsignal/hooks/action_mailer_spec.rb | 20 +- .../finish_with_state_shared_examples.rb | 83 +- .../instrument_shared_examples.rb | 298 +++- .../start_finish_shared_examples.rb | 162 +- .../active_support_notifications_spec.rb | 17 +- spec/lib/appsignal/hooks/at_exit_spec.rb | 60 +- spec/lib/appsignal/hooks/dry_monitor_spec.rb | 74 +- spec/lib/appsignal/hooks/rake_spec.rb | 158 +- spec/lib/appsignal/hooks/redis_client_spec.rb | 210 ++- spec/lib/appsignal/hooks/redis_spec.rb | 107 +- spec/lib/appsignal/hooks/sequel_spec.rb | 46 +- .../active_support_event_reporter_spec.rb | 50 +- .../integrations/code_ownership_spec.rb | 88 +- .../integrations/data_mapper_spec.rb | 64 +- .../integrations/mongo_ruby_driver_spec.rb | 80 +- .../lib/appsignal/integrations/object_spec.rb | 208 ++- .../appsignal/integrations/ownership_spec.rb | 343 ++-- spec/lib/appsignal/integrations/puma_spec.rb | 263 +++- .../appsignal/integrations/railtie_spec.rb | 654 ++++++-- .../appsignal/integrations/webmachine_spec.rb | 186 ++- spec/lib/appsignal/loaders/grape_spec.rb | 2 +- spec/lib/appsignal/loaders/hanami_spec.rb | 69 +- spec/lib/appsignal/loaders/padrino_spec.rb | 200 ++- .../rack/abstract_middleware_spec.rb | 699 +++++++-- spec/lib/appsignal/rack/body_wrapper_spec.rb | 1379 ++++++++++++----- spec/lib/appsignal/rack/event_handler_spec.rb | 952 ++++++++++-- .../appsignal/rack/grape_middleware_spec.rb | 163 +- .../appsignal/rack/hanami_middleware_spec.rb | 89 +- .../rack/instrumentation_middleware_spec.rb | 68 +- .../rack/rails_instrumentation_spec.rb | 273 +++- .../rack/sinatra_instrumentation_spec.rb | 242 ++- 33 files changed, 6146 insertions(+), 1807 deletions(-) diff --git a/build_matrix.yml b/build_matrix.yml index 363a2f370..db976afa9 100644 --- a/build_matrix.yml +++ b/build_matrix.yml @@ -293,6 +293,7 @@ matrix: - "3.4.1" - "3.3.4" - "3.2.5" + - gem: "mongo" - gem: "resque-2" - gem: "resque-3" only: diff --git a/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/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 ca72877ec..cacf7dfe1 100644 --- a/spec/lib/appsignal/hooks/dry_monitor_spec.rb +++ b/spec/lib/appsignal/hooks/dry_monitor_spec.rb @@ -33,13 +33,8 @@ describe "Dry Monitor Integration" do let(:notifications) { Dry::Monitor::Notifications.new(:test) } - let(:transaction) { http_request_transaction } - before do - start_agent - set_current_transaction(transaction) - end - context "when is a dry-sql event" do + describe "a SQL event" do let(:event_id) { :sql } let(:payload) do { @@ -48,8 +43,15 @@ } end - it "creates an sql event named after ROM" do + def perform notifications.instrument(event_id, payload) + end + + it "in agent mode", :agent_mode do + start_agent + transaction = http_request_transaction + set_current_transaction(transaction) + perform expect(transaction).to include_event( "body" => "SELECT * FROM users", "body_format" => Appsignal::EventFormatter::SQL_BODY_FORMAT, @@ -58,18 +60,42 @@ "title" => "" ) end + + it "in collector mode", :collector_mode do + start_collector_agent + transaction = http_request_transaction + set_current_transaction(transaction) + perform + Appsignal::Transaction.complete_current! + + expect(event_spans.size).to eq(1) + span = event_spans.first + expect(span.name).to eq("query.rom") + expect(span.parent_span_id).to eq(root_span.span_id) + # ROM emits its queries as dry-monitor `sql` events; a query is an + # outgoing call, so it carries CLIENT kind. + expect(span.kind).to eq(:client) + attrs = span.attributes + expect(attrs["db.query.text"]).to eq("SELECT * FROM users") + expect(attrs["db.system.name"]).to eq("other_sql") + expect(attrs["appsignal.category"]).to eq("query.rom") + 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 in the dry-monitor group" do + def perform notifications.instrument(event_id, payload) + end + + it "in agent mode", :agent_mode do + start_agent + transaction = http_request_transaction + set_current_transaction(transaction) + perform expect(transaction).to include_event( "body" => "", "body_format" => Appsignal::EventFormatter::DEFAULT, @@ -78,6 +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.dry") + expect(span.parent_span_id).to eq(root_span.span_id) + # A non-SQL dry event is not an outgoing call, so it keeps the default kind. + expect(span.kind).to eq(:internal) + attrs = span.attributes + expect(attrs["appsignal.category"]).to eq("foo.dry") + expect(attrs).not_to have_key("appsignal.body") + expect(attrs).not_to have_key("db.query.text") + expect(attrs).not_to have_key("db.system.name") + end end end end diff --git a/spec/lib/appsignal/hooks/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/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/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/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/webmachine_spec.rb b/spec/lib/appsignal/integrations/webmachine_spec.rb index 471e563fe..ba3b1f648 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,130 @@ 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 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. + + 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 "sets the action" do - fsm.run - expect(last_transaction).to have_action("MyResource#GET") + 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 +216,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/rack/abstract_middleware_spec.rb b/spec/lib/appsignal/rack/abstract_middleware_spec.rb index e51bb02dc..737fd498f 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,126 @@ def make_request_with_error(error_class, error_message) context "when not active" do let(:appsignal_env) { :inactive_env } - it "does not instrument the request" do + it_in_both_modes "does not instrument the request" do expect { make_request }.to_not(change { created_transactions.count }) end - it "calls the next middleware in the stack" do + it_in_both_modes "calls the next middleware in the stack" do make_request expect(app).to be_called end end context "when appsignal is active" do - it "creates a transaction for the request" do - expect { make_request }.to(change { created_transactions.count }.by(1)) + describe "creates a transaction for the request" do + def perform + make_request + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + expect { perform }.to(change { created_transactions.count }.by(1)) + + expect(last_transaction).to have_namespace(Appsignal::Transaction::HTTP_REQUEST) + end + + it "in collector mode", :collector_mode do + start_collector_agent + expect { perform }.to(change { created_transactions.count }.by(1)) - expect(last_transaction).to have_namespace(Appsignal::Transaction::HTTP_REQUEST) + 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 + 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 +156,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 +182,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 + + expect(last_transaction).to have_error("ExampleException", "error message") + end - it "records the error" do - expect(last_transaction).to have_error("ExampleException", "error message") + it "in collector mode", :collector_mode do + start_collector_agent + perform + + event = root_span.events.find { |e| e.name == "exception" } + expect(event).not_to be_nil + expect(event.attributes["exception.type"]).to eq("ExampleException") + expect(event.attributes["exception.message"]).to eq("error message") + expect(event.attributes["exception.stacktrace"]).to be_a(String) + expect(event.attributes["appsignal.alert_this_error"]).to eq(true) + expect(root_span.status.code).to eq(::OpenTelemetry::Trace::Status::ERROR) + end end - it "completes the transaction" do + it_in_both_modes "completes the transaction" do + make_request_with_error(ExampleException, "error message") + expect(last_transaction).to be_completed expect(Appsignal::Transaction.current) .to be_kind_of(Appsignal::Transaction::NilTransaction) end context "with :report_errors set to false" do - let(:app) { lambda { |_env| raise ExampleException, "error message" } } let(:options) { { :report_errors => false } } - it "does not record the exception on the transaction" do - expect(last_transaction).to_not have_error + describe "does not record the exception on the transaction" do + def perform + make_request_with_error(ExampleException, "error message") + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + + expect(last_transaction).to_not have_error + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + + expect(exception_events).to be_empty + end end end context "with :report_errors set to true" do - let(:app) { lambda { |_env| raise ExampleException, "error message" } } let(:options) { { :report_errors => true } } - it "records the exception on the transaction" do - expect(last_transaction).to have_error("ExampleException", "error message") + describe "records the exception on the transaction" do + def perform + make_request_with_error(ExampleException, "error message") + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + + expect(last_transaction).to have_error("ExampleException", "error message") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + + event = root_span.events.find { |e| e.name == "exception" } + expect(event).not_to be_nil + expect(event.attributes["exception.type"]).to eq("ExampleException") + expect(event.attributes["exception.message"]).to eq("error message") + expect(event.attributes["exception.stacktrace"]).to be_a(String) + expect(event.attributes["appsignal.alert_this_error"]).to eq(true) + expect(root_span.status.code).to eq(::OpenTelemetry::Trace::Status::ERROR) + end end end context "with :report_errors set to a lambda that returns false" do - let(:app) { lambda { |_env| raise ExampleException, "error message" } } let(:options) { { :report_errors => lambda { |_env| false } } } - it "does not record the exception on the transaction" do - expect(last_transaction).to_not have_error + describe "does not record the exception on the transaction" do + def perform + make_request_with_error(ExampleException, "error message") + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + + expect(last_transaction).to_not have_error + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + + expect(exception_events).to be_empty + end end end context "with :report_errors set to a lambda that returns true" do - let(:app) { lambda { |_env| raise ExampleException, "error message" } } let(:options) { { :report_errors => lambda { |_env| true } } } - it "records the exception on the transaction" do - expect(last_transaction).to have_error("ExampleException", "error message") + describe "records the exception on the transaction" do + def perform + make_request_with_error(ExampleException, "error message") + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + + expect(last_transaction).to have_error("ExampleException", "error message") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + + event = root_span.events.find { |e| e.name == "exception" } + expect(event).not_to be_nil + expect(event.attributes["exception.type"]).to eq("ExampleException") + expect(event.attributes["exception.message"]).to eq("error message") + expect(event.attributes["exception.stacktrace"]).to be_a(String) + expect(event.attributes["appsignal.alert_this_error"]).to eq(true) + expect(root_span.status.code).to eq(::OpenTelemetry::Trace::Status::ERROR) + end end end end end context "without action name metadata" do - it "reports no action name" do - make_request + describe "reports no action name" do + def perform + make_request + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + + expect(last_transaction).to_not have_action + 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 # 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 - expect(last_transaction).to include_params( - "page" => "2", - "query" => "lorem" - ) + it "in collector mode", :collector_mode do + start_collector_agent + perform + + params = JSON.parse(root_span.attributes["appsignal.request.payload"]) + expect(params).to include("page" => "2", "query" => "lorem") + end end - it "sets session data" do - make_request + describe "sets session data" do + def perform + make_request + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + + expect(last_transaction).to include_session_data("session" => "data", "user_id" => 123) + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform - expect(last_transaction).to include_session_data("session" => "data", "user_id" => 123) + 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 + + 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 - expect(last_transaction).to have_queue_start(queue_start_time) + 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 +514,73 @@ def session { :request_class => SomeFilteredRequest, :params_method => :filtered_params } end - it "uses the overridden request class and params method to fetch params" do - make_request + describe "uses the overridden request class and params method to fetch params" do + def perform + make_request + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + + expect(last_transaction).to include_params("abc" => "123") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform - expect(last_transaction).to include_params("abc" => "123") + params = JSON.parse(root_span.attributes["appsignal.request.payload"]) + expect(params).to include("abc" => "123") + end end - it "uses the overridden request class to fetch session data" do - make_request + describe "uses the overridden request class to fetch session data" do + def perform + make_request + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform - expect(last_transaction).to include_session_data("data" => "value") + expect(last_transaction).to include_session_data("data" => "value") + 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("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 +589,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 - expect(last_transaction).to have_action("My custom action") + it "in collector mode", :collector_mode do + start_collector_agent + perform + Appsignal::Transaction.complete_current! + + expect(root_span.name).to eq("My custom action") + expect(root_span.attributes["appsignal.action_name"]).to eq("My custom action") + end end end @@ -325,10 +708,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 +735,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 + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform - 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 + 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 +768,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 +795,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 + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + + 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 + 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 7a20aaaf6..10c83e594 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,110 @@ 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 - transaction = last_transaction - expect(transaction).to have_id - expect(transaction).to have_namespace(Appsignal::Transaction::HTTP_REQUEST) + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + use_test_logger + expect { perform }.to change { created_transactions.length }.by(1) - expect(Appsignal::Transaction.current).to eq(transaction) + transaction = last_transaction + expect(transaction).to have_id + expect(transaction).to have_namespace(Appsignal::Transaction::HTTP_REQUEST) + + expect(Appsignal::Transaction.current).to eq(transaction) + end + + it "in collector mode", :collector_mode do + start_collector_agent + use_test_logger + expect { perform }.to change { created_transactions.length }.by(1) + + transaction = last_transaction + expect(Appsignal::Transaction.current).to eq(transaction) + # Finish the still-open root span so we can read the namespace it was + # opened with. + Appsignal::Transaction.complete_current! + expect(root_span.attributes["appsignal.namespace"]) + .to eq("web") + expect(root_span.kind).to eq(:server) + end end context "when not active" do let(:appsignal_env) { :inactive_env } - it "does not create a new transaction" do + it_in_both_modes "does not create a new transaction" do + use_test_logger expect { on_start }.to_not(change { created_transactions.length }) end end context "when the handler is nested in another EventHandler" do - it "does not create a new transaction in the nested EventHandler" do + it_in_both_modes "does not create a new transaction in the nested EventHandler" do + use_test_logger on_start expect { described_class.new.on_start(request, response) } .to_not(change { created_transactions.length }) end end - it "registers transaction on the request environment" do + it_in_both_modes "registers transaction on the request environment" do + use_test_logger on_start expect(request.env[Appsignal::Rack::APPSIGNAL_TRANSACTION]) .to eq(last_transaction) end - it "registers an rack.after_reply callback that completes the transaction" do - request.env[Appsignal::Rack::RACK_AFTER_REPLY] = [] - expect do - on_start - end.to change { request.env[Appsignal::Rack::RACK_AFTER_REPLY].length }.by(1) + describe "registers an rack.after_reply callback that completes the transaction" do + def perform + request.env[Appsignal::Rack::RACK_AFTER_REPLY] = [] + expect do + on_start + end.to change { request.env[Appsignal::Rack::RACK_AFTER_REPLY].length }.by(1) - expect(Appsignal::Transaction.current).to eq(last_transaction) + expect(Appsignal::Transaction.current).to eq(last_transaction) - callback = request.env[Appsignal::Rack::RACK_AFTER_REPLY].first - callback.call + callback = request.env[Appsignal::Rack::RACK_AFTER_REPLY].first + callback.call - expect(Appsignal::Transaction.current).to be_kind_of(Appsignal::Transaction::NilTransaction) + expect(Appsignal::Transaction.current).to be_kind_of(Appsignal::Transaction::NilTransaction) + end - expect(last_transaction.backend.queue_start).to eq(queue_start_time) - expect(last_transaction).to include_event( - "name" => "process_request.rack", - "title" => "callback: after_reply" - ) + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + use_test_logger + perform + + expect(last_transaction.backend.queue_start).to eq(queue_start_time) + expect(last_transaction).to include_event( + "name" => "process_request.rack", + "title" => "callback: after_reply" + ) + end + + it "in collector mode", :collector_mode do + start_collector_agent + use_test_logger + perform + + queue_event = Array(root_span.events).find { |e| e.name == "appsignal.queue_start" } + expect(queue_event.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 +188,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 +207,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 +223,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 +238,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") + end - expect(last_transaction).to have_error("ExampleStandardError", "the error") + 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 - 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 + + expect(exception_events).to be_empty + end end end context "when the handler is nested in another EventHandler" do - it "does not report the error on the transaction" do - on_start - described_class.new.on_error(request, response, ExampleStandardError.new("the error")) + describe "does not report the error on the transaction" do + def perform + on_start + described_class.new.on_error(request, response, ExampleStandardError.new("the error")) + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + use_test_logger + perform + + expect(last_transaction).to_not have_error + end - expect(last_transaction).to_not have_error + 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 +352,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 +431,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 +463,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 +492,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 + + context "without a response" do + describe "sets params on the transaction" do + def perform + on_start + on_finish + end - it "does not report a response_status counter metric" do - expect(Appsignal).to_not receive(:increment_counter) - .with(:response_status, anything, anything) + 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 - on_start - on_finish(request, nil) + 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_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 have_action + expect(last_transaction).to be_completed + end + + 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 - expect(last_transaction).to include_tags("response_status" => 500) + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + use_test_logger + perform + + expect(last_transaction).to_not include_tags("response_status" => anything) 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.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_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_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 +828,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 + + 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) + 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 +1011,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 From 6ce933f7ac8741423eba5b816291f6cfb4a21bc7 Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Wed, 8 Jul 2026 17:52:22 +0200 Subject: [PATCH 07/69] Inject trace context into outgoing HTTP requests In collector mode the HTTP client integrations write the current W3C trace context onto their outgoing requests, so the service being called continues the same trace. Net::HTTP, HTTP.rb, Excon and Faraday each inject a `traceparent` header and mark the request span with CLIENT kind. HTTP.rb injects on every hop, and Excon injects through a middleware. Injection is a no-op outside collector mode. --- gemfiles/faraday-2.gemfile | 2 - lib/appsignal/hooks/http.rb | 5 + .../excon/appsignal_middleware.rb | 21 + lib/appsignal/integrations/faraday.rb | 24 +- lib/appsignal/integrations/http.rb | 22 +- lib/appsignal/integrations/net_http.rb | 7 +- spec/lib/appsignal/hooks/http_spec.rb | 5 + .../appsignal/integrations/faraday_spec.rb | 91 ++-- spec/lib/appsignal/integrations/http_spec.rb | 387 ++++++++++++++---- .../appsignal/integrations/net_http_spec.rb | 106 ++++- 10 files changed, 524 insertions(+), 146 deletions(-) create mode 100644 lib/appsignal/integrations/excon/appsignal_middleware.rb 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/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/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/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/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/integrations/faraday_spec.rb b/spec/lib/appsignal/integrations/faraday_spec.rb index f0b922fcf..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,39 +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. - # Asserted on the whole list of event names, so that an Excon event with - # any title at all fails this. - expect(transaction.to_h["events"].map { |event| event["name"] }) - .to eq(["request.faraday"]) - 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/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 From 37f577c264adf332e8215b8d50b17866034add77 Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Wed, 8 Jul 2026 17:54:47 +0200 Subject: [PATCH 08/69] Extract trace context from incoming web requests In collector mode the Rack middleware, the Rack event handler and the Webmachine integration read an incoming W3C `traceparent` header and pass it to the transaction. A web transaction continues under the remote span. A job transaction starts its own trace and links back to it. With no context, or outside collector mode, the root span stands alone. --- lib/appsignal/integrations/webmachine.rb | 11 ++- lib/appsignal/rack/abstract_middleware.rb | 5 +- lib/appsignal/rack/event_handler.rb | 5 +- lib/appsignal/transaction.rb | 12 ++- .../transaction/extension_backend.rb | 4 +- .../transaction/opentelemetry_backend.rb | 44 +++++++++-- sig/appsignal.rbi | 4 +- sig/appsignal.rbs | 2 +- .../appsignal/integrations/webmachine_spec.rb | 25 +++++++ .../rack/abstract_middleware_spec.rb | 21 ++++++ spec/lib/appsignal/rack/event_handler_spec.rb | 23 ++++++ .../transaction/opentelemetry_backend_spec.rb | 73 +++++++++++++++++++ 12 files changed, 212 insertions(+), 17 deletions(-) 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/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 721a81047..d98b0524e 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 @@ -38,7 +38,10 @@ def create(namespace) if Thread.current[:appsignal_transaction].nil? # If not, start a new transaction set_current_transaction( - Appsignal::Transaction.new(namespace) + Appsignal::Transaction.new( + namespace, + :opentelemetry_context => opentelemetry_context + ) ) else transaction = current @@ -162,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, backend: nil) + def initialize(namespace, id: SecureRandom.uuid, backend: nil, opentelemetry_context: nil) @transaction_id = id @action = nil @namespace = namespace @@ -182,7 +185,8 @@ def initialize(namespace, id: SecureRandom.uuid, backend: nil) @backend = backend || Appsignal::Backends.transaction.new( @transaction_id, - @namespace + @namespace, + :opentelemetry_context => opentelemetry_context ) run_after_create_hooks diff --git a/lib/appsignal/transaction/extension_backend.rb b/lib/appsignal/transaction/extension_backend.rb index add9b33a8..934defbd3 100644 --- a/lib/appsignal/transaction/extension_backend.rb +++ b/lib/appsignal/transaction/extension_backend.rb @@ -20,7 +20,9 @@ class ExtensionBackend < BaseBackend # @!visibility private attr_writer :breadcrumbs - def initialize(transaction_id, namespace, handle: nil) + # `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) || diff --git a/lib/appsignal/transaction/opentelemetry_backend.rb b/lib/appsignal/transaction/opentelemetry_backend.rb index 3a5f884af..8019e67b1 100644 --- a/lib/appsignal/transaction/opentelemetry_backend.rb +++ b/lib/appsignal/transaction/opentelemetry_backend.rb @@ -54,7 +54,7 @@ class OpenTelemetryBackend < BaseBackend # queue duration when `queue_start_ms > 946_681_200_000`. QUEUE_START_MIN = 946_681_200_000 - def initialize(transaction_id, namespace) + def initialize(transaction_id, namespace, opentelemetry_context: nil, **) super() @transaction_id = transaction_id @namespace = namespace @@ -65,7 +65,7 @@ def initialize(transaction_id, namespace) @start_time = Time.now kind = SPAN_KIND_BY_NAMESPACE.fetch(namespace, DEFAULT_SPAN_KIND) - @span = start_transaction_span(namespace, kind) + @span = start_transaction_span(namespace, kind, opentelemetry_context) @context_token = ::OpenTelemetry::Context.attach( ::OpenTelemetry::Trace.context_with_span(@span) ) @@ -334,10 +334,42 @@ def placeholder_span_name(namespace) "appsignal.transaction #{namespace}" end - # Open the transaction's root span. A transaction is its own unit of work, - # so it starts a plain root span that ignores any ambient OTel context. - def start_transaction_span(namespace, kind) - tracer.start_root_span(placeholder_span_name(namespace), :kind => kind) + # 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) diff --git a/sig/appsignal.rbi b/sig/appsignal.rbi index 389422232..f332e09f4 100644 --- a/sig/appsignal.rbi +++ b/sig/appsignal.rbi @@ -1695,8 +1695,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. diff --git a/sig/appsignal.rbs b/sig/appsignal.rbs index 41d1073a2..a4e13872d 100644 --- a/sig/appsignal.rbs +++ b/sig/appsignal.rbs @@ -1557,7 +1557,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. diff --git a/spec/lib/appsignal/integrations/webmachine_spec.rb b/spec/lib/appsignal/integrations/webmachine_spec.rb index ba3b1f648..2f1990dd9 100644 --- a/spec/lib/appsignal/integrations/webmachine_spec.rb +++ b/spec/lib/appsignal/integrations/webmachine_spec.rb @@ -157,6 +157,31 @@ def to_html 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 } # The parent is set inside each example rather than in a `before`: in diff --git a/spec/lib/appsignal/rack/abstract_middleware_spec.rb b/spec/lib/appsignal/rack/abstract_middleware_spec.rb index 737fd498f..e991d147e 100644 --- a/spec/lib/appsignal/rack/abstract_middleware_spec.rb +++ b/spec/lib/appsignal/rack/abstract_middleware_spec.rb @@ -61,6 +61,27 @@ def perform end end + describe "incoming trace context" do + let(:trace_id_hex) { "0af7651916cd43dd8448eb211c80319c" } + let(:span_id_hex) { "b7ad6b7169203331" } + + it "continues the upstream trace when a traceparent is present", :collector_mode do + env["HTTP_TRACEPARENT"] = "00-#{trace_id_hex}-#{span_id_hex}-01" + start_collector_agent + make_request + + expect(root_span.hex_trace_id).to eq(trace_id_hex) + expect(root_span.parent_span_id.unpack1("H*")).to eq(span_id_hex) + end + + 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) diff --git a/spec/lib/appsignal/rack/event_handler_spec.rb b/spec/lib/appsignal/rack/event_handler_spec.rb index 10c83e594..bdb86f6b5 100644 --- a/spec/lib/appsignal/rack/event_handler_spec.rb +++ b/spec/lib/appsignal/rack/event_handler_spec.rb @@ -108,6 +108,29 @@ def perform 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 } diff --git a/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb b/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb index 7f6e81026..081744c87 100644 --- a/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb +++ b/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb @@ -89,6 +89,79 @@ def event_names(finished) 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) From dc081b6e3da6fef4aa5819de059cb16137cef290 Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Wed, 8 Jul 2026 17:59:54 +0200 Subject: [PATCH 09/69] Propagate trace context across background jobs In collector mode each job library carries the W3C trace context across the enqueue and perform boundary. On enqueue the client writes the current context onto the job payload and marks the enqueue span with PRODUCER kind. On perform the server side reads it back and links the job's trace to the enqueuing span. Each library carries the context on its own channel, such as a job hash key, an SQS message attribute, a Que tag, or Active Job's serialized arguments. When a channel is full the propagation is skipped rather than the enqueue broken. --- lib/appsignal/hooks/active_job.rb | 121 +- lib/appsignal/hooks/resque.rb | 2 +- lib/appsignal/hooks/shoryuken.rb | 6 +- lib/appsignal/hooks/sidekiq.rb | 2 +- lib/appsignal/integrations/que.rb | 179 ++- lib/appsignal/integrations/resque.rb | 34 +- lib/appsignal/integrations/shoryuken.rb | 103 +- lib/appsignal/integrations/sidekiq.rb | 37 +- spec/lib/appsignal/hooks/activejob_spec.rb | 1259 +++++++++++++---- spec/lib/appsignal/integrations/que_spec.rb | 558 ++++++-- .../lib/appsignal/integrations/resque_spec.rb | 361 +++-- .../integrations/shoryuken_client_spec.rb | 54 +- .../appsignal/integrations/shoryuken_spec.rb | 449 ++++-- .../appsignal/integrations/sidekiq_spec.rb | 991 ++++++++++--- 14 files changed, 3235 insertions(+), 921 deletions(-) diff --git a/lib/appsignal/hooks/active_job.rb b/lib/appsignal/hooks/active_job.rb index b49c8e18d..11e315a69 100644 --- a/lib/appsignal/hooks/active_job.rb +++ b/lib/appsignal/hooks/active_job.rb @@ -30,8 +30,11 @@ def install ActiveSupport.on_load(:active_job) do ::ActiveJob::Base .extend ::Appsignal::Hooks::ActiveJobHook::ActiveJobClassInstrumentation + # Carry W3C trace context across the enqueue/perform boundary in + # collector mode (no-ops otherwise). The patches are cheap and + # mode-gated inside their method bodies, so install them unconditionally. ::ActiveJob::Base - .prepend ::Appsignal::Hooks::ActiveJobHook::ActiveJobEnqueueInstrumentation + .prepend ::Appsignal::Hooks::ActiveJobHook::ActiveJobTraceContext next unless Appsignal::Hooks::ActiveJobHook.version_7_1_or_higher? @@ -44,42 +47,6 @@ def install end end - # Records an `enqueue.active_job` event when a job is enqueued, so the - # enqueue shows up on the active transaction's timeline (e.g. when - # enqueuing from within a web request or another job). - # - # Wrapping `enqueue` ourselves -- rather than relying on Rails' native - # `enqueue.active_job` notification, which the AppSignal notifications - # path now suppresses -- gives us a single event we own. Like all - # AppSignal events, this only records when there's an active transaction; - # an enqueue with no transaction is a transparent pass-through. - # - # @!visibility private - module ActiveJobEnqueueInstrumentation - def enqueue(*, **) - # Skip recording the event when enqueue events are suppressed. That is - # the case when enqueue instrumentation is disabled, and it keeps this - # integration consistent with the standalone adapters (Sidekiq, ...), - # which already gate their own enqueue event on this check. - if Appsignal::Transaction.current? && - Appsignal::Transaction.current.job_enqueue_events_suppressed? - return super - end - - Appsignal.instrument("enqueue.active_job", "enqueue #{self.class.name} job") do - # Active Job enqueues through an adapter (Sidekiq, Resque, ...) that - # has its own enqueue instrumentation. Suppress it so the enqueue is - # recorded once, as this event, rather than as nested Active Job + - # adapter events. - if Appsignal::Transaction.current? - Appsignal::Transaction.current.suppress_job_enqueue_events { super } - else - super - end - end - end - end - module ActiveJobClassInstrumentation def execute(job) enqueued_at = job["enqueued_at"] @@ -101,8 +68,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 @@ -162,6 +138,75 @@ 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(*, **) + # Skip recording the event when enqueue events are suppressed. That is + # the case when enqueue instrumentation is disabled, and it keeps this + # integration consistent with the standalone adapters (Sidekiq, ...), + # which already gate their own enqueue event on this check. + if Appsignal::Transaction.current? && + Appsignal::Transaction.current.job_enqueue_events_suppressed? + return super + end + + Appsignal.instrument( + "enqueue.active_job", + "enqueue #{self.class.name} job", + :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/resque.rb b/lib/appsignal/hooks/resque.rb index 75cc6ad59..7bf8de0b6 100644 --- a/lib/appsignal/hooks/resque.rb +++ b/lib/appsignal/hooks/resque.rb @@ -15,7 +15,7 @@ def install Resque::Job.prepend Appsignal::Integrations::ResqueIntegration # Resque enqueues through the `Resque.push` singleton method, so prepend - # onto its singleton class to record the enqueue event. + # onto its singleton class to write the trace context onto outgoing jobs. Resque.singleton_class.prepend Appsignal::Integrations::ResquePushIntegration end end diff --git a/lib/appsignal/hooks/shoryuken.rb b/lib/appsignal/hooks/shoryuken.rb index 82d9dce88..d138117f6 100644 --- a/lib/appsignal/hooks/shoryuken.rb +++ b/lib/appsignal/hooks/shoryuken.rb @@ -19,9 +19,9 @@ def install end # Servers enqueue jobs too, so they need the client middleware that - # records the enqueue event. Shoryuken only yields `configure_client` - # outside the server, so register it here as well for enqueues from - # within a worker. + # writes the trace context onto outgoing messages. Shoryuken only + # yields `configure_client` outside the server, so register it here as + # well for enqueues from within a worker. config.client_middleware do |chain| chain.add Appsignal::Integrations::ShoryukenClientMiddleware end diff --git a/lib/appsignal/hooks/sidekiq.rb b/lib/appsignal/hooks/sidekiq.rb index 62178323e..2e8a949d0 100644 --- a/lib/appsignal/hooks/sidekiq.rb +++ b/lib/appsignal/hooks/sidekiq.rb @@ -45,7 +45,7 @@ def install end # Servers enqueue jobs too, so they need the client middleware that - # records the enqueue event. + # writes the trace context onto outgoing jobs. config.client_middleware do |chain| chain.add Appsignal::Integrations::SidekiqClientMiddleware end diff --git a/lib/appsignal/integrations/que.rb b/lib/appsignal/integrations/que.rb index ed67cd68e..a144400b1 100644 --- a/lib/appsignal/integrations/que.rb +++ b/lib/appsignal/integrations/que.rb @@ -2,11 +2,89 @@ 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 + + # Que only has tags from version 1.0 on, and they are the only carrier its + # enqueue API exposes. On Que 0.x there is nowhere to put the trace context + # that survives to the worker, so propagation is skipped there. Writing the + # context into the job's arguments instead would change the arguments the + # job is called with, which breaks the job. + TAGS_SUPPORTED = defined?(::Que::Job::MAXIMUM_TAGS_COUNT) + + # Read the incoming context off the job's tags. Splits each `"key:value"` + # tag on the first colon back into a carrier hash, then extracts. Returns + # an `OpenTelemetry::Context`, or `nil` outside collector mode and on Que + # versions without tags. + def extract(tags) + return unless TAGS_SUPPORTED + + Appsignal::OpenTelemetry.if_started do + carrier = Array(tags) + .map { |tag| tag.split(":", 2) } + .select { |pair| pair.size == 2 } + .to_h + ::OpenTelemetry.propagation.extract(carrier) + end + end + + # Returns the tags array to enqueue the job with. In collector mode that is + # a copy with the current context injected, kept only if it still fits + # Que's limits: propagation is skipped rather than break the enqueue. + # Outside collector mode, and on Que versions without tags, the tags are + # returned unchanged. + def inject(tags) + original = Array(tags) + return original unless TAGS_SUPPORTED + + 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 +92,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,36 +114,72 @@ def _run(*args) # @!visibility private # - # Prepended to `Que::Job`'s singleton so it records each enqueue as an - # `enqueue.que` event under the active transaction. Like all AppSignal - # events, it only records when there's an active transaction (e.g. enqueuing - # from within a web request or another job); otherwise it's a transparent - # pass-through. + # Prepended to `Que::Job`'s singleton so it wraps enqueues. Records the + # enqueue as an AppSignal event (a producer span in collector mode), and in + # collector mode writes the current trace context onto the job's tags so the + # job that later performs links back to it. Like all AppSignal events, the + # enqueue only records when there's an active transaction; otherwise it's a + # transparent pass-through. module QueClientPlugin - # The keyword arguments are captured into a single `**kwargs` hash, rather - # than declaring a `job_options:` keyword with a default, so that an - # implicit `super` forwards the original call unchanged. Declaring - # `job_options: {}` would bind that default even when the caller did not - # pass it, and `super` would then forward it to Que. On Que 0.14, whose - # `enqueue` takes only positional arguments, that extra keyword ends up - # persisted as an additional job argument. - def enqueue(*_args, **kwargs) - job_options = kwargs[:job_options] || {} - - # Inside a `bulk_enqueue` block the batch is recorded once by the - # `bulk_enqueue` wrapper, so each inner enqueue is a pass-through to - # avoid recording an event per job. + # The keyword arguments are captured into a single `kwargs` hash, rather + # than declaring a `job_options:` keyword with a default, so that a + # keyword the caller did not pass is never forwarded to Que. See + # `forward_job_options` for why that matters. + def enqueue(*args, **kwargs) + # Inside a `bulk_enqueue` block the per-job enqueue must stay a + # pass-through: tags come from `bulk_enqueue`'s own `job_options` (Que + # raises if an inner enqueue passes them), and the batch's event and + # propagation are recorded once by the `bulk_enqueue` wrapper. return super if Thread.current[:appsignal_que_bulk_enqueue] - # Under Active Job the enqueue is already recorded as an - # `enqueue.active_job` event, so skip recording it again here. - return super if Appsignal::Transaction.current? && - Appsignal::Transaction.current.job_enqueue_events_suppressed? + job_options = kwargs[:job_options] || {} # Resolve the job class the way Que does: an explicit `:job_class`, else # the class `enqueue` was called on. title = "enqueue #{job_options[:job_class] || name} job" - Appsignal.instrument("enqueue.que", title) { super } + record_enqueue(job_options, "enqueue.que", title) do |merged| + super(*args, **forward_job_options(kwargs, merged)) + end + end + + private + + # Builds the keyword arguments to enqueue the job with. Passes + # `job_options` on to Que only when the caller passed it, or when the + # trace context was injected into it. Adding a `job_options` keyword to a + # call that did not have one breaks Que 0.x: it does not recognise the + # keyword, so it stores it as an extra job argument, and the job then + # fails because it is called with one argument too many. + def forward_job_options(kwargs, job_options) + return kwargs unless kwargs.key?(:job_options) || job_options.any? + + kwargs.merge(:job_options => job_options) + end + + # Records the enqueue as a producer event and, in collector mode, injects + # the current trace context into the job's tags so the job that later + # performs links back. Yields the (possibly tag-augmented) `job_options` to + # do the actual enqueue. + def record_enqueue(job_options, event_name, title) + # 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 @@ -74,22 +187,18 @@ def enqueue(*_args, **kwargs) # # `bulk_enqueue` exists only on Que 2+, so this lives in its own module that # the hook prepends only when Que has the method -- otherwise we'd define a - # `bulk_enqueue` on Que versions that have none. The whole batch records a - # single `bulk_enqueue.que` event; the inner enqueues are pass-throughs. + # `bulk_enqueue` on Que versions that have none. The whole batch shares one + # `job_options`, so it records a single `bulk_enqueue.que` producer event and + # the inner enqueues are pass-throughs. module QueBulkClientPlugin - def bulk_enqueue(*_args, job_options: {}, **_rest) - # Under Active Job the enqueue is already recorded as an - # `enqueue.active_job` event, so skip recording it again here. - return super if Appsignal::Transaction.current? && - Appsignal::Transaction.current.job_enqueue_events_suppressed? - - Appsignal.instrument("bulk_enqueue.que", bulk_enqueue_title(job_options)) do + def bulk_enqueue(job_options: {}, **rest, &block) + record_enqueue(job_options, "bulk_enqueue.que", bulk_enqueue_title(job_options)) 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/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 fd1f178ae..1135e9431 100644 --- a/lib/appsignal/integrations/sidekiq.rb +++ b/lib/appsignal/integrations/sidekiq.rb @@ -98,24 +98,32 @@ def safe_load(content, default) end end - # Sidekiq client middleware that runs on enqueue. Records an - # `enqueue.sidekiq` event so the enqueue shows up under the active - # transaction. + # Client middleware that runs on enqueue. Records an `enqueue.sidekiq` + # event so the enqueue shows up under the active transaction (both modes), + # and in collector mode writes the trace context onto the job hash so the + # job that later performs links back to it. # # Like all AppSignal events, this only records when there's an active - # transaction (e.g. enqueuing from within a web request or another job). An - # enqueue with no transaction is a transparent pass-through. + # transaction (e.g. enqueuing from within a web request or another job). + # An enqueue with no transaction is a transparent pass-through. # # @!visibility private class SidekiqClientMiddleware - def call(_worker_class, job, _queue, _redis_pool, &block) + def call(_worker_class, job, _queue, _redis_pool) # Under Active Job the enqueue is already recorded as an - # `enqueue.active_job` event, so skip recording it again here. - return yield if Appsignal::Transaction.current? && - Appsignal::Transaction.current.job_enqueue_events_suppressed? + # `enqueue.active_job` event, so skip recording it again here. The trace + # context is still injected so the performed job links back. + if Appsignal::Transaction.current? && + Appsignal::Transaction.current.job_enqueue_events_suppressed? + Appsignal::OpenTelemetry.inject_context(job) + return yield + end title = "enqueue #{SidekiqActionName.parse_action_name(job)} job" - Appsignal.instrument("enqueue.sidekiq", title, &block) + Appsignal.instrument("enqueue.sidekiq", title, :opentelemetry_kind => :producer) do + Appsignal::OpenTelemetry.inject_context(job) + yield + end end end @@ -126,7 +134,7 @@ class SidekiqMiddleware EXCLUDED_JOB_KEYS = %w[ args backtrace class created_at enqueued_at error_backtrace error_class error_message failed_at jid retried_at retry wrapped cattr tags retry_for - unique_for + unique_for traceparent tracestate __otel_headers ].freeze def self.sidekiq8? @@ -138,7 +146,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/spec/lib/appsignal/hooks/activejob_spec.rb b/spec/lib/appsignal/hooks/activejob_spec.rb index 6a092ac87..b35b55212 100644 --- a/spec/lib/appsignal/hooks/activejob_spec.rb +++ b/spec/lib/appsignal/hooks/activejob_spec.rb @@ -83,10 +83,10 @@ end end let(:options) { {} } + let(:start_agent_args) { { :options => options } } before do ActiveJob::Base.queue_adapter = :inline - start_agent(:options => options) stub_const("ActiveJobTestJob", Class.new(ActiveJob::Base) do def perform(*_args) end @@ -113,41 +113,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 @@ -162,67 +211,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 @@ -230,35 +333,74 @@ def perform(*_args) context "with activejob_report_errors set to discard" do let(:options) { { :activejob_report_errors => "discard" } } - it "does not report error on first failure" do - with_test_adapter do - # Prevent the job from being instantly retried so we can test - # what happens before it's retried - allow_any_instance_of(ActiveJobErrorWithRetryTestJob).to receive(:retry_job) + describe "does not report error on first failure" do + def perform + with_test_adapter do + # Prevent the job from being instantly retried so we can test + # what happens before it's retried + allow_any_instance_of(ActiveJobErrorWithRetryTestJob).to receive(:retry_job) - queue_job(ActiveJobErrorWithRetryTestJob) + queue_job(ActiveJobErrorWithRetryTestJob) + end end - transaction = last_transaction - expect(transaction).to_not have_error - expect(transaction).to include_tags("executions" => 1) + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + + perform + + transaction = last_transaction + transaction._sample + expect(transaction).to_not have_error + expect(transaction).to include_tags("executions" => 1) + end + + it "in collector mode", :collector_mode do + start_collector_agent + + perform + last_transaction.complete + + expect(exception_events).to be_empty + expect(root_span.attributes["appsignal.tag.executions"]).to eq(1) + end end - it "reports error when discarding the job" do - allow(Appsignal).to receive(:increment_counter) - tags = { :queue => queue } - expect(Appsignal).to receive(:increment_counter) - .with("active_job_queue_job_count", 1, tags.merge(:status => :failed)) + describe "reports error when discarding the job" do + def perform + allow(Appsignal).to receive(:increment_counter) - with_test_adapter do - expect do + with_test_adapter do queue_job(ActiveJobErrorWithRetryTestJob) - end.to raise_error(RuntimeError, "uh oh") + end end - transaction = last_transaction - expect(transaction).to have_error("RuntimeError", "uh oh") - expect(transaction).to include_tags("executions" => 2) + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + + expect { perform }.to raise_error(RuntimeError, "uh oh") + + transaction = last_transaction + transaction._sample + expect(transaction).to have_error("RuntimeError", "uh oh") + expect(transaction).to include_tags("executions" => 2) + end + + it "in collector mode", :collector_mode do + start_collector_agent + + expect { perform }.to raise_error(RuntimeError, "uh oh") + last_transaction.complete + + event = exception_events.find do |e| + e.attributes["exception.type"] == "RuntimeError" + end + expect(event).not_to be_nil + expect(event.attributes["exception.message"]).to eq("uh oh") + expect(event.attributes["exception.stacktrace"]).to be_a(String) + expect(event.attributes["appsignal.alert_this_error"]).to eq(true) + expect(root_span.attributes["appsignal.tag.executions"]).to eq(2) + end end end end @@ -275,131 +417,285 @@ 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) - context "with an active transaction" do - it "records a single enqueue.active_job event on the transaction" do + job = ActiveJobTestJob.new + job.__otel_headers = { "traceparent" => traceparent } + + expect(job.serialize).to_not have_key("__otel_headers") + end + end + + describe "injecting context on enqueue" do + before { ActiveJob::Base.queue_adapter = :test } + + # Returns the enqueuing transaction so the example can read its events. + def enqueue_within_transaction transaction = http_request_transaction set_current_transaction(transaction) - ActiveJobTestJob.perform_later + transaction + end - # Exactly one enqueue event: ours. Rails' native `enqueue.active_job` + 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 + + 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 context "when enqueue instrumentation is disabled" do let(:options) { { :enable_job_enqueue_instrumentation => false } } + before { ActiveJob::Base.queue_adapter = :test } it "does not record an enqueue event but still enqueues the job" do + start_agent(**start_agent_args) transaction = http_request_transaction set_current_transaction(transaction) @@ -416,22 +712,50 @@ def perform(*_args) context "with params" do let(:options) { { :filter_parameters => ["foo"] } } - it "filters the configured params" do - queue_job(ActiveJobTestJob, method_given_args) + describe "filters the configured params" do + def perform + queue_job(ActiveJobTestJob, method_given_args) + end - transaction = last_transaction - transaction_hash = transaction.to_h - expect(transaction_hash["sample_data"]["params"]).to include( - [ - "foo", - { - "_aj_symbol_keys" => ["foo"], - "foo" => "[FILTERED]", - "bar" => "Bar", - "baz" => { "_aj_symbol_keys" => [], "1" => "foo" } - } - ] - ) + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + + perform + + transaction = last_transaction + transaction_hash = transaction.to_h + expect(transaction_hash["sample_data"]["params"]).to include( + [ + "foo", + { + "_aj_symbol_keys" => ["foo"], + "foo" => "[FILTERED]", + "bar" => "Bar", + "baz" => { "_aj_symbol_keys" => [], "1" => "foo" } + } + ] + ) + end + + it "in collector mode", :collector_mode do + start_collector_agent + + perform + last_transaction.complete + + params = JSON.parse(root_span.attributes["appsignal.function.parameters"]) + expect(params).to include( + [ + "foo", + { + "_aj_symbol_keys" => ["foo"], + "foo" => "[FILTERED]", + "bar" => "Bar", + "baz" => { "_aj_symbol_keys" => [], "1" => "foo" } + } + ] + ) + end end end @@ -462,12 +786,29 @@ def perform(*_args) end) end - it "sets provider_job_id as tag" do - queue_job(ProviderWrappedActiveJobTestJob) + describe "sets provider_job_id as tag" do + def perform + queue_job(ProviderWrappedActiveJobTestJob) + end - expect(last_transaction).to include_tags( - "provider_job_id" => "my_provider_job_id" - ) + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + + perform + expect(last_transaction).to include_tags( + "provider_job_id" => "my_provider_job_id" + ) + end + + it "in collector mode", :collector_mode do + start_collector_agent + + perform + last_transaction.complete + + expect(root_span.attributes["appsignal.tag.provider_job_id"]) + .to eq("my_provider_job_id") + end end end @@ -499,22 +840,17 @@ def perform(*_args) end) end - it "sets queue time on transaction" do + # `have_queue_start` reads agent-only backend state, so this stays + # agent-only. In collector mode the queue start surfaces as a span event + # and `transaction_queue_duration` metric (covered in the transaction spec). + it "sets queue time on transaction", :agent_mode do + start_agent(**start_agent_args) + queue_job(ProviderWrappedActiveJobTestJob) queue_time = Time.parse("2001-01-01T09:00:00.000000000Z") expect(last_transaction).to have_queue_start((queue_time.to_f * 1_000).to_i) end - - it "reports the queue time" do - allow(Appsignal).to receive(:add_distribution_value) - - queue_job(ProviderWrappedActiveJobTestJob) - - # Asserts 1 hour queue time - expect(Appsignal).to have_received(:add_distribution_value) - .with("active_job_queue_time", 3_600_000.0, :queue => queue) - end end context "with ActionMailer job" do @@ -528,55 +864,67 @@ def welcome(_first_arg = nil, _second_arg = nil) end context "without params" do - it "sets the Action mailer data on the transaction" do - perform_mailer(ActionMailerTestJob, :welcome) + describe "sets the Action mailer data on the transaction" do + def perform + perform_mailer(ActionMailerTestJob, :welcome) + end - transaction = last_transaction - expect(transaction).to have_action("ActionMailerTestJob#welcome") - expect(transaction).to include_params( - ["ActionMailerTestJob", "welcome", "deliver_now"] + active_job_args_wrapper - ) - expect(transaction).to include_tags( - "active_job_id" => kind_of(String), - "request_id" => kind_of(String), - "queue" => "mailers", - "executions" => 1 - ) + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + + perform + + transaction = last_transaction + transaction._sample + expect(transaction).to have_action("ActionMailerTestJob#welcome") + expect(transaction).to include_params( + ["ActionMailerTestJob", "welcome", "deliver_now"] + active_job_args_wrapper + ) + expect(transaction).to include_tags( + "active_job_id" => kind_of(String), + "request_id" => kind_of(String), + "queue" => "mailers", + "executions" => 1 + ) + end + + it "in collector mode", :collector_mode do + start_collector_agent + + perform + last_transaction.complete + + expect(root_span.attributes["appsignal.action_name"]) + .to eq("ActionMailerTestJob#welcome") + expected_params = + ["ActionMailerTestJob", "welcome", "deliver_now"] + active_job_args_wrapper + expect(JSON.parse(root_span.attributes["appsignal.function.parameters"])) + .to eq(expected_params) + expect(root_span.attributes["appsignal.tag.active_job_id"]).to be_a(String) + expect(root_span.attributes["appsignal.tag.request_id"]).to be_a(String) + expect(root_span.attributes["appsignal.tag.queue"]).to eq("mailers") + expect(root_span.attributes["appsignal.tag.executions"]).to eq(1) + end end end context "with multiple arguments" do - it "sets the arguments on the transaction" do - perform_mailer(ActionMailerTestJob, :welcome, method_given_args) + describe "sets the arguments on the transaction" do + def perform + perform_mailer(ActionMailerTestJob, :welcome, method_given_args) + end - transaction = last_transaction - expect(transaction).to have_action("ActionMailerTestJob#welcome") - expect(transaction).to include_params( - ["ActionMailerTestJob", "welcome", - "deliver_now"] + active_job_args_wrapper(:args => method_expected_args) - ) - expect(transaction).to include_tags( - "active_job_id" => kind_of(String), - "request_id" => kind_of(String), - "queue" => "mailers", - "executions" => 1 - ) - end - end + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) - if DependencyHelper.rails_version >= Gem::Version.new("5.2.0") - context "with parameterized arguments" do - it "sets the arguments on the transaction" do - perform_mailer(ActionMailerTestJob, :welcome, parameterized_given_args) + perform transaction = last_transaction + transaction._sample expect(transaction).to have_action("ActionMailerTestJob#welcome") expect(transaction).to include_params( - [ - "ActionMailerTestJob", - "welcome", - "deliver_now" - ] + active_job_args_wrapper(:params => parameterized_expected_args) + ["ActionMailerTestJob", "welcome", + "deliver_now"] + active_job_args_wrapper(:args => method_expected_args) ) expect(transaction).to include_tags( "active_job_id" => kind_of(String), @@ -585,6 +933,80 @@ def welcome(_first_arg = nil, _second_arg = nil) "executions" => 1 ) end + + it "in collector mode", :collector_mode do + start_collector_agent + + perform + last_transaction.complete + + expect(root_span.attributes["appsignal.action_name"]) + .to eq("ActionMailerTestJob#welcome") + expected_params = + ["ActionMailerTestJob", "welcome", + "deliver_now"] + active_job_args_wrapper(:args => method_expected_args) + expect(JSON.parse(root_span.attributes["appsignal.function.parameters"])) + .to eq(expected_params) + expect(root_span.attributes["appsignal.tag.active_job_id"]).to be_a(String) + expect(root_span.attributes["appsignal.tag.request_id"]).to be_a(String) + expect(root_span.attributes["appsignal.tag.queue"]).to eq("mailers") + expect(root_span.attributes["appsignal.tag.executions"]).to eq(1) + end + end + end + + if DependencyHelper.rails_version >= Gem::Version.new("5.2.0") + context "with parameterized arguments" do + describe "sets the arguments on the transaction" do + def perform + perform_mailer(ActionMailerTestJob, :welcome, parameterized_given_args) + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + + perform + + transaction = last_transaction + transaction._sample + expect(transaction).to have_action("ActionMailerTestJob#welcome") + expect(transaction).to include_params( + [ + "ActionMailerTestJob", + "welcome", + "deliver_now" + ] + active_job_args_wrapper(:params => parameterized_expected_args) + ) + expect(transaction).to include_tags( + "active_job_id" => kind_of(String), + "request_id" => kind_of(String), + "queue" => "mailers", + "executions" => 1 + ) + end + + it "in collector mode", :collector_mode do + start_collector_agent + + perform + last_transaction.complete + + expect(root_span.attributes["appsignal.action_name"]) + .to eq("ActionMailerTestJob#welcome") + expected_params = + [ + "ActionMailerTestJob", + "welcome", + "deliver_now" + ] + active_job_args_wrapper(:params => parameterized_expected_args) + expect(JSON.parse(root_span.attributes["appsignal.function.parameters"])) + .to eq(expected_params) + expect(root_span.attributes["appsignal.tag.active_job_id"]).to be_a(String) + expect(root_span.attributes["appsignal.tag.request_id"]).to be_a(String) + expect(root_span.attributes["appsignal.tag.queue"]).to eq("mailers") + expect(root_span.attributes["appsignal.tag.executions"]).to eq(1) + end + end end end end @@ -602,42 +1024,25 @@ def welcome(*_args) end) end - it "sets the Action mailer data on the transaction" do - perform_mailer(ActionMailerTestMailDeliveryJob, :welcome) + describe "sets the Action mailer data on the transaction" do + def perform + perform_mailer(ActionMailerTestMailDeliveryJob, :welcome) + end - transaction = last_transaction - expect(transaction).to have_action("ActionMailerTestMailDeliveryJob#welcome") - expect(transaction).to include_params( - [ - "ActionMailerTestMailDeliveryJob", - "welcome", - "deliver_now", - { active_job_internal_key => ["args"], "args" => [] } - ] - ) - expect(transaction).to include_tags( - "active_job_id" => kind_of(String), - "request_id" => kind_of(String), - "queue" => "mailers", - "executions" => 1 - ) - end + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) - context "with method arguments" do - it "sets the Action mailer data on the transaction" do - perform_mailer(ActionMailerTestMailDeliveryJob, :welcome, method_given_args) + perform transaction = last_transaction + transaction._sample expect(transaction).to have_action("ActionMailerTestMailDeliveryJob#welcome") expect(transaction).to include_params( [ "ActionMailerTestMailDeliveryJob", "welcome", "deliver_now", - { - active_job_internal_key => ["args"], - "args" => method_expected_args - } + { active_job_internal_key => ["args"], "args" => [] } ] ) expect(transaction).to include_tags( @@ -647,15 +1052,103 @@ def welcome(*_args) "executions" => 1 ) end + + it "in collector mode", :collector_mode do + start_collector_agent + + perform + last_transaction.complete + + expect(root_span.attributes["appsignal.action_name"]) + .to eq("ActionMailerTestMailDeliveryJob#welcome") + expect(JSON.parse(root_span.attributes["appsignal.function.parameters"])) + .to eq([ + "ActionMailerTestMailDeliveryJob", + "welcome", + "deliver_now", + { active_job_internal_key => ["args"], "args" => [] } + ]) + expect(root_span.attributes["appsignal.tag.active_job_id"]).to be_a(String) + expect(root_span.attributes["appsignal.tag.request_id"]).to be_a(String) + expect(root_span.attributes["appsignal.tag.queue"]).to eq("mailers") + expect(root_span.attributes["appsignal.tag.executions"]).to eq(1) + end + end + + context "with method arguments" do + describe "sets the Action mailer data on the transaction" do + def perform + perform_mailer(ActionMailerTestMailDeliveryJob, :welcome, method_given_args) + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + + perform + + transaction = last_transaction + transaction._sample + expect(transaction).to have_action("ActionMailerTestMailDeliveryJob#welcome") + expect(transaction).to include_params( + [ + "ActionMailerTestMailDeliveryJob", + "welcome", + "deliver_now", + { + active_job_internal_key => ["args"], + "args" => method_expected_args + } + ] + ) + expect(transaction).to include_tags( + "active_job_id" => kind_of(String), + "request_id" => kind_of(String), + "queue" => "mailers", + "executions" => 1 + ) + end + + it "in collector mode", :collector_mode do + start_collector_agent + + perform + last_transaction.complete + + expect(root_span.attributes["appsignal.action_name"]) + .to eq("ActionMailerTestMailDeliveryJob#welcome") + expect(JSON.parse(root_span.attributes["appsignal.function.parameters"])) + .to eq([ + "ActionMailerTestMailDeliveryJob", + "welcome", + "deliver_now", + { + active_job_internal_key => ["args"], + "args" => method_expected_args + } + ]) + expect(root_span.attributes["appsignal.tag.active_job_id"]).to be_a(String) + expect(root_span.attributes["appsignal.tag.request_id"]).to be_a(String) + expect(root_span.attributes["appsignal.tag.queue"]).to eq("mailers") + expect(root_span.attributes["appsignal.tag.executions"]).to eq(1) + end + end end context "with parameterized arguments" do - it "sets the Action mailer data on the transaction" do - perform_mailer(ActionMailerTestMailDeliveryJob, :welcome, parameterized_given_args) + describe "sets the Action mailer data on the transaction" do + def perform + perform_mailer(ActionMailerTestMailDeliveryJob, :welcome, parameterized_given_args) + end - transaction = last_transaction - expect(transaction).to have_action("ActionMailerTestMailDeliveryJob#welcome") - expect(transaction).to include_params( + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + + perform + + transaction = last_transaction + transaction._sample + expect(transaction).to have_action("ActionMailerTestMailDeliveryJob#welcome") + expect(transaction).to include_params( [ "ActionMailerTestMailDeliveryJob", "welcome", @@ -667,12 +1160,38 @@ def welcome(*_args) } ] ) - expect(transaction).to include_tags( - "active_job_id" => kind_of(String), - "request_id" => kind_of(String), - "queue" => "mailers", - "executions" => 1 - ) + expect(transaction).to include_tags( + "active_job_id" => kind_of(String), + "request_id" => kind_of(String), + "queue" => "mailers", + "executions" => 1 + ) + end + + it "in collector mode", :collector_mode do + start_collector_agent + + perform + last_transaction.complete + + expect(root_span.attributes["appsignal.action_name"]) + .to eq("ActionMailerTestMailDeliveryJob#welcome") + expect(JSON.parse(root_span.attributes["appsignal.function.parameters"])) + .to eq([ + "ActionMailerTestMailDeliveryJob", + "welcome", + "deliver_now", + { + active_job_internal_key => ["params", "args"], + "args" => [], + "params" => parameterized_expected_args + } + ]) + expect(root_span.attributes["appsignal.tag.active_job_id"]).to be_a(String) + expect(root_span.attributes["appsignal.tag.request_id"]).to be_a(String) + expect(root_span.attributes["appsignal.tag.queue"]).to eq("mailers") + expect(root_span.attributes["appsignal.tag.executions"]).to eq(1) + end end end end @@ -714,4 +1233,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/integrations/que_spec.rb b/spec/lib/appsignal/integrations/que_spec.rb index 886fe2b78..c87e1f2ad 100644 --- a/spec/lib/appsignal/integrations/que_spec.rb +++ b/spec/lib/appsignal/integrations/que_spec.rb @@ -25,115 +25,211 @@ 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 + + # Que 0.x handles the error inside its own `_run` and counts the failed + # attempt there. That happens before the plugin reads the job attributes, + # so it reports one attempt. Que 1 and up leave the count to the worker, + # so they still report none here. + let(:expected_attempts) { DependencyHelper.que1_present? ? 0 : 1 } + + describe "reports errors and does not re-raise them" do + it "in agent mode", :agent_mode do + start_agent + expect { perform }.to change { created_transactions.length }.by(1) + + transaction = last_transaction + expect(transaction).to have_id + expect(transaction).to have_action("MyQueJob#run") + expect(transaction).to have_namespace(Appsignal::Transaction::BACKGROUND_JOB) + expect(transaction).to have_error(error.class.name, error.message) + expect(transaction).to include_params( + "arguments" => %w[post_id_123 user_id_123] + ) + expect(transaction).to include_tags( + "attempts" => expected_attempts, + "id" => 123, + "priority" => 100, + "queue" => "dfl", + "run_at" => fixed_time.to_s + ) + expect(transaction).to be_completed + end - 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( - # Que 0.x handles the error inside its own `_run` and counts the - # failed attempt there. That happens before the plugin reads the job - # attributes, so it reports one attempt. Que 1 and up leave the - # count to the worker, so they still report none here. - "attempts" => DependencyHelper.que1_present? ? 0 : 1, - "id" => 123, - "priority" => 100, - "queue" => "dfl", - "run_at" => fixed_time.to_s - ) - expect(transaction).to be_completed + it "in collector mode", :collector_mode do + start_collector_agent + expect { perform }.to change { created_transactions.length }.by(1) + + expect(root_span.kind).to eq(:consumer) + expect(root_span.attributes["appsignal.action_name"]).to eq("MyQueJob#run") + expect(root_span.attributes["appsignal.namespace"]) + .to eq("background") + event = exception_events.find { |e| e.attributes["exception.type"] == error.class.name } + expect(event).not_to be_nil + expect(event.attributes["exception.message"]).to eq(error.message) + expect(event.attributes["exception.stacktrace"]).to be_a(String) + expect(event.attributes["appsignal.alert_this_error"]).to eq(true) + expected_params = { "arguments" => %w[post_id_123 user_id_123] } + expected_params["keyword_arguments"] = {} if DependencyHelper.que2_present? + expect(JSON.parse(root_span.attributes["appsignal.function.parameters"])) + .to eq(expected_params) + expect(root_span.attributes["appsignal.tag.attempts"]).to eq(expected_attempts) + expect(root_span.attributes["appsignal.tag.id"]).to eq(123) + expect(root_span.attributes["appsignal.tag.priority"]).to eq(100) + expect(root_span.attributes["appsignal.tag.queue"]).to eq("dfl") + expect(root_span.attributes["appsignal.tag.run_at"]).to eq(fixed_time.to_s) + expect(last_transaction).to be_completed + end end end @@ -158,13 +254,31 @@ def run(post_id, user_id: nil) end end - it "reports keyword arguments as parameters" do + def perform perform_que_job(instance) + end - expect(last_transaction).to include_params( - "arguments" => %w[post_id_123], - "keyword_arguments" => { "user_id" => "user_id_123" } - ) + describe "reports keyword arguments as parameters" do + it "in agent mode", :agent_mode do + start_agent + perform + + expect(last_transaction).to include_params( + "arguments" => %w[post_id_123], + "keyword_arguments" => { "user_id" => "user_id_123" } + ) + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + + expect(JSON.parse(root_span.attributes["appsignal.function.parameters"])) + .to eq( + "arguments" => %w[post_id_123], + "keyword_arguments" => { "user_id" => "user_id_123" } + ) + end end end end @@ -178,17 +292,73 @@ def run(*_args) end end - it "uses the custom action" do + def perform perform_que_job(instance) + end + + describe "uses the custom action" do + it "in agent mode", :agent_mode do + start_agent + perform + + transaction = last_transaction + expect(transaction).to have_action("MyCustomJob#perform") + expect(transaction).to be_completed + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform - transaction = last_transaction - expect(transaction).to have_action("MyCustomJob#perform") - expect(transaction).to be_completed + expect(root_span.attributes["appsignal.action_name"]) + .to eq("MyCustomJob#perform") + expect(last_transaction).to be_completed + end + end + end + + # Que only has tags from version 1.0 on, and they are the only carrier the + # trace context can ride on, so this is Que 1 and up only. + context "with incoming trace context", :if => DependencyHelper.que1_present? do + let(:trace_id_hex) { "0af7651916cd43dd8448eb211c80319c" } + let(:span_id_hex) { "b7ad6b7169203331" } + let(:traceparent) { "00-#{trace_id_hex}-#{span_id_hex}-01" } + # OpenTelemetry's Que instrumentation carries the trace context as + # "key:value" tag strings under the job's `data` attribute. + let(:job_attrs) do + super().merge(:data => { :tags => ["traceparent:#{traceparent}"] }) + end + + def perform + perform_que_job(instance) + end + + it "in agent mode", :agent_mode do + start_agent + expect { perform }.to change { created_transactions.length }.by(1) + expect(last_transaction).to be_completed + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + + # The job 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. Que 0.x has no tags, so there the enqueue + # is only recorded as an event and nothing is propagated. describe Appsignal::Integrations::QueClientPlugin do let(:job) do Class.new(::Que::Job) do @@ -212,10 +382,7 @@ def self.name allow(Que).to receive(:adapter) .and_return(double(:wake_worker_after_commit => nil)) end - - start_agent end - around { |example| keep_transactions { example.run } } # The arguments are the fifth value Que passes to its `:insert_job` query on # every version. Que 1 and up serialise them to JSON first; Que 0.x hands @@ -233,29 +400,30 @@ def enqueued_tags data ? JSON.parse(data)["tags"] : nil end - # Que 0.x has no `job_options` keyword and no tags at all. It reads its - # scheduling options from top-level keys in a trailing hash instead. So the - # tags are only passed, and only asserted, on Que 1 and up. - def enqueue + # Que 0.x has no `job_options` keyword and no tags. It reads its scheduling + # options from top-level keys in a trailing hash instead. So the tags are + # only passed on Que 1 and up. + def enqueue(tags: ["user:42"]) if DependencyHelper.que1_present? - job.enqueue("post_id_123", :job_options => { :tags => ["user:42"] }) + job.enqueue("post_id_123", :job_options => { :tags => tags }) else job.enqueue("post_id_123") end end - # Whatever the plugin does, it must forward the enqueue call to Que - # unchanged. This matters most on Que 0.x, which turns any keyword argument - # it does not recognise into an extra job argument. That means a keyword the - # plugin adds to the call itself, such as an empty `job_options` default, - # would be persisted as a job argument and break the job when it runs. - def expect_job_to_be_enqueued_unchanged + # Whatever the plugin records or injects, it must leave the job's own + # arguments alone. This matters most on Que 0.x, which turns any keyword + # argument it does not recognise into an extra job argument. A keyword the + # plugin adds to the call itself, such as a `job_options` the caller never + # passed, would be persisted as a job argument and break the job when it + # runs. + def expect_job_arguments_untouched expect(enqueued_args).to eq(["post_id_123"]) - expect(enqueued_tags).to eq(["user:42"]) if DependencyHelper.que1_present? end context "with an active transaction" do - it "records an enqueue event and leaves the job's arguments untouched" do + it "in agent mode", :agent_mode do + start_agent transaction = http_request_transaction set_current_transaction(transaction) @@ -265,35 +433,112 @@ def expect_job_to_be_enqueued_unchanged event = transaction.to_h["events"].find { |e| e["name"] == "enqueue.que" } expect(event).to_not be_nil expect(event["title"]).to eq("enqueue MyQueJob job") - expect_job_to_be_enqueued_unchanged + # No wire context in agent mode; only the user's own tag persists. + expect(enqueued_tags).to eq(["user:42"]) if DependencyHelper.que1_present? + expect_job_arguments_untouched + end + + it "in collector mode", :collector_mode do + start_collector_agent + transaction = http_request_transaction + set_current_transaction(transaction) + + enqueue + Appsignal::Transaction.complete_current! + + # The enqueue is a producer event span under the active transaction, + # named after the job being enqueued. + producer = event_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) + + if DependencyHelper.que1_present? + # The job carries the producer span's context as a traceparent tag, + # alongside the user's own tag. + expect(enqueued_tags).to include("user:42") + expect(enqueued_tags) + .to include("traceparent:00-#{producer.hex_trace_id}-#{producer.hex_span_id}-01") + end + expect_job_arguments_untouched + end + + it "skips propagation rather than break the enqueue when tags are full", + :collector_mode, :if => DependencyHelper.que1_present? do + start_collector_agent + set_current_transaction(http_request_transaction) + + # Already at Que's 5-tag limit; adding trace context would exceed it, so + # propagation is skipped and the enqueue still succeeds unchanged. + full = %w[t1 t2 t3 t4 t5] + expect { enqueue(:tags => full) }.to_not raise_error + Appsignal::Transaction.complete_current! + + expect(enqueued_tags).to eq(full) end end context "without an active transaction" do - it "is a transparent pass-through" do + it "in agent mode", :agent_mode do + start_agent + + # No transaction to attach to: a transparent pass-through. expect { enqueue }.to_not raise_error + expect(enqueued_tags).to eq(["user:42"]) if DependencyHelper.que1_present? + expect_job_arguments_untouched + end + + it "in collector mode", :collector_mode do + start_collector_agent - expect_job_to_be_enqueued_unchanged + enqueue + + # No transaction to attach to: nothing recorded, nothing injected. + expect(span_exporter.finished_spans.map(&:name)).to_not include("enqueue.que") + expect(enqueued_tags).to eq(["user:42"]) if DependencyHelper.que1_present? + expect_job_arguments_untouched end end context "when job enqueue events are suppressed" do # As happens under Active Job, which records the enqueue itself. - it "passes through without recording the enqueue" do + def enqueue_suppressed(transaction) + transaction.suppress_job_enqueue_events { enqueue } + end + + it "in agent mode", :agent_mode do + start_agent transaction = http_request_transaction set_current_transaction(transaction) - transaction.suppress_job_enqueue_events { enqueue } + enqueue_suppressed(transaction) # The outer integration records the enqueue, so this one doesn't. event_names = transaction.to_h["events"].map { |event| event["name"] } expect(event_names).to_not include("enqueue.que") - expect_job_to_be_enqueued_unchanged + expect_job_arguments_untouched + end + + it "in collector mode", :collector_mode do + start_collector_agent + transaction = http_request_transaction + set_current_transaction(transaction) + + enqueue_suppressed(transaction) + Appsignal::Transaction.complete_current! + + # No producer span for the suppressed enqueue... + expect(span_exporter.finished_spans.map(&:name)).to_not include("enqueue.que") + # ...but the trace context is still injected so the job links back. + if DependencyHelper.que1_present? + expect(enqueued_tags).to include(a_string_starting_with("traceparent:")) + end + expect_job_arguments_untouched end end - # `bulk_enqueue` is Que 2 only. The whole batch records a single - # `bulk_enqueue.que` event; the inner enqueues are pass-throughs. + # `bulk_enqueue` is Que 2 only. The whole batch shares one `job_options`, so + # it records a single producer event and the inner enqueues are pass-throughs. describe "#bulk_enqueue", :if => DependencyHelper.que2_present? do before do # Que's bulk path constantizes the job class by name, so it needs a real @@ -313,35 +558,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/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 4ed5fe207..f80dc37fa 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,24 +327,46 @@ 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 @@ -259,27 +431,58 @@ def enqueue end context "without an active transaction" do - it "passes through without recording" do - expect { |block| plugin.call("TestClass", job, "default", nil, &block) } - .to yield_control + it "in agent mode", :agent_mode do + start_agent + # A transparent pass-through: nothing is recorded and the job hash is + # untouched. + expect(enqueue).to eq(:enqueued) expect(created_transactions).to be_empty + expect(job).to_not have_key("traceparent") + end + + it "in collector mode", :collector_mode do + start_collector_agent + + # No transaction to attach the event to, so nothing is emitted and the + # job hash is untouched. + expect(enqueue).to eq(:enqueued) + expect(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 @@ -331,9 +534,7 @@ class DelayedTestClass; end end let(:plugin) { Appsignal::Integrations::SidekiqMiddleware.new } let(:options) { {} } - before do - start_agent(:options => options) - end + let(:start_agent_args) { { :options => options } } around { |example| keep_transactions { example.run } } def expect_no_yaml_parse_error(logs) @@ -341,31 +542,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 @@ -375,10 +669,25 @@ def expect_no_yaml_parse_error(logs) item["args"] << "super secret value" # Last argument will be replaced end - it "replaces the last argument (the secret bag) with an [encrypted data] string" do - perform_sidekiq_job + describe "replaces the last argument (the secret bag) with an [encrypted data] string" do + def perform + perform_sidekiq_job + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + + expect(transaction).to include_params(expected_args << "[encrypted data]") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform - expect(transaction).to include_params(expected_args << "[encrypted data]") + params = JSON.parse(root_span.attributes["appsignal.function.parameters"]) + expect(params).to eq(expected_args << "[encrypted data]") + end end end @@ -398,22 +707,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 @@ -434,22 +778,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" }]) + 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(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::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(transaction).to have_action("Sidekiq::Extensions::DelayedModel#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::DelayedModel#perform") + params = JSON.parse(root_span.attributes["appsignal.function.parameters"]) + expect(params).to eq([]) + expect(logs).to contains_log(:warn, "Unable to load YAML") + end end end end @@ -457,37 +836,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 @@ -495,55 +909,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 @@ -638,8 +1107,8 @@ def expect_transaction_to_have_sidekiq_event(transaction) end end around { |example| keep_transactions { example.run } } + before do - start_agent Appsignal.internal_logger = test_logger(log) ActiveJob::Base.queue_adapter = :sidekiq @@ -665,35 +1134,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) + describe "reports the transaction from the ActiveJob integration" do + def perform + perform_activejob_sidekiq_job(ActiveJobSidekiqTestJob, given_args) + end - events = transaction.to_h["events"] - .sort_by { |e| e["start"] } - .map { |event| event["name"] } - expect(events).to eq(expected_perform_events) - end - - context "with error" do - it "reports the error on the transaction from the ActiveRecord integration" do - expect do - perform_activejob_sidekiq_job(ActiveJobSidekiqErrorTestJob, given_args) - end.to raise_error(RuntimeError, "uh oh") + it "in agent mode", :agent_mode do + start_agent + perform transaction = last_transaction expect(transaction).to have_namespace(namespace) - expect(transaction).to have_action("ActiveJobSidekiqErrorTestJob#perform") - expect(transaction).to have_error("RuntimeError", "uh oh") + expect(transaction).to have_action("ActiveJobSidekiqTestJob#perform") + expect(transaction).to_not have_error expect(transaction).to include_metadata("queue" => "default") expect(transaction).to_not include_environment expect(transaction).to include_params([expected_args]) @@ -705,6 +1158,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 @@ -717,15 +1247,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 From 4907792e89e70e0c8d141930371c5b5240ca671d Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Fri, 10 Jul 2026 16:07:44 +0200 Subject: [PATCH 10/69] Run error blocks eagerly in collector mode In collector mode an error is recorded when it is added, but its block was held until completion. Deferral only makes sense in agent mode, where blocks run against the duplicate transactions fabricated at completion. The block now runs when the error is added, so side effects that target the current span, such as breadcrumbs or nested errors, land where the error was reported rather than on the root span. `@error_blocks` was carrying two jobs: the set of distinct errors, which drives `last_errors`, the dedup check and the error limit, and the per-error blocks that only agent mode runs. `@errors` is now the distinct-error set used in both modes, and `@error_blocks` holds only blocks and is populated only in agent mode. `records_errors_eagerly?` is renamed to `supports_multiple_errors?`. The question at the call site is whether the backend can hold more than one error on a transaction, not how it records them. --- lib/appsignal/transaction.rb | 80 ++++++++++--------- lib/appsignal/transaction/base_backend.rb | 12 +-- .../transaction/extension_backend.rb | 2 +- .../transaction/opentelemetry_backend.rb | 7 +- .../transaction/extension_backend_spec.rb | 4 +- .../transaction/opentelemetry_backend_spec.rb | 4 +- spec/lib/appsignal/transaction_spec.rb | 20 +++++ 7 files changed, 79 insertions(+), 50 deletions(-) diff --git a/lib/appsignal/transaction.rb b/lib/appsignal/transaction.rb index d98b0524e..5e1777652 100644 --- a/lib/appsignal/transaction.rb +++ b/lib/appsignal/transaction.rb @@ -174,6 +174,16 @@ def initialize(namespace, id: SecureRandom.uuid, backend: nil, opentelemetry_con @completed = false @tags = {} @store = Hash.new { |hash, key| hash[key] = {} } + # The distinct errors added to this transaction, in add order. Drives + # `last_errors`, the dedup check and the `ERRORS_LIMIT`, in both modes. + # A Set, so membership is by object identity (`eql?`/`hash`), matching how + # these errors used to be deduplicated as Hash keys. `Exception#==` would + # instead collapse distinct errors with the same class, message and + # backtrace, which we don't want. + @errors = Set.new + # Blocks per error, only populated in agent mode, where they run against + # the duplicate transactions at completion. Collector mode runs blocks + # when the error is added and never touches this. @error_blocks = Hash.new { |hash, key| hash[key] = [] } @is_duplicate = false @error_set = nil @@ -236,7 +246,7 @@ def complete should_sample = true unless duplicate? - self.class.last_errors = @error_blocks.keys + self.class.last_errors = @errors.to_a should_sample = @backend.finish end @@ -632,7 +642,7 @@ def add_error(error, &block) return unless error return unless Appsignal.active? - if error.instance_variable_get(:@__appsignal_error_reported) && !@error_blocks.include?(error) + if error.instance_variable_get(:@__appsignal_error_reported) && !@errors.include?(error) return end @@ -720,26 +730,37 @@ def to_h # @!visibility private def internal_set_error(error, &block) - is_new_error = !@error_blocks.include?(error) + is_new_error = !@errors.include?(error) - if is_new_error && @error_blocks.length >= ERRORS_LIMIT + if is_new_error && @errors.length >= ERRORS_LIMIT Appsignal.internal_logger.warn "Appsignal::Transaction#add_error: Transaction has more " \ "than #{ERRORS_LIMIT} distinct errors. Only the first " \ "#{ERRORS_LIMIT} distinct errors will be reported." return end - if @error_blocks.empty? + if @errors.empty? _set_error(error) - elsif is_new_error && @backend.records_errors_eagerly? + elsif is_new_error && @backend.supports_multiple_errors? # Record additional errors immediately so each exception event lands on # the span current now, not the root span at completion. The agent # backend instead reports extras as duplicate transactions. _send_error_to_backend(error) end - @error_blocks[error] << block - @error_blocks[error].compact! + @errors.add(error) + + if @backend.supports_multiple_errors? + # Collector mode: the error is already recorded, so run its block now + # rather than at completion. Anything a block attaches to the current + # span -- breadcrumbs, nested errors, custom instrumentation -- then + # lands where the error was reported, not on the root span at + # completion. + self.class.with_transaction(self) { block.call(self) } if block + else + @error_blocks[error] << block + @error_blocks[error].compact! + end end private @@ -756,40 +777,25 @@ 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: + # Reports the errors stored on the transaction at completion. # - # - 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. + # In eager (collector) mode nothing is left to do: each error was recorded + # and its block run when the error was added. In deferred (agent) mode the + # extension holds a single error, so the primary error's blocks run on this + # transaction and every additional error is reported as a duplicate + # transaction. def report_errors - if @backend.records_errors_eagerly? - run_error_blocks - else - report_errors_as_duplicates - end - end + return if @backend.supports_multiple_errors? - # 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 + report_errors_as_duplicates end - # Agent mode: the extension transaction holds a single error, so report each - # additional error as a duplicate transaction. + # Agent-only legacy path. The extension transaction holds a single error, so + # extra errors are reported as duplicate transactions. This whole method + # disappears once agent mode is dropped: collector mode records every error + # eagerly and leaves nothing to do at completion. def report_errors_as_duplicates - @error_blocks.each do |error, blocks| + @errors.each do |error| # Ignore the error that is already set in this transaction. next if error == @error_set @@ -798,7 +804,7 @@ def report_errors_as_duplicates # 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) } + @error_blocks[error].each { |block| block.call(transaction) } end transaction.complete diff --git a/lib/appsignal/transaction/base_backend.rb b/lib/appsignal/transaction/base_backend.rb index 9b4c23b53..5a4bed945 100644 --- a/lib/appsignal/transaction/base_backend.rb +++ b/lib/appsignal/transaction/base_backend.rb @@ -52,9 +52,10 @@ 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? + # Whether the backend can hold more than one error on a single + # transaction. When it can't (agent mode), the Transaction reports the + # extra errors as duplicate transactions instead. + def supports_multiple_errors? raise NotImplementedError end @@ -71,8 +72,9 @@ 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. + # Only used when `supports_multiple_errors?` is false (agent mode). + # Backends that support multiple errors never duplicate and leave this + # unimplemented. def duplicate(_new_transaction_id) raise NotImplementedError end diff --git a/lib/appsignal/transaction/extension_backend.rb b/lib/appsignal/transaction/extension_backend.rb index 934defbd3..7ddedecf7 100644 --- a/lib/appsignal/transaction/extension_backend.rb +++ b/lib/appsignal/transaction/extension_backend.rb @@ -109,7 +109,7 @@ def discard # The extension transaction holds a single error, so the Transaction # reports additional errors as duplicate transactions instead. - def records_errors_eagerly? + def supports_multiple_errors? false end diff --git a/lib/appsignal/transaction/opentelemetry_backend.rb b/lib/appsignal/transaction/opentelemetry_backend.rb index 8019e67b1..dac43c1e8 100644 --- a/lib/appsignal/transaction/opentelemetry_backend.rb +++ b/lib/appsignal/transaction/opentelemetry_backend.rb @@ -264,9 +264,10 @@ def discard 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? + # current when it was added, so a trace holds many errors and the + # Transaction never duplicates itself -- which is why `duplicate` is left + # unimplemented (see BaseBackend). + def supports_multiple_errors? true end diff --git a/spec/lib/appsignal/transaction/extension_backend_spec.rb b/spec/lib/appsignal/transaction/extension_backend_spec.rb index 9e517dee6..b10c6f1d9 100644 --- a/spec/lib/appsignal/transaction/extension_backend_spec.rb +++ b/spec/lib/appsignal/transaction/extension_backend_spec.rb @@ -218,9 +218,9 @@ end end - describe "#records_errors_eagerly?" do + describe "#supports_multiple_errors?" do it "returns false (extra errors are reported as duplicate transactions)" do - expect(backend.records_errors_eagerly?).to eq(false) + expect(backend.supports_multiple_errors?).to eq(false) end end end diff --git a/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb b/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb index 081744c87..e5bfaa653 100644 --- a/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb +++ b/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb @@ -475,9 +475,9 @@ def exception_event(backend) end end - describe "#records_errors_eagerly?" do + describe "#supports_multiple_errors?" do it "returns true (multiple exception events on one span)" do - expect(create_backend.records_errors_eagerly?).to eq(true) + expect(create_backend.supports_multiple_errors?).to eq(true) end end diff --git a/spec/lib/appsignal/transaction_spec.rb b/spec/lib/appsignal/transaction_spec.rb index c931db767..3eda5a5d3 100644 --- a/spec/lib/appsignal/transaction_spec.rb +++ b/spec/lib/appsignal/transaction_spec.rb @@ -203,6 +203,26 @@ expect(event_span.events.map(&:name)).to include("exception", "appsignal.breadcrumb") expect(Array(foreign_span.events).map(&:name)).to be_empty end + + it "runs an error block on the span current when the error was added", :collector_mode do + start_collector_agent + transaction = create_transaction(Appsignal::Transaction::HTTP_REQUEST) + + # The block runs when the error is added, while the event span is still + # current -- not deferred to completion, when the root span would be + # current. A breadcrumb added from the block lands on the event span. + transaction.start_event + transaction.add_error(ExampleStandardError.new("boom")) do |t| + t.add_breadcrumb("network", "GET /", "ok", { "code" => "200" }) + end + transaction.finish_event("sql.query", "Query", "SELECT 1", + Appsignal::EventFormatter::DEFAULT) + Appsignal::Transaction.complete_current! + + event_span = event_spans.find { |s| s.attributes["appsignal.category"] == "sql.query" } + expect(event_span.events.map(&:name)).to include("exception", "appsignal.breadcrumb") + expect(Array(root_span.events).map(&:name)).to_not include("appsignal.breadcrumb") + end end end From 5e1d1cbdf7c34595393bda3cd98268300bb6e49d Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Tue, 14 Jul 2026 17:51:24 +0200 Subject: [PATCH 11/69] Tag Sequel query spans CLIENT in collector mode The sequel-rails gem emits its queries as `sql.sequel` ActiveSupport::Notifications events. Those go through the generic notifications integration rather than the dedicated Sequel hook, so the hook's CLIENT-kind tagging never applied to them, and they were exported with the default INTERNAL kind. The notifications integration listed only `sql.active_record` as an outgoing datastore call. `sql.sequel` is on that list now, so a Sequel query is CLIENT whichever path records it. --- .../active_support_notifications.rb | 8 +++- .../instrument_shared_examples.rb | 47 +++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/lib/appsignal/integrations/active_support_notifications.rb b/lib/appsignal/integrations/active_support_notifications.rb index 99f4faab2..78cb621f3 100644 --- a/lib/appsignal/integrations/active_support_notifications.rb +++ b/lib/appsignal/integrations/active_support_notifications.rb @@ -13,7 +13,13 @@ class << self # `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 + # + # `sql.sequel` is emitted by the sequel-rails gem through + # ActiveSupport::Notifications, so it reaches us here rather than through + # the dedicated Sequel hook (which already tags its own query events as + # CLIENT). Including it keeps a Sequel query CLIENT regardless of which + # path records it. + CLIENT_EVENT_NAMES = ["sql.active_record", "sql.sequel"].freeze # Events a dedicated AppSignal integration already records with richer # semantics, so the generic notifications path must not record them a 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 a52dc028e..c69c53c0f 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 @@ -42,6 +42,53 @@ def perform end end + describe "a Sequel query event (emitted by sequel-rails)" do + def perform + as.instrument( + "sql.sequel", + :name => "Sequel::Postgres::Database", + :sql => "SQL" + ) { "value" } + end + + it "in agent mode", :agent_mode do + start_agent + transaction = http_request_transaction + set_current_transaction(transaction) + as.notifier = notifier + + expect(perform).to eq "value" + expect(transaction).to include_event( + "body" => "SQL", + "body_format" => Appsignal::EventFormatter::SQL_BODY_FORMAT, + "count" => 1, + "name" => "sql.sequel", + "title" => "Sequel::Postgres::Database" + ) + end + + it "in collector mode", :collector_mode do + start_collector_agent + transaction = http_request_transaction + set_current_transaction(transaction) + as.notifier = notifier + + expect(perform).to eq "value" + Appsignal::Transaction.complete_current! + + expect(event_spans.size).to eq(1) + span = event_spans.find { |s| s.name == "Sequel::Postgres::Database" } + 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.sequel") + expect(span.attributes).not_to have_key("appsignal.body") + end + end + describe "an event with no registered formatter" do def perform as.instrument("no-registered.formatter", :key => "something") { "value" } From ecfb15fe9f6aa55e74c695c13e26c38462d0b864 Mon Sep 17 00:00:00 2001 From: Noemi <45180344+unflxw@users.noreply.github.com> Date: Thu, 16 Jul 2026 13:40:54 +0200 Subject: [PATCH 12/69] Extract trace context onto an empty context `extract_rack_context` and `extract_job_context` called `OpenTelemetry.propagation.extract` with no `context:` argument, so it defaulted to `Context.current`. The W3C extractor returns its base context unchanged when the carrier has no `traceparent`, so a request with no incoming context inherited whatever span was attached to the fiber. In collector mode a span left over from an earlier request could therefore become the parent of a later request's root span, merging unrelated requests into a single trace. Extraction is onto `Context.empty`, so it reflects only the carrier. A real incoming `traceparent` still parents a web transaction and links a job. --- lib/appsignal/opentelemetry.rb | 16 ++++- spec/lib/appsignal/opentelemetry_spec.rb | 91 +++++++++++++++++++++++- 2 files changed, 103 insertions(+), 4 deletions(-) diff --git a/lib/appsignal/opentelemetry.rb b/lib/appsignal/opentelemetry.rb index a2ca99c6b..c2171de61 100644 --- a/lib/appsignal/opentelemetry.rb +++ b/lib/appsignal/opentelemetry.rb @@ -125,8 +125,14 @@ def inject_context(carrier) # names Rack puts in the env. def extract_rack_context(env) if_started do + # Extract onto an empty context, not `Context.current`. The W3C + # extractor returns its base context unchanged when the carrier has no + # `traceparent`, so a request with no incoming context would inherit + # whatever span is ambient on the fiber. Starting empty means "no + # carrier" yields no parent, and the transaction starts its own trace. ::OpenTelemetry.propagation.extract( env, + :context => ::OpenTelemetry::Context.empty, :getter => ::OpenTelemetry::Common::Propagation.rack_env_getter ) end @@ -148,7 +154,15 @@ def extract_job_context(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) + # Extract onto an empty context rather than the default + # `Context.current`, for the same reason as `extract_rack_context`: + # a job with no injected trace context must not inherit an ambient + # span left on the fiber. Otherwise the job's transaction would link + # back to an unrelated leaked span instead of standing on its own. + ::OpenTelemetry.propagation.extract( + carrier, + :context => ::OpenTelemetry::Context.empty + ) end end diff --git a/spec/lib/appsignal/opentelemetry_spec.rb b/spec/lib/appsignal/opentelemetry_spec.rb index c35e212be..ab2f081f2 100644 --- a/spec/lib/appsignal/opentelemetry_spec.rb +++ b/spec/lib/appsignal/opentelemetry_spec.rb @@ -22,6 +22,21 @@ before { described_class.reset! } after { described_class.reset! } + # Attach a root span to the current OTel context, run the block, and detach + # it afterwards. Stands in for Bug A leaving a previous request's root span + # attached to the fiber, so the extraction specs can assert that a request + # with no incoming trace context does not inherit it. + def with_leaked_ambient_context + tracer = ::OpenTelemetry.tracer_provider.tracer("leak-spec") + leaked = tracer.start_root_span("leaked-from-previous-request") + token = ::OpenTelemetry::Context.attach( + ::OpenTelemetry::Trace.context_with_span(leaked) + ) + yield leaked + ensure + ::OpenTelemetry::Context.detach(token) + end + describe ".configure" do context "on success" do it "sets started? to true" do @@ -222,14 +237,84 @@ expect(described_class.extract_rack_context(env)).to be_nil end - it "extracts from the env with the Rack getter when started" do + it "extracts from the env with the Rack getter, onto an empty context, when started" do require "opentelemetry-common" allow(described_class).to receive(:started?).and_return(true) - expect(::OpenTelemetry.propagation).to receive(:extract) - .with(env, :getter => ::OpenTelemetry::Common::Propagation.rack_env_getter) + received = {} + allow(::OpenTelemetry.propagation).to receive(:extract) do |carrier, **kwargs| + received = kwargs.merge(:carrier => carrier) + ::OpenTelemetry::Context.empty + end described_class.extract_rack_context(env) + + expect(received[:carrier]).to eq(env) + expect(received[:getter]) + .to eq(::OpenTelemetry::Common::Propagation.rack_env_getter) + # The base context must carry no ambient span, so a request with no + # `traceparent` in the carrier does not inherit whatever span happens + # to be current on the fiber. + expect(::OpenTelemetry::Trace.current_span(received[:context])) + .to eq(::OpenTelemetry::Trace::Span::INVALID) + end + + # Regression for issue #7. With a span already attached to the fiber's + # context, extraction must reflect only the carrier. Otherwise a request + # with no `traceparent` inherits the ambient span and its trace merges + # into the leaked one. + context "with a span already attached to the current context", :collector_mode do + before { start_collector_agent } + + it "does not inherit the ambient span when the env has no trace context" do + with_leaked_ambient_context do |leaked| + context = described_class.extract_rack_context({}) + extracted = ::OpenTelemetry::Trace.current_span(context).context + expect(extracted).to_not be_valid + expect(extracted.hex_trace_id).to_not eq(leaked.context.hex_trace_id) + end + end + + it "still continues a real incoming traceparent" do + with_leaked_ambient_context do + context = described_class.extract_rack_context(env) + extracted = ::OpenTelemetry::Trace.current_span(context).context + expect(extracted.hex_trace_id).to eq("0af7651916cd43dd8448eb211c80319c") + end + end + end + end + + describe ".extract_job_context" do + let(:carrier) do + { "traceparent" => "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01" } + end + + it "returns nil when the SDK has not booted" do + expect(described_class.started?).to be(false) + expect(described_class.extract_job_context({})).to be_nil + end + + # Regression for issue #7, mirroring the Rack case for the job carrier. + context "with a span already attached to the current context", :collector_mode do + before { start_collector_agent } + + it "does not inherit the ambient span when the job has no trace context" do + with_leaked_ambient_context do |leaked| + context = described_class.extract_job_context({}) + extracted = ::OpenTelemetry::Trace.current_span(context).context + expect(extracted).to_not be_valid + expect(extracted.hex_trace_id).to_not eq(leaked.context.hex_trace_id) + end + end + + it "still reads a real incoming traceparent" do + with_leaked_ambient_context do + context = described_class.extract_job_context(carrier) + extracted = ::OpenTelemetry::Trace.current_span(context).context + expect(extracted.hex_trace_id).to eq("0af7651916cd43dd8448eb211c80319c") + end + end end end From 10e49ff19aec3145b0828494e9a4173cc4c5e3ea Mon Sep 17 00:00:00 2001 From: Noemi <45180344+unflxw@users.noreply.github.com> Date: Thu, 16 Jul 2026 13:41:35 +0200 Subject: [PATCH 13/69] Guard blocks handed to the transaction A transaction runs blocks that come from user code: the error block given to `set_error`, `send_error` and `report_error`, and the `after_create` and `before_complete` hooks. Any of them can raise, and the raise escaped into whatever drove the transaction's creation or completion, which is usually code with no connection to the block. In collector mode a raise also skipped the backend's completion step. That left the transaction's OpenTelemetry context attached to the fiber, where it became the parent of the next request's root span on the same thread. Unrelated requests were merged into a single trace. Agent mode never showed this, because its extension handle is not a per-fiber stack. A block that raises is now logged with its definition site, and the error is not re-raised, so creation and completion always finish. --- lib/appsignal/helpers/instrumentation.rb | 4 +- lib/appsignal/transaction.rb | 50 +++++++- spec/lib/appsignal/transaction_spec.rb | 157 +++++++++++++++++++++-- spec/lib/appsignal_spec.rb | 30 +++++ 4 files changed, 219 insertions(+), 22 deletions(-) diff --git a/lib/appsignal/helpers/instrumentation.rb b/lib/appsignal/helpers/instrumentation.rb index cb7ef2f34..44bc681f0 100644 --- a/lib/appsignal/helpers/instrumentation.rb +++ b/lib/appsignal/helpers/instrumentation.rb @@ -238,7 +238,7 @@ def send_error(error, &block) transaction = Appsignal::Transaction.new(Appsignal::Transaction::HTTP_REQUEST) - transaction.set_error(error, &block) + transaction.set_error(error, :source => "Appsignal.send_error", &block) transaction.complete end @@ -373,7 +373,7 @@ def report_error(exception, &block) Appsignal::Transaction.new(Appsignal::Transaction::HTTP_REQUEST) end - transaction.add_error(exception, &block) + transaction.add_error(exception, :source => "Appsignal.report_error", &block) transaction.complete unless has_parent_transaction end diff --git a/lib/appsignal/transaction.rb b/lib/appsignal/transaction.rb index 5e1777652..b2f5d50b6 100644 --- a/lib/appsignal/transaction.rb +++ b/lib/appsignal/transaction.rb @@ -632,7 +632,7 @@ def set_metadata(key, value) # @!visibility private # @see Appsignal::Helpers::Instrumentation#report_error - def add_error(error, &block) + def add_error(error, source: nil, &block) unless error.is_a?(Exception) Appsignal.internal_logger.error "Appsignal::Transaction#add_error: Cannot add error. " \ "The given value is not an exception: #{error.inspect}" @@ -646,6 +646,11 @@ def add_error(error, &block) return end + # Wrap the block here, at the entry point, so it stays protected wherever + # it later runs: right away in collector mode, or at completion in agent + # mode. `source` names the helper the block was given to, when known, so + # the log points the customer at the right call. + block = protect(source ? "the block passed to #{source}" : "the error block", &block) if block internal_set_error(error, &block) # Mark errors and their causes as tracked so we don't report duplicates, @@ -756,7 +761,11 @@ def internal_set_error(error, &block) # span -- breadcrumbs, nested errors, custom instrumentation -- then # lands where the error was reported, not on the root span at # completion. - self.class.with_transaction(self) { block.call(self) } if block + if block + self.class.with_transaction(self) do + block.call(self) + end + end else @error_blocks[error] << block @error_blocks[error].compact! @@ -765,15 +774,41 @@ def internal_set_error(error, &block) private + # Wrap a block handed to the transaction by user code so that, wherever it + # later runs, a failure is logged and swallowed instead of breaking the + # transaction lifecycle. A raise would otherwise skip the rest of creation or + # completion, including the backend teardown that detaches the transaction's + # OpenTelemetry context, which would then become the parent of the next + # request's spans on that thread. Re-raising is wrong because the block runs + # far from where its caller defined it. + # + # Returns `nil` when no block is given, so it can wrap an optional block. + def protect(description, &block) + return unless block + + proc do |*args| + block.call(*args) + rescue => error + location = block.source_location&.join(":") || "an unknown location" + Appsignal.internal_logger.error( + "Error in #{description}, defined at #{location}: " \ + "#{error.class}: #{error.message}\n#{error.backtrace&.join("\n")}" + ) + end + end + + # Hooks are registered both as blocks and as method objects pushed onto the + # set directly, so there is no single entry point to wrap them at. They are + # protected here instead, at the one place that runs all of them. def run_after_create_hooks self.class.after_create.each do |block| - block.call(self) + protect("the after_create hook", &block).call(self) end end def run_before_complete_hooks self.class.before_complete.each do |block| - block.call(self, @error_set) + protect("the before_complete hook", &block).call(self, @error_set) end end @@ -802,9 +837,12 @@ def report_errors_as_duplicates 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. + # in the original transaction. Those blocks were already wrapped + # when they were added, so they are called directly here. transaction.internal_set_error(error) do - @error_blocks[error].each { |block| block.call(transaction) } + @error_blocks[error].each do |block| + block.call(transaction) + end end transaction.complete diff --git a/spec/lib/appsignal/transaction_spec.rb b/spec/lib/appsignal/transaction_spec.rb index 3eda5a5d3..ad596a661 100644 --- a/spec/lib/appsignal/transaction_spec.rb +++ b/spec/lib/appsignal/transaction_spec.rb @@ -226,6 +226,126 @@ end end + # A block handed to AppSignal (an error block, or an after_create/ + # before_complete hook) is user code that can raise. Because these blocks + # can run far from where they were defined -- an error block runs at + # completion in agent mode -- a failure must be logged and swallowed, never + # raised into whatever drove creation or completion. Otherwise, in collector + # mode, the transaction's OpenTelemetry context is left attached to the + # fiber and leaks into the next request on that thread. + describe "when a block handed to the transaction raises" do + describe "an error block" do + it_in_both_modes "completes the transaction and logs, without raising" do + transaction = create_transaction(Appsignal::Transaction::HTTP_REQUEST) + + logs = capture_logs do + expect do + transaction.add_error(ExampleStandardError.new("boom")) do + raise ExampleStandardError, "error block boom" + end + Appsignal::Transaction.complete_current! + end.to_not raise_error + end + + expect(transaction).to be_completed + expect(logs).to contains_log(:error, + /Error in the error block, defined at .+error block boom/) + end + + it "names the reporting helper in the log when a source is given" do + transaction = create_transaction(Appsignal::Transaction::HTTP_REQUEST) + + logs = capture_logs do + transaction.add_error( + ExampleStandardError.new("boom"), + :source => "Appsignal.send_error" + ) { raise ExampleStandardError, "error block boom" } + Appsignal::Transaction.complete_current! + end + + expect(logs).to contains_log( + :error, + /Error in the block passed to Appsignal\.send_error, defined at .+error block boom/ + ) + end + + it "detaches the OpenTelemetry context", :collector_mode do + start_collector_agent + transaction = create_transaction(Appsignal::Transaction::HTTP_REQUEST) + + expect do + transaction.add_error(ExampleStandardError.new("boom")) do + raise ExampleStandardError, "error block boom" + end + Appsignal::Transaction.complete_current! + end.to_not raise_error + + expect(::OpenTelemetry::Trace.current_span) + .to eq(::OpenTelemetry::Trace::Span::INVALID) + end + end + + describe "a before_complete hook" do + it_in_both_modes "completes the transaction and logs, without raising" do + Appsignal::Transaction.before_complete do |_transaction, _error| + raise ExampleStandardError, "before_complete boom" + end + transaction = create_transaction(Appsignal::Transaction::HTTP_REQUEST) + + logs = capture_logs do + expect { Appsignal::Transaction.complete_current! }.to_not raise_error + end + + expect(transaction).to be_completed + expect(logs).to contains_log( + :error, /Error in the before_complete hook, defined at .+before_complete boom/ + ) + end + + it "detaches the OpenTelemetry context", :collector_mode do + start_collector_agent + Appsignal::Transaction.before_complete do |_transaction, _error| + raise ExampleStandardError, "before_complete boom" + end + create_transaction(Appsignal::Transaction::HTTP_REQUEST) + + expect { Appsignal::Transaction.complete_current! }.to_not raise_error + expect(::OpenTelemetry::Trace.current_span) + .to eq(::OpenTelemetry::Trace::Span::INVALID) + end + end + + describe "an after_create hook" do + it_in_both_modes "creates the transaction and logs, without raising" do + Appsignal::Transaction.after_create do |_transaction| + raise ExampleStandardError, "after_create boom" + end + + logs = capture_logs do + expect { create_transaction(Appsignal::Transaction::HTTP_REQUEST) } + .to_not raise_error + end + + expect(logs).to contains_log( + :error, /Error in the after_create hook, defined at .+after_create boom/ + ) + end + + it "detaches the OpenTelemetry context on the next completion", :collector_mode do + start_collector_agent + Appsignal::Transaction.after_create do |_transaction| + raise ExampleStandardError, "after_create boom" + end + + create_transaction(Appsignal::Transaction::HTTP_REQUEST) + Appsignal::Transaction.complete_current! + + expect(::OpenTelemetry::Trace.current_span) + .to eq(::OpenTelemetry::Trace::Span::INVALID) + end + end + end + describe ".current" do context "when there is a current transaction" do let!(:transaction) { create_transaction } @@ -3201,14 +3321,17 @@ def perform end context "when a block is given" do - it "stores the block in the error blocks" do - block = proc { "block" } + it "stores the block, wrapped, in the error blocks" do + called_with = nil + transaction.add_error(error) { |t| called_with = t } - transaction.add_error(error, &block) + stored = transaction.error_blocks[error] + expect(stored.size).to eq(1) - expect(transaction.error_blocks).to eq({ - error => [block] - }) + # The block is wrapped when it is added, so what is stored is not the + # given block itself but a wrapper that calls through to it. + stored.each { |block| block.call(transaction) } + expect(called_with).to eq(transaction) end end @@ -3424,12 +3547,15 @@ def perform end context "when a block is given" do - it "adds the block to the error blocks" do - block = proc { "block" } + it "adds the block, wrapped, to the error blocks" do + called = false + transaction.add_error(error) { called = true } - transaction.add_error(error, &block) + stored = transaction.error_blocks[error] + expect(stored.size).to eq(1) - expect(transaction.error_blocks).to eq({ error => [block] }) + stored.each { |block| block.call(transaction) } + expect(called).to be(true) end end end @@ -3475,12 +3601,15 @@ def perform expect(transaction.error_blocks.length).to eq(10) end - it "does add the block to the error blocks" do - block = proc { "block" } + it "does add the block, wrapped, to the error blocks" do + called = false + transaction.add_error(seen_error) { called = true } - transaction.add_error(seen_error, &block) + stored = transaction.error_blocks[seen_error] + expect(stored.size).to eq(1) - expect(transaction.error_blocks[seen_error]).to eq([block]) + stored.each { |block| block.call(transaction) } + expect(called).to be(true) end it "does not log a debug message" do diff --git a/spec/lib/appsignal_spec.rb b/spec/lib/appsignal_spec.rb index b7b3924d7..625a79b24 100644 --- a/spec/lib/appsignal_spec.rb +++ b/spec/lib/appsignal_spec.rb @@ -2019,6 +2019,21 @@ def perform expect(last_transaction).to have_error("StandardError", "my_error") end + it "logs a raising block without raising, naming the helper" do + logs = capture_logs do + expect do + Appsignal.send_error(StandardError.new("my_error")) do + raise ExampleStandardError, "metadata boom" + end + end.to_not raise_error + end + + expect(logs).to contains_log( + :error, + /Error in the block passed to Appsignal\.send_error, defined at .+metadata boom/ + ) + end + it "yields to set metadata and doesn't modify the active transaction" do active_transaction = http_request_transaction active_transaction.set_action("active action") @@ -2220,6 +2235,21 @@ def perform expect(transaction).to include_tags("tag1" => "value1") expect(transaction).to be_completed end + + it "logs a raising block without raising, naming the helper" do + logs = capture_logs do + expect do + Appsignal.report_error(error) do + raise ExampleStandardError, "metadata boom" + end + end.to_not raise_error + end + + expect(logs).to contains_log( + :error, + /Error in the block passed to Appsignal\.report_error, defined at .+metadata boom/ + ) + end end end From 42e6b67a4049369e93d73a2b63ceb5578f50a561 Mon Sep 17 00:00:00 2001 From: Noemi <45180344+unflxw@users.noreply.github.com> Date: Thu, 16 Jul 2026 13:42:15 +0200 Subject: [PATCH 14/69] Drop actionless transactions in collector mode A web request that never sets an action name, such as a static asset served without a controller, has nothing to group by, and agent mode does not report it. In collector mode the root span is created with a placeholder name, `appsignal.transaction `, so every actionless request surfaced under that one shared action. The root span is now flagged with `appsignal.ignore_subtrace` on completion when no action was set, the same as `discard` does. The AppSignal Collector drops the subtrace from version 0.10.0 onwards, so these requests no longer appear. --- .../transaction/opentelemetry_backend.rb | 26 +++++++++++++++++-- .../rack/abstract_middleware_spec.rb | 5 ++++ .../transaction/opentelemetry_backend_spec.rb | 25 ++++++++++++++++++ spec/lib/appsignal/transaction_spec.rb | 5 ++++ 4 files changed, 59 insertions(+), 2 deletions(-) diff --git a/lib/appsignal/transaction/opentelemetry_backend.rb b/lib/appsignal/transaction/opentelemetry_backend.rb index dac43c1e8..a3a502d15 100644 --- a/lib/appsignal/transaction/opentelemetry_backend.rb +++ b/lib/appsignal/transaction/opentelemetry_backend.rb @@ -63,6 +63,7 @@ def initialize(transaction_id, namespace, opentelemetry_context: nil, **) @breadcrumb_count = 0 @queue_start = nil @start_time = Time.now + @action_set = false kind = SPAN_KIND_BY_NAMESPACE.fetch(namespace, DEFAULT_SPAN_KIND) @span = start_transaction_span(namespace, kind, opentelemetry_context) @@ -116,6 +117,7 @@ def set_action(action) # the collector treats the span name as authoritative for display. @span.name = action @span.set_attribute("appsignal.action_name", action) + @action_set = true end def set_namespace(namespace) @@ -243,9 +245,12 @@ def finish end def complete - # `teardown` sets `@completed`, so this guard also makes the metric + # `teardown` sets `@completed`, so this guard also makes the body # idempotent across a double `complete`, and skips it on `discard`. - emit_queue_duration_metric unless @completed + unless @completed + emit_queue_duration_metric + ignore_subtrace_without_action + end teardown end @@ -297,6 +302,23 @@ def teardown @span&.finish end + # An action name is required for performance monitoring, and a transaction + # that never set one has nothing to group by. Agent mode simply does not + # report such a transaction (e.g. a static-asset or otherwise unrouted + # request). Collector mode can't represent "no name": the root span keeps + # the placeholder name it was created with (`appsignal.transaction + # `), so without this every actionless request would surface + # under that shared placeholder action. Mirror agent mode by flagging the + # subtrace so the collector drops it, exactly as `discard` does. The flag + # must be set before `teardown` finishes the span, since attributes set on + # an ended span are dropped. This is orthogonal to the queue-duration + # metric above, which is a namespace-level signal on its own stream. + def ignore_subtrace_without_action + return if @action_set + + @span&.set_attribute("appsignal.ignore_subtrace", true) + end + # Emits the queue duration as a distribution metric in both the # per-namespace and per-namespace-and-host series the queue-time graph # reads. Nothing downstream fans these out, so emit both ourselves. diff --git a/spec/lib/appsignal/rack/abstract_middleware_spec.rb b/spec/lib/appsignal/rack/abstract_middleware_spec.rb index e991d147e..c5d1cdcb4 100644 --- a/spec/lib/appsignal/rack/abstract_middleware_spec.rb +++ b/spec/lib/appsignal/rack/abstract_middleware_spec.rb @@ -387,6 +387,11 @@ def perform perform expect(root_span.attributes).to_not have_key("appsignal.action_name") + # With no action to group by, mirror agent mode (which does not + # report an actionless transaction) by flagging the subtrace so the + # collector drops it, instead of surfacing it under the placeholder + # span name. + expect(root_span.attributes["appsignal.ignore_subtrace"]).to be(true) end end end diff --git a/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb b/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb index e5bfaa653..f500e7196 100644 --- a/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb +++ b/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb @@ -515,6 +515,31 @@ def exception_event(backend) expect(backend._completed?).to eq(true) end + + context "when no action was set" do + it "flags the subtrace as ignored so the collector drops the placeholder-named root" do + backend = create_backend + span = backend.instance_variable_get(:@span) + backend.complete + + finished = finished_span(span) + expect(finished.name).to eq("appsignal.transaction http_request") + expect(finished.attributes["appsignal.ignore_subtrace"]).to be(true) + end + end + + context "when an action was set" do + it "does not flag the subtrace as ignored" do + backend = create_backend + backend.set_action("PagesController#show") + span = backend.instance_variable_get(:@span) + backend.complete + + finished = finished_span(span) + expect(finished.attributes["appsignal.action_name"]).to eq("PagesController#show") + expect(finished.attributes).to_not have_key("appsignal.ignore_subtrace") + end + end end describe "#discard" do diff --git a/spec/lib/appsignal/transaction_spec.rb b/spec/lib/appsignal/transaction_spec.rb index ad596a661..c88697d4d 100644 --- a/spec/lib/appsignal/transaction_spec.rb +++ b/spec/lib/appsignal/transaction_spec.rb @@ -512,7 +512,12 @@ def perform end describe "completing a restored transaction" do + # Set an action so this isolates the restore/discard behaviour: an + # actionless transaction would be flagged `ignore_subtrace` on its own + # (see "#complete" / actionless handling), which is unrelated to + # whether it was restored. def perform + transaction.set_action("SomeController#action") transaction.discard! transaction.restore! transaction.complete From f1ad6b4af8ceb0de7efb62b0718966e65dff1215 Mon Sep 17 00:00:00 2001 From: Noemi <45180344+unflxw@users.noreply.github.com> Date: Thu, 16 Jul 2026 13:42:50 +0200 Subject: [PATCH 15/69] Record the Delayed Job enqueue as a producer span Enqueuing a Delayed Job records an `enqueue.delayed_job` event on the active transaction, so an enqueue made from a web request or another job appears in the event timeline. In collector mode that event now also opens a producer span, matching the other job backends and OpenTelemetry's own Delayed Job instrumentation. Delayed Job has no envelope that can carry trace context across the enqueue and perform boundary. Nothing is injected, so the producer and consumer spans are not linked. OpenTelemetry's own instrumentation does the same. --- gemfiles/delayed_job-collector.gemfile | 6 + .../integrations/delayed_job_plugin.rb | 6 +- .../integrations/delayed_job_plugin_spec.rb | 106 +++++++++++++----- 3 files changed, 91 insertions(+), 27 deletions(-) create mode 100644 gemfiles/delayed_job-collector.gemfile diff --git a/gemfiles/delayed_job-collector.gemfile b/gemfiles/delayed_job-collector.gemfile new file mode 100644 index 000000000..0905ec09d --- /dev/null +++ b/gemfiles/delayed_job-collector.gemfile @@ -0,0 +1,6 @@ +# DO NOT EDIT +# This is a generated file by the `rake build_matrix:gemfiles:generate` task. +# It layers the optional OpenTelemetry gems (gemfiles/collector.rb) on top of delayed_job.gemfile. + +eval_gemfile File.expand_path("delayed_job.gemfile", __dir__) +eval_gemfile File.expand_path("collector.rb", __dir__) diff --git a/lib/appsignal/integrations/delayed_job_plugin.rb b/lib/appsignal/integrations/delayed_job_plugin.rb index ed804fd63..196f521e7 100644 --- a/lib/appsignal/integrations/delayed_job_plugin.rb +++ b/lib/appsignal/integrations/delayed_job_plugin.rb @@ -32,7 +32,11 @@ def self.enqueue_with_instrumentation(job, block) return block.call(job) end - Appsignal.instrument("enqueue.delayed_job", "enqueue #{enqueue_name(job)} job") do + Appsignal.instrument( + "enqueue.delayed_job", + "enqueue #{enqueue_name(job)} job", + :opentelemetry_kind => :producer + ) do block.call(job) end end diff --git a/spec/lib/appsignal/integrations/delayed_job_plugin_spec.rb b/spec/lib/appsignal/integrations/delayed_job_plugin_spec.rb index 57bc2bc28..10c0c781e 100644 --- a/spec/lib/appsignal/integrations/delayed_job_plugin_spec.rb +++ b/spec/lib/appsignal/integrations/delayed_job_plugin_spec.rb @@ -35,7 +35,7 @@ def perform_job(job) describe "enqueueing a job" do context "with an active transaction" do - it "records an enqueue event titled after the job" do + it "records an enqueue event titled after the job", :agent_mode do start_agent transaction = http_request_transaction set_current_transaction(transaction) @@ -46,39 +46,43 @@ def perform_job(job) expect(event).to_not be_nil expect(event["title"]).to eq("enqueue DelayedTestJob job") end - end - - context "with a custom appsignal_name" do - before do - stub_const("DelayedNamedJob", Class.new do - def perform - end - def appsignal_name - "CustomName#perform" - end - end) - end - - it "titles the enqueue event with the custom name" do - start_agent + it "records the enqueue as a producer span", :collector_mode do + start_collector_agent transaction = http_request_transaction set_current_transaction(transaction) - Delayed::Job.enqueue(DelayedNamedJob.new) - - event = transaction.to_h["events"].find { |e| e["name"] == "enqueue.delayed_job" } - expect(event["title"]).to eq("enqueue CustomName#perform job") + Delayed::Job.enqueue(DelayedTestJob.new) + Appsignal::Transaction.complete_current! + + # Delayed Job has no envelope to carry trace context, so -- like + # OpenTelemetry's own instrumentation -- nothing is injected; the + # producer span is not linked to the later perform. + producer = event_spans.find { |s| s.name == "enqueue DelayedTestJob job" } + expect(producer.attributes["appsignal.category"]).to eq("enqueue.delayed_job") + expect(producer.kind).to eq(:producer) + expect(producer.parent_span_id).to eq(root_span.span_id) end end context "without an active transaction" do - it "is a transparent pass-through" do + it "is a transparent pass-through", :agent_mode do start_agent expect { Delayed::Job.enqueue(DelayedTestJob.new) } .to change { Delayed::Backend::Test::Job.count }.by(1) end + + it "emits no enqueue span", :collector_mode do + start_collector_agent + + Delayed::Job.enqueue(DelayedTestJob.new) + + # Event spans are named after the title; the event name lives in the + # `appsignal.category` attribute, so match on that. + categories = span_exporter.finished_spans.map { |s| s.attributes["appsignal.category"] } + expect(categories).to_not include("enqueue.delayed_job") + end end if DependencyHelper.active_job_present? @@ -96,7 +100,7 @@ def perform(*) end) end - it "does not record a second enqueue event" do + it "does not record a second enqueue event", :agent_mode do start_agent transaction = http_request_transaction set_current_transaction(transaction) @@ -109,11 +113,35 @@ def perform(*) end end end + + context "with a custom appsignal_name" do + before do + stub_const("DelayedNamedJob", Class.new do + def perform + end + + def appsignal_name + "CustomName#perform" + end + end) + end + + it "titles the enqueue event with the custom name", :agent_mode do + start_agent + transaction = http_request_transaction + set_current_transaction(transaction) + + Delayed::Job.enqueue(DelayedNamedJob.new) + + event = transaction.to_h["events"].find { |e| e["name"] == "enqueue.delayed_job" } + expect(event["title"]).to eq("enqueue CustomName#perform job") + end + end end describe "performing a job" do context "with a normal job" do - it "wraps it in a background_job transaction" do + it "wraps it in a background_job transaction", :agent_mode do start_agent job = Delayed::Job.enqueue(DelayedTestJob.new) @@ -126,6 +154,19 @@ def perform(*) expect(transaction).to include_event(:name => "perform_job.delayed_job") expect(transaction).to include_tags("attempts" => 0, "priority" => 0) end + + it "wraps it in a consumer span", :collector_mode do + start_collector_agent + job = Delayed::Job.enqueue(DelayedTestJob.new) + + perform_job(job) + Appsignal::Transaction.complete_current! + + expect(root_span.kind).to eq(:consumer) + expect(root_span.attributes["appsignal.action_name"]).to eq("DelayedTestJob#perform") + expect(root_span.attributes["appsignal.namespace"]).to eq("background") + expect(event_spans.map(&:name)).to include("perform_job.delayed_job") + end end context "with a job that raises" do @@ -137,7 +178,7 @@ def perform end) end - it "records the error on the transaction" do + it "records the error on the transaction", :agent_mode do start_agent job = Delayed::Job.enqueue(DelayedErrorJob.new) @@ -150,6 +191,19 @@ def perform expect(transaction).to have_action("DelayedErrorJob#perform") expect(transaction).to have_error("ExampleException", "uh oh") end + + it "records the error on the consumer span", :collector_mode do + start_collector_agent + job = Delayed::Job.enqueue(DelayedErrorJob.new) + + expect { perform_job(job) }.to raise_error(ExampleException, "uh oh") + Appsignal::Transaction.complete_current! + + expect(root_span.kind).to eq(:consumer) + event = root_span.events.find { |e| e.name == "exception" } + expect(event.attributes["exception.type"]).to eq("ExampleException") + expect(event.attributes["exception.message"]).to eq("uh oh") + end end context "with a custom appsignal_name" do @@ -164,7 +218,7 @@ def appsignal_name end) end - it "uses the custom name as the action" do + it "uses the custom name as the action", :agent_mode do start_agent job = Delayed::Job.enqueue(DelayedNamedJob.new) @@ -187,7 +241,7 @@ def perform(*) end) end - it "uses the Active Job class as the action" do + it "uses the Active Job class as the action", :agent_mode do start_agent keep_transactions do From d5ac552ad150d4fd6b4c4ec4c6d810af06e9b7ba Mon Sep 17 00:00:00 2001 From: Noemi <45180344+unflxw@users.noreply.github.com> Date: Thu, 16 Jul 2026 13:52:45 +0200 Subject: [PATCH 16/69] Dual-mode transaction and helper spec coverage The transaction and helper specs had behaviours covered in only one mode. Most were agent-mode leftovers that assert through `to_h`-based matchers, which do nothing in collector mode, so the gap was invisible rather than deliberate. Those behaviours now run in both modes. --- .../metrics/opentelemetry_backend_spec.rb | 8 + spec/lib/appsignal/transaction_spec.rb | 21 +- spec/lib/appsignal_spec.rb | 536 ++++++++++++++---- 3 files changed, 445 insertions(+), 120 deletions(-) diff --git a/spec/lib/appsignal/metrics/opentelemetry_backend_spec.rb b/spec/lib/appsignal/metrics/opentelemetry_backend_spec.rb index a8aa30b91..984ede8a3 100644 --- a/spec/lib/appsignal/metrics/opentelemetry_backend_spec.rb +++ b/spec/lib/appsignal/metrics/opentelemetry_backend_spec.rb @@ -46,6 +46,14 @@ def snapshot_for(name) snapshot = snapshot_for("my_gauge") expect(snapshot.data_points.first.value).to eq(10.0) end + + it "coerces a symbol metric name to a string" do + described_class.set_gauge(:my_gauge, 1.0, {}) + + # snapshot_for matches on the string name, so this only finds the + # snapshot if the symbol name was coerced. + expect(snapshot_for("my_gauge")).not_to be_nil + end end describe ".increment_counter" do diff --git a/spec/lib/appsignal/transaction_spec.rb b/spec/lib/appsignal/transaction_spec.rb index c88697d4d..8135e5608 100644 --- a/spec/lib/appsignal/transaction_spec.rb +++ b/spec/lib/appsignal/transaction_spec.rb @@ -3959,7 +3959,8 @@ def exception_event ) end - it "does not keep error causes from previously set errors" do + it "does not keep error causes from previously set errors", :agent_mode do + start_agent(**start_agent_args) transaction.send(:_set_error, error) transaction.send(:_set_error, error_without_cause) @@ -3972,6 +3973,24 @@ def exception_event expect(transaction).to include_error_causes([]) end + it "records no causes for a later cause-less error in collector mode", + :collector_mode do + start_collector_agent + transaction.send(:_set_error, error) + transaction.send(:_set_error, error_without_cause) + transaction.complete + + # Collector mode records each error as its own exception event rather + # than overwriting, so the later cause-less error's event carries no + # causes; the earlier error's causes do not leak onto it. + events = Array(root_span.events).select { |event| event.name == "exception" } + without_cause = events.find do |event| + event.attributes["exception.message"] == "error without cause" + end + expect(without_cause).not_to be_nil + expect(without_cause.attributes).not_to have_key("appsignal.error_causes") + end + describe "with app paths" do let(:root_path) { project_fixture_path } let(:error) do diff --git a/spec/lib/appsignal_spec.rb b/spec/lib/appsignal_spec.rb index 625a79b24..95f0b679c 100644 --- a/spec/lib/appsignal_spec.rb +++ b/spec/lib/appsignal_spec.rb @@ -1565,24 +1565,57 @@ def perform 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") + describe "merging the params if called multiple times" do + def perform + set_current_transaction(transaction) + Appsignal.add_params("param1" => "value1") + Appsignal.add_params("param2" => "value2") + end - transaction._sample - expect(transaction).to include_params( - "param1" => "value1", - "param2" => "value2" - ) + it "in agent mode", :agent_mode do + start_agent + perform + + transaction._sample + expect(transaction).to include_params( + "param1" => "value1", + "param2" => "value2" + ) + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + expect(JSON.parse(root_span.attributes["appsignal.request.payload"])) + .to eq("param1" => "value1", "param2" => "value2") + end end - it "adds parameters with a block to the transaction" do - Appsignal.add_params { { "param1" => "value1" } } + describe "adding parameters with a block" do + def perform + set_current_transaction(transaction) + Appsignal.add_params { { "param1" => "value1" } } + end - transaction._sample - expect(transaction).to include_params("param1" => "value1") + it "in agent mode", :agent_mode do + start_agent + perform + + transaction._sample + expect(transaction).to include_params("param1" => "value1") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + expect(JSON.parse(root_span.attributes["appsignal.request.payload"])) + .to eq("param1" => "value1") + end end end @@ -1596,19 +1629,30 @@ def perform end describe ".set_empty_params!" do - before { start_agent } - - context "with transaction" do + describe "marking parameters to be sent as an empty value" do let(:transaction) { http_request_transaction } - before { set_current_transaction(transaction) } - it "marks parameters to be sent as an empty value" do + def perform + set_current_transaction(transaction) Appsignal.add_params("key1" => "value") Appsignal.set_empty_params! + end + + it "in agent mode", :agent_mode do + start_agent + perform transaction._sample expect(transaction).to_not include_params end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + expect(root_span.attributes).to_not have_key("appsignal.request.payload") + end end end @@ -1649,24 +1693,57 @@ def perform 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") + describe "merging the session data if called multiple times" do + def perform + set_current_transaction(transaction) + Appsignal.set_session_data("data1" => "value1") + Appsignal.set_session_data("data2" => "value2") + end - transaction._sample - expect(transaction).to include_session_data( - "data1" => "value1", - "data2" => "value2" - ) + it "in agent mode", :agent_mode do + start_agent + perform + + transaction._sample + expect(transaction).to include_session_data( + "data1" => "value1", + "data2" => "value2" + ) + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + expect(JSON.parse(root_span.attributes["appsignal.request.session_data"])) + .to eq("data1" => "value1", "data2" => "value2") + end end - it "adds session data with a block to the transaction" do - Appsignal.set_session_data { { "data" => "value1" } } + describe "adding session data with a block" do + def perform + set_current_transaction(transaction) + Appsignal.set_session_data { { "data" => "value1" } } + end - transaction._sample - expect(transaction).to include_session_data("data" => "value1") + it "in agent mode", :agent_mode do + start_agent + perform + + transaction._sample + expect(transaction).to include_session_data("data" => "value1") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + expect(JSON.parse(root_span.attributes["appsignal.request.session_data"])) + .to eq("data" => "value1") + end end end @@ -1716,24 +1793,59 @@ def perform 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") - Appsignal.add_headers("REQUEST_METHOD" => "GET") + # Uses true HTTP headers (rather than CGI vars like PATH_INFO) because + # collector mode only emits the HTTP_*/CONTENT_* headers as + # http.request.header.* attributes and drops the rest. + describe "merging the request headers if called multiple times" do + def perform + set_current_transaction(transaction) + Appsignal.add_headers("HTTP_ACCEPT" => "text/html") + Appsignal.add_headers("HTTP_ACCEPT_CHARSET" => "utf-8") + end - transaction._sample - expect(transaction).to include_environment( - "PATH_INFO" => "/some-path", - "REQUEST_METHOD" => "GET" - ) + it "in agent mode", :agent_mode do + start_agent + perform + + transaction._sample + expect(transaction).to include_environment( + "HTTP_ACCEPT" => "text/html", + "HTTP_ACCEPT_CHARSET" => "utf-8" + ) + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + expect(root_span.attributes["http.request.header.accept"]).to eq("text/html") + expect(root_span.attributes["http.request.header.accept-charset"]).to eq("utf-8") + end end - it "adds request headers with a block to the transaction" do - Appsignal.add_headers { { "PATH_INFO" => "/some-path" } } + describe "adding request headers with a block" do + def perform + set_current_transaction(transaction) + Appsignal.add_headers { { "HTTP_ACCEPT" => "text/html" } } + end - transaction._sample - expect(transaction).to include_environment("PATH_INFO" => "/some-path") + it "in agent mode", :agent_mode do + start_agent + perform + + transaction._sample + expect(transaction).to include_environment("HTTP_ACCEPT" => "text/html") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + expect(root_span.attributes["http.request.header.accept"]).to eq("text/html") + end end end @@ -1791,17 +1903,33 @@ def perform 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") + describe "merging the custom data if called multiple times" do + def perform + set_current_transaction(transaction) + Appsignal.add_custom_data(:abc => "value") + Appsignal.add_custom_data(:def => "value") + end + + it "in agent mode", :agent_mode do + start_agent + perform - transaction._sample - expect(transaction).to include_custom_data( - "abc" => "value", - "def" => "value" - ) + 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 @@ -2008,17 +2136,6 @@ def perform end end - it "yields and allows additional metadata to be set with global helpers" do - Appsignal.send_error(StandardError.new("my_error")) do - Appsignal.set_action("my_action") - Appsignal.set_namespace("my_namespace") - 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") - end - it "logs a raising block without raising, naming the helper" do logs = capture_logs do expect do @@ -2034,29 +2151,82 @@ def perform ) end - it "yields to set metadata and doesn't modify the active transaction" do - active_transaction = http_request_transaction - active_transaction.set_action("active action") - active_transaction.set_namespace("active namespace") - set_current_transaction(active_transaction) - expect(current_transaction).to eq(active_transaction) + # The block uses the global helpers, which act on the current + # transaction. send_error runs the block against its own transaction, so + # the block's metadata and the error must land there, not on the + # transaction that was active when send_error was called. That active + # transaction must be restored afterwards and left untouched. + describe "yielding to set metadata with global helpers, " \ + "with an active transaction" do + def perform + active_transaction = create_transaction(Appsignal::Transaction::HTTP_REQUEST) + active_transaction.set_action("active action") + active_transaction.set_namespace("active namespace") + + Appsignal.send_error(StandardError.new("my_error")) do + Appsignal.set_action("my_action") + Appsignal.set_namespace("my_namespace") + Appsignal.add_tags(:block_tag => "value") + end - Appsignal.send_error(StandardError.new("my_error")) do - Appsignal.set_action("my_action") - Appsignal.set_namespace("my_namespace") + active_transaction end - # Restores the active_transaction as the current transaction - expect(current_transaction).to eq(active_transaction) + it "in agent mode", :agent_mode do + start_agent + active_transaction = perform - expect(last_transaction).to have_namespace("my_namespace") - expect(last_transaction).to have_action("my_action") - expect(last_transaction).to have_error("StandardError", "my_error") - expect(last_transaction).to be_completed + # The active transaction is restored as the current transaction. + expect(current_transaction).to eq(active_transaction) + + # The block ran against send_error's own transaction. + expect(last_transaction).to have_namespace("my_namespace") + expect(last_transaction).to have_action("my_action") + expect(last_transaction).to include_tags("block_tag" => "value") + expect(last_transaction).to have_error("StandardError", "my_error") + expect(last_transaction).to be_completed + + # The active transaction is untouched. + expect(active_transaction).to have_namespace("active namespace") + expect(active_transaction).to have_action("active action") + expect(active_transaction).to_not include_tags + expect(active_transaction).to_not be_completed + end + + it "in collector mode", :collector_mode do + start_collector_agent + active_transaction = perform - expect(active_transaction).to have_namespace("active namespace") - expect(active_transaction).to have_action("active action") - expect(active_transaction).to_not be_completed + # send_error restores the active transaction as both the current + # AppSignal transaction and the current OpenTelemetry span, so the + # active trace continues rather than being dropped or left open. + expect(current_transaction).to eq(active_transaction) + active_trace_id = ::OpenTelemetry::Trace.current_span.context.trace_id + + # Finish the active transaction so its root span is exported too. + Appsignal::Transaction.complete_current! + + server_spans = span_exporter.finished_spans.select { |s| s.kind == :server } + error_span = server_spans.find { |s| s.name == "my_action" } + active_span = server_spans.find { |s| s.name == "active action" } + expect(error_span).not_to be_nil + expect(active_span).not_to be_nil + + # send_error reports on its own transaction, a separate trace from + # the active one, which continues on the trace that stayed current. + expect(active_span.trace_id).to eq(active_trace_id) + expect(error_span.trace_id).not_to eq(active_trace_id) + + # The block's metadata and the error land on send_error's trace. + expect(error_span.attributes["appsignal.namespace"]).to eq("my_namespace") + expect(error_span.attributes["appsignal.tag.block_tag"]).to eq("value") + expect(Array(error_span.events).map(&:name)).to include("exception") + + # The active trace is untouched: no block metadata, no error. + expect(active_span.attributes["appsignal.namespace"]).to eq("active namespace") + expect(active_span.attributes).to_not have_key("appsignal.tag.block_tag") + expect(Array(active_span.events).map(&:name)).to_not include("exception") + end end end end @@ -2103,7 +2273,14 @@ def perform end context "when there is an active transaction" do - before { set_current_transaction(transaction) } + # Mode-tagged examples set the current transaction in their own body, + # after starting the agent, so collector mode builds the root span + # against the in-memory provider. Untagged examples keep the hook. + before do |example| + unless example.metadata[:agent_mode] || example.metadata[:collector_mode] + set_current_transaction(transaction) + end + end context "when the error is not an Exception" do let(:error) { Object.new } @@ -2126,17 +2303,42 @@ def perform end end - context "when given a block" do - it "yields the transaction and allows additional metadata to be set" do + describe "when given a block" do + # The set_error helper yields the current transaction and runs the + # block synchronously, so the block's metadata lands on the active + # transaction in both modes. + def perform + set_current_transaction(transaction) Appsignal.set_error(StandardError.new("my_error")) do |t| t.set_action("my_action") t.set_namespace("my_namespace") end + end + + it "in agent mode", :agent_mode do + start_agent + perform expect(transaction).to have_namespace("my_namespace") expect(transaction).to have_action("my_action") expect(transaction).to have_error("StandardError", "my_error") end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + expect(root_span.name).to eq("my_action") + expect(root_span.attributes["appsignal.action_name"]).to eq("my_action") + expect(root_span.attributes["appsignal.namespace"]).to eq("my_namespace") + + event = root_span.events.find { |e| e.name == "exception" } + expect(event).not_to be_nil + expect(event.attributes["exception.type"]).to eq("StandardError") + expect(event.attributes["exception.message"]).to eq("my_error") + expect(root_span.status.code).to eq(::OpenTelemetry::Trace::Status::ERROR) + end end end @@ -2206,34 +2408,61 @@ def perform end context "when given a block" do - it "yields the transaction and allows additional metadata to be set" do - Appsignal.report_error(error) do |t| - t.set_action("my_action") - t.set_namespace("my_namespace") - t.set_tags(:tag1 => "value1") + # With no active transaction, report_error creates its own + # transaction and completes it, so the block's metadata and the error + # land on that transaction in both modes. + shared_examples "reports the metadata on its own transaction" do + it "in agent mode", :agent_mode do + start_agent + perform + + transaction = last_transaction + expect(transaction).to have_namespace("my_namespace") + expect(transaction).to have_action("my_action") + expect(transaction).to have_error("ExampleException", "error message") + expect(transaction).to include_tags("tag1" => "value1") + expect(transaction).to be_completed end - transaction = last_transaction - expect(transaction).to have_namespace("my_namespace") - expect(transaction).to have_action("my_action") - expect(transaction).to have_error("ExampleException", "error message") - expect(transaction).to include_tags("tag1" => "value1") - expect(transaction).to be_completed + it "in collector mode", :collector_mode do + start_collector_agent + perform + + expect(root_span.name).to eq("my_action") + expect(root_span.attributes["appsignal.action_name"]).to eq("my_action") + expect(root_span.attributes["appsignal.namespace"]).to eq("my_namespace") + expect(root_span.attributes["appsignal.tag.tag1"]).to eq("value1") + + event = root_span.events.find { |e| e.name == "exception" } + expect(event).not_to be_nil + expect(event.attributes["exception.type"]).to eq("ExampleException") + expect(event.attributes["exception.message"]).to eq("error message") + expect(root_span.status.code).to eq(::OpenTelemetry::Trace::Status::ERROR) + end end - it "yields and allows additional metadata to be set with the global helpers" do - Appsignal.report_error(error) do - Appsignal.set_action("my_action") - Appsignal.set_namespace("my_namespace") - Appsignal.set_tags(:tag1 => "value1") + describe "yielding the transaction" do + def perform + Appsignal.report_error(error) do |t| + t.set_action("my_action") + t.set_namespace("my_namespace") + t.set_tags(:tag1 => "value1") + end end - transaction = last_transaction - expect(transaction).to have_namespace("my_namespace") - expect(transaction).to have_action("my_action") - expect(transaction).to have_error("ExampleException", "error message") - expect(transaction).to include_tags("tag1" => "value1") - expect(transaction).to be_completed + include_examples "reports the metadata on its own transaction" + end + + describe "with the global helpers" do + def perform + Appsignal.report_error(error) do + Appsignal.set_action("my_action") + Appsignal.set_namespace("my_namespace") + Appsignal.set_tags(:tag1 => "value1") + end + end + + include_examples "reports the metadata on its own transaction" end it "logs a raising block without raising, naming the helper" do @@ -2368,14 +2597,20 @@ def perform end end - it "does not complete the transaction" do + it_in_both_modes "does not complete the transaction" do + set_current_transaction(transaction) Appsignal.report_error(error) - expect(last_transaction).to_not be_completed + expect(transaction).to_not be_completed end context "when given a block" do - before do + # Agent mode defers the block to completion; the collector example + # below starts the agent and reports the error itself, so it skips + # this setup. + before do |example| + next if example.metadata[:collector_mode] + Appsignal.report_error(error) do |t| t.set_action("my_action") t.set_namespace("my_namespace") @@ -2437,6 +2672,32 @@ def perform expect(transaction).to have_error("ExampleException", "error message") expect(transaction).to include_tags("tag1" => "value1") end + + it "applies the block to the transaction eagerly", :collector_mode do + start_collector_agent + set_current_transaction(transaction) + Appsignal.report_error(error) do |t| + t.set_action("my_action") + t.set_namespace("my_namespace") + t.set_tags(:tag1 => "value1") + end + + # Collector mode records the error and runs its block when the error + # is added, not at completion as agent mode does. So the action name + # is on the active transaction's span right away. Tags flush with the + # other sample data at completion. + expect(::OpenTelemetry::Trace.current_span.name).to eq("my_action") + + transaction.complete + expect(root_span.name).to eq("my_action") + expect(root_span.attributes["appsignal.action_name"]).to eq("my_action") + expect(root_span.attributes["appsignal.namespace"]).to eq("my_namespace") + expect(root_span.attributes["appsignal.tag.tag1"]).to eq("value1") + + event = root_span.events.find { |e| e.name == "exception" } + expect(event).not_to be_nil + expect(event.attributes["exception.message"]).to eq("error message") + end end end end @@ -2444,15 +2705,34 @@ def perform describe ".set_action" do around { |example| keep_transactions { example.run } } - context "with current transaction" do - before { set_current_transaction(transaction) } - - it "sets the namespace on the current transaction" do + describe "setting the action on the current transaction" do + def perform + set_current_transaction(transaction) Appsignal.set_action("custom") + end + + it "in agent mode", :agent_mode do + start_agent + perform expect(transaction).to have_action("custom") end + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + expect(root_span.name).to eq("custom") + expect(root_span.attributes["appsignal.action_name"]).to eq("custom") + end + end + + # The nil and no-current-transaction cases are guarded in the helper + # before any backend, so they behave the same in both modes. + context "with current transaction" do + before { set_current_transaction(transaction) } + it "does not set the action if the action is nil" do Appsignal.set_action(nil) @@ -2461,7 +2741,7 @@ def perform end context "without current transaction" do - it "does not set ther action" do + it "does not set the action" do Appsignal.set_action("custom") expect(transaction).to_not have_action @@ -2472,15 +2752,33 @@ def perform describe ".set_namespace" do around { |example| keep_transactions { example.run } } - context "with current transaction" do - before { set_current_transaction(transaction) } - - it "should set the namespace to the current transaction" do + describe "setting the namespace on the current transaction" do + def perform + set_current_transaction(transaction) Appsignal.set_namespace("custom") + end + + it "in agent mode", :agent_mode do + start_agent + perform expect(transaction).to have_namespace("custom") end + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + expect(root_span.attributes["appsignal.namespace"]).to eq("custom") + end + end + + # The nil and no-current-transaction cases are guarded in the helper + # before any backend, so they behave the same in both modes. + context "with current transaction" do + before { set_current_transaction(transaction) } + it "does not update the namespace if the namespace is nil" do Appsignal.set_namespace(nil) From 383b34c93898a98eb9faab3ff3c971566f05c76c Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Tue, 21 Jul 2026 15:07:12 +0200 Subject: [PATCH 17/69] Resolve OTel logger per emit, drop the cache The OpenTelemetry logs SDK already caches the logger in its provider, keyed by name and version. Caching it again in the backend duplicated that, and the only thing the local cache saved was a provider registry lookup on each emit. The logger is resolved from the provider on every emit. That is a registry lookup rather than a rebuild, and it always reflects the currently configured provider. The test-only `reset!` hook is gone, because it existed only to drop this cache when the provider was swapped between tests. --- lib/appsignal/logger/opentelemetry_backend.rb | 20 ++++--------------- .../logger/opentelemetry_backend_spec.rb | 16 ++------------- .../support/shared_contexts/collector_mode.rb | 5 ++--- 3 files changed, 8 insertions(+), 33 deletions(-) diff --git a/lib/appsignal/logger/opentelemetry_backend.rb b/lib/appsignal/logger/opentelemetry_backend.rb index 1cf4f62be..6e3584e0c 100644 --- a/lib/appsignal/logger/opentelemetry_backend.rb +++ b/lib/appsignal/logger/opentelemetry_backend.rb @@ -38,8 +38,6 @@ module OpenTelemetryBackend 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]) @@ -54,23 +52,13 @@ def emit(group, severity, format, message, 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. + # Resolve the OTel logger on each emit. The logger provider caches it + # by name, so this is a registry lookup rather than a rebuild, and it + # always reflects the currently configured provider. def logger - @logger || MUTEX.synchronize do - @logger ||= ::OpenTelemetry.logger_provider.logger(:name => "appsignal-logger") - end + ::OpenTelemetry.logger_provider.logger(:name => "appsignal-logger") end end end diff --git a/spec/lib/appsignal/logger/opentelemetry_backend_spec.rb b/spec/lib/appsignal/logger/opentelemetry_backend_spec.rb index ce19f573e..a1de148d4 100644 --- a/spec/lib/appsignal/logger/opentelemetry_backend_spec.rb +++ b/spec/lib/appsignal/logger/opentelemetry_backend_spec.rb @@ -15,11 +15,8 @@ before do ::OpenTelemetry.logger_provider = logger_provider - described_class.reset! end - after { described_class.reset! } - def emitted_records exporter.emitted_log_records end @@ -126,18 +123,9 @@ def emitted_records 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 + describe "logger resolution" do + it "picks up a swapped logger provider on the next emit" do described_class.emit("g", ::Logger::INFO, Appsignal::Logger::PLAINTEXT, "a", {}) - described_class.reset! new_provider = ::OpenTelemetry::SDK::Logs::LoggerProvider.new new_exporter = ::OpenTelemetry::SDK::Logs::Export::InMemoryLogRecordExporter.new diff --git a/spec/support/shared_contexts/collector_mode.rb b/spec/support/shared_contexts/collector_mode.rb index a5a796e7c..a660111a6 100644 --- a/spec/support/shared_contexts/collector_mode.rb +++ b/spec/support/shared_contexts/collector_mode.rb @@ -87,13 +87,12 @@ def start_collector_agent # 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. + # logs back, and reset the metrics backend so its cached instruments + # re-resolve against this meter provider on the next emit. ::OpenTelemetry.tracer_provider = tracer_provider ::OpenTelemetry.meter_provider = meter_provider ::OpenTelemetry.logger_provider = logger_provider Appsignal::Metrics::OpenTelemetryBackend.reset! - Appsignal::Logger::OpenTelemetryBackend.reset! end def root_span From 467ec4b122473180f3eb23bedb15066704470c1d Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Tue, 21 Jul 2026 15:07:12 +0200 Subject: [PATCH 18/69] Add opentelemetry_scope to instrumentation An optional `opentelemetry_scope:` argument names the OpenTelemetry instrumentation scope a span belongs to, as a `[name, version]` pair. It threads from the instrumentation helpers and `Transaction.create` through the transaction to the backends, the same way `opentelemetry_kind` does. In collector mode the OpenTelemetry backend resolves a tracer for that scope and uses it for the root span and every event span. A nil scope, or one with a blank name, falls back to the default `appsignal-ruby` scope at the gem version, so every span carries a scope. The extension backend accepts the argument and ignores it, because agent mode has no notion of instrumentation scope. --- lib/appsignal/helpers/instrumentation.rb | 37 ++++++--- lib/appsignal/transaction.rb | 34 ++++++--- lib/appsignal/transaction/base_backend.rb | 7 +- .../transaction/extension_backend.rb | 18 +++-- .../transaction/opentelemetry_backend.rb | 37 +++++++-- sig/appsignal.rbi | 76 +++++++++++++------ sig/appsignal.rbs | 38 ++++++---- .../transaction/opentelemetry_backend_spec.rb | 71 ++++++++++++++++- spec/lib/appsignal/transaction_spec.rb | 39 +++++++++- spec/lib/appsignal_spec.rb | 33 ++++++++ .../support/shared_contexts/collector_mode.rb | 6 ++ 11 files changed, 318 insertions(+), 78 deletions(-) diff --git a/lib/appsignal/helpers/instrumentation.rb b/lib/appsignal/helpers/instrumentation.rb index 44bc681f0..a0d0f5e87 100644 --- a/lib/appsignal/helpers/instrumentation.rb +++ b/lib/appsignal/helpers/instrumentation.rb @@ -112,7 +112,7 @@ module Instrumentation # # @see https://docs.appsignal.com/ruby/instrumentation/background-jobs.html # Monitor guide - def monitor(action:, namespace: nil) + def monitor(action:, namespace: nil, opentelemetry_scope: nil) return yield unless Appsignal.active? has_parent_transaction = Appsignal::Transaction.current? @@ -133,7 +133,10 @@ def monitor(action:, namespace: nil) if has_parent_transaction Appsignal::Transaction.current else - Appsignal::Transaction.create(namespace || Appsignal::Transaction::HTTP_REQUEST) + Appsignal::Transaction.create( + namespace || Appsignal::Transaction::HTTP_REQUEST, + :opentelemetry_scope => opentelemetry_scope + ) end begin @@ -175,13 +178,18 @@ def monitor(action:, namespace: nil) # @return [Object, nil] The value of the given block is returned. # # @see monitor - def monitor_and_stop(action:, namespace: nil, &block) + def monitor_and_stop(action:, namespace: nil, opentelemetry_scope: nil, &block) Appsignal::Utils::StdoutAndLoggerMessage.warning \ "The `Appsignal.monitor_and_stop` helper is deprecated. " \ "Use the `Appsignal.monitor` along with our `enable_at_exit_hook` " \ "option instead." - monitor(:namespace => namespace, :action => action, &block) + monitor( + :namespace => namespace, + :action => action, + :opentelemetry_scope => opentelemetry_scope, + &block + ) ensure Appsignal.stop("monitor_and_stop") end @@ -226,7 +234,7 @@ def monitor_and_stop(action:, namespace: nil, &block) # # @see https://docs.appsignal.com/ruby/instrumentation/exception-handling.html # Exception handling guide - def send_error(error, &block) + def send_error(error, opentelemetry_scope: nil, &block) return unless Appsignal.active? unless error.is_a?(Exception) @@ -237,7 +245,10 @@ def send_error(error, &block) end transaction = - Appsignal::Transaction.new(Appsignal::Transaction::HTTP_REQUEST) + Appsignal::Transaction.new( + Appsignal::Transaction::HTTP_REQUEST, + :opentelemetry_scope => opentelemetry_scope + ) transaction.set_error(error, :source => "Appsignal.send_error", &block) transaction.complete @@ -356,7 +367,7 @@ def set_error(exception) # # @see https://docs.appsignal.com/ruby/instrumentation/exception-handling.html # Exception handling guide - def report_error(exception, &block) + def report_error(exception, opentelemetry_scope: nil, &block) unless exception.is_a?(Exception) Appsignal.internal_logger.error "Appsignal.report_error: " \ "Cannot add error. " \ @@ -370,7 +381,10 @@ def report_error(exception, &block) if has_parent_transaction Appsignal::Transaction.current else - Appsignal::Transaction.new(Appsignal::Transaction::HTTP_REQUEST) + Appsignal::Transaction.new( + Appsignal::Transaction::HTTP_REQUEST, + :opentelemetry_scope => opentelemetry_scope + ) end transaction.add_error(exception, :source => "Appsignal.report_error", &block) @@ -795,12 +809,13 @@ def add_breadcrumb(category, action, message = "", metadata = {}, time = Time.no # AppSignal custom instrumentation guide # @see https://docs.appsignal.com/api/event-names.html # AppSignal event naming guide - def instrument( + def instrument( # rubocop:disable Metrics/ParameterLists name, title = nil, body = nil, body_format = Appsignal::EventFormatter::DEFAULT, opentelemetry_kind: nil, + opentelemetry_scope: nil, &block ) Appsignal::Transaction.current @@ -810,6 +825,7 @@ def instrument( body, body_format, :opentelemetry_kind => opentelemetry_kind, + :opentelemetry_scope => opentelemetry_scope, &block ) end @@ -844,12 +860,13 @@ def instrument( # AppSignal custom instrumentation guide # @see https://docs.appsignal.com/api/event-names.html # AppSignal event naming guide - def instrument_sql(name, title = nil, body = nil, &block) + def instrument_sql(name, title = nil, body = nil, opentelemetry_scope: nil, &block) instrument( name, title, body, Appsignal::EventFormatter::SQL_BODY_FORMAT, + :opentelemetry_scope => opentelemetry_scope, &block ) end diff --git a/lib/appsignal/transaction.rb b/lib/appsignal/transaction.rb index b2f5d50b6..bcf464b4b 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, opentelemetry_context: nil) + def create(namespace, opentelemetry_context: nil, opentelemetry_scope: nil) # Reset the transaction if it was already completed but not cleared if Thread.current[:appsignal_transaction]&.completed? Thread.current[:appsignal_transaction] = nil @@ -40,7 +40,8 @@ def create(namespace, opentelemetry_context: nil) set_current_transaction( Appsignal::Transaction.new( namespace, - :opentelemetry_context => opentelemetry_context + :opentelemetry_context => opentelemetry_context, + :opentelemetry_scope => opentelemetry_scope ) ) else @@ -165,7 +166,8 @@ def last_errors # @param namespace [String] Namespace of the to be created transaction. # @see create # @!visibility private - def initialize(namespace, id: SecureRandom.uuid, backend: nil, opentelemetry_context: nil) + def initialize(namespace, id: SecureRandom.uuid, backend: nil, + opentelemetry_context: nil, opentelemetry_scope: nil) @transaction_id = id @action = nil @namespace = namespace @@ -196,7 +198,8 @@ def initialize(namespace, id: SecureRandom.uuid, backend: nil, opentelemetry_con @backend = backend || Appsignal::Backends.transaction.new( @transaction_id, @namespace, - :opentelemetry_context => opentelemetry_context + :opentelemetry_context => opentelemetry_context, + :opentelemetry_scope => opentelemetry_scope ) run_after_create_hooks @@ -665,10 +668,13 @@ def add_error(error, source: nil, &block) # @!visibility private # @see Helpers::Instrumentation#instrument - def start_event(opentelemetry_kind: nil) + def start_event(opentelemetry_kind: nil, opentelemetry_scope: nil) return if paused? - @backend.start_event(:opentelemetry_kind => opentelemetry_kind) + @backend.start_event( + :opentelemetry_kind => opentelemetry_kind, + :opentelemetry_scope => opentelemetry_scope + ) end # @!visibility private @@ -692,7 +698,8 @@ def record_event( # rubocop:disable Metrics/ParameterLists body, duration, body_format = Appsignal::EventFormatter::DEFAULT, - opentelemetry_kind: nil + opentelemetry_kind: nil, + opentelemetry_scope: nil ) return if paused? @@ -702,20 +709,25 @@ def record_event( # rubocop:disable Metrics/ParameterLists body || BLANK, body_format || Appsignal::EventFormatter::DEFAULT, duration, - :opentelemetry_kind => opentelemetry_kind + :opentelemetry_kind => opentelemetry_kind, + :opentelemetry_scope => opentelemetry_scope ) end # @!visibility private # @see Helpers::Instrumentation#instrument - def instrument( + def instrument( # rubocop:disable Metrics/ParameterLists name, title = nil, body = nil, body_format = Appsignal::EventFormatter::DEFAULT, - opentelemetry_kind: nil + opentelemetry_kind: nil, + opentelemetry_scope: nil ) - start_event(:opentelemetry_kind => opentelemetry_kind) + start_event( + :opentelemetry_kind => opentelemetry_kind, + :opentelemetry_scope => opentelemetry_scope + ) yield if block_given? ensure finish_event(name, title, body, body_format) diff --git a/lib/appsignal/transaction/base_backend.rb b/lib/appsignal/transaction/base_backend.rb index 5a4bed945..b5f3d940d 100644 --- a/lib/appsignal/transaction/base_backend.rb +++ b/lib/appsignal/transaction/base_backend.rb @@ -10,7 +10,7 @@ class Transaction # contract; a backend that leaves a method unimplemented raises here. class BaseBackend # Instrumented events. - def start_event(opentelemetry_kind: nil) + def start_event(opentelemetry_kind: nil, opentelemetry_scope: nil) raise NotImplementedError end @@ -18,7 +18,10 @@ 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 + def record_event( # rubocop:disable Metrics/ParameterLists + _name, _title, _body, _body_format, _duration, + opentelemetry_kind: nil, opentelemetry_scope: nil + ) raise NotImplementedError end diff --git a/lib/appsignal/transaction/extension_backend.rb b/lib/appsignal/transaction/extension_backend.rb index 7ddedecf7..46cf74045 100644 --- a/lib/appsignal/transaction/extension_backend.rb +++ b/lib/appsignal/transaction/extension_backend.rb @@ -20,9 +20,11 @@ class ExtensionBackend < BaseBackend # @!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 + # `opentelemetry_context` is an incoming trace context and + # `opentelemetry_scope` an instrumentation scope, both used only in + # collector mode; agent mode has no notion of either, so they're ignored + # here. + def initialize(transaction_id, namespace, handle: nil, opentelemetry_context: nil, opentelemetry_scope: nil) # rubocop:disable Lint/UnusedMethodArgument, Layout/LineLength super() @handle = handle || Appsignal::Extension.start_transaction(transaction_id, namespace, 0) || @@ -30,8 +32,9 @@ def initialize(transaction_id, namespace, handle: nil, opentelemetry_context: ni @breadcrumbs = [] end - # Agent mode has no span kind; `opentelemetry_kind` is ignored here. - def start_event(opentelemetry_kind: nil) # rubocop:disable Lint/UnusedMethodArgument + # Agent mode has no span kind or instrumentation scope; + # `opentelemetry_kind` and `opentelemetry_scope` are ignored here. + def start_event(opentelemetry_kind: nil, opentelemetry_scope: nil) # rubocop:disable Lint/UnusedMethodArgument @handle.start_event(0) end @@ -39,8 +42,9 @@ 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 + # Agent mode has no span kind or instrumentation scope; + # `opentelemetry_kind` and `opentelemetry_scope` are ignored here. + def record_event(name, title, body, body_format, duration, opentelemetry_kind: nil, opentelemetry_scope: nil) # rubocop:disable Lint/UnusedMethodArgument, Metrics/ParameterLists, Layout/LineLength @handle.record_event(name, title, body, body_format, duration, 0) end diff --git a/lib/appsignal/transaction/opentelemetry_backend.rb b/lib/appsignal/transaction/opentelemetry_backend.rb index a3a502d15..8204cd0f9 100644 --- a/lib/appsignal/transaction/opentelemetry_backend.rb +++ b/lib/appsignal/transaction/opentelemetry_backend.rb @@ -54,10 +54,12 @@ class OpenTelemetryBackend < BaseBackend # 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, **) + def initialize(transaction_id, namespace, + opentelemetry_context: nil, opentelemetry_scope: nil, **) super() @transaction_id = transaction_id @namespace = namespace + @scope = opentelemetry_scope @completed = false @event_stack = [] @breadcrumb_count = 0 @@ -79,8 +81,9 @@ def initialize(transaction_id, namespace, opentelemetry_context: nil, **) # `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) + def start_event(opentelemetry_kind: nil, opentelemetry_scope: nil) + span = tracer_for(opentelemetry_scope) + .start_span(EVENT_SPAN_PLACEHOLDER_NAME, :kind => opentelemetry_kind) token = ::OpenTelemetry::Context.attach( ::OpenTelemetry::Trace.context_with_span(span) ) @@ -99,9 +102,12 @@ def finish_event(name, title, body, body_format) # `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 + def record_event( # rubocop:disable Metrics/ParameterLists + name, title, body, body_format, duration, + opentelemetry_kind: nil, opentelemetry_scope: nil + ) start_time = Time.now - (duration / 1_000_000_000.0) - span = tracer.start_span( + span = tracer_for(opentelemetry_scope).start_span( EVENT_SPAN_PLACEHOLDER_NAME, :start_timestamp => start_time, :kind => opentelemetry_kind @@ -342,8 +348,24 @@ def hostname Appsignal.config&.[](:hostname) || Socket.gethostname end - def tracer - ::OpenTelemetry.tracer_provider.tracer(TRACER_NAME, Appsignal::VERSION) + # Resolve the tracer for an instrumentation scope. `scope` is a + # `[name, version]` pair supplied by the integration that created the + # span, or nil. A nil scope, a nil/blank name, or a nil version each fall + # back to the default AppSignal scope, so every span always carries a + # scope (the collector drops scope-less spans). The tracer provider caches + # tracers by `(name, version)`, so this resolves rather than rebuilds. + def tracer_for(scope) + name, version = scope + if name.nil? || name.to_s.empty? + # A nil scope or one with a blank name is unusable, so fall back to + # the default scope entirely rather than pairing the default name with + # a stray version. + name = TRACER_NAME + version = Appsignal::VERSION + else + version ||= Appsignal::VERSION + end + ::OpenTelemetry.tracer_provider.tracer(name, version) end # The open event span, or the root span when no event is open. Not the OTel @@ -371,6 +393,7 @@ def placeholder_span_name(namespace) def start_transaction_span(namespace, kind, opentelemetry_context) name = placeholder_span_name(namespace) remote = remote_span_context(opentelemetry_context) + tracer = tracer_for(@scope) if remote && kind == :server tracer.start_span(name, :with_parent => opentelemetry_context, :kind => kind) diff --git a/sig/appsignal.rbi b/sig/appsignal.rbi index f332e09f4..48497442b 100644 --- a/sig/appsignal.rbi +++ b/sig/appsignal.rbi @@ -391,8 +391,15 @@ module Appsignal # ``` # # _@see_ `https://docs.appsignal.com/ruby/instrumentation/background-jobs.html` — Monitor guide - sig { params(action: T.any(String, Symbol, NilClass), namespace: T.nilable(T.any(String, Symbol)), blk: T.proc.returns(Object)).returns(T.nilable(Object)) } - def self.monitor(action:, namespace: nil, &blk); end + sig do + params( + action: T.any(String, Symbol, NilClass), + namespace: T.nilable(T.any(String, Symbol)), + opentelemetry_scope: T.untyped, + blk: T.proc.returns(Object) + ).returns(T.nilable(Object)) + end + def self.monitor(action:, namespace: nil, opentelemetry_scope: nil, &blk); end # Instrument a block of code and stop AppSignal. # @@ -409,8 +416,15 @@ module Appsignal # _@return_ — The value of the given block is returned. # # _@see_ `monitor` - sig { params(action: T.any(String, Symbol, NilClass), namespace: T.nilable(T.any(String, Symbol)), block: T.proc.returns(Object)).returns(T.nilable(Object)) } - def self.monitor_and_stop(action:, namespace: nil, &block); end + sig do + params( + action: T.any(String, Symbol, NilClass), + namespace: T.nilable(T.any(String, Symbol)), + opentelemetry_scope: T.untyped, + block: T.proc.returns(Object) + ).returns(T.nilable(Object)) + end + def self.monitor_and_stop(action:, namespace: nil, opentelemetry_scope: nil, &block); end # Send an error to AppSignal regardless of the context. # @@ -449,8 +463,8 @@ module Appsignal # ``` # # _@see_ `https://docs.appsignal.com/ruby/instrumentation/exception-handling.html` — Exception handling guide - sig { params(error: Exception, block: T.proc.params(transaction: Transaction).void).void } - def self.send_error(error, &block); end + sig { params(error: Exception, opentelemetry_scope: T.untyped, block: T.proc.params(transaction: Transaction).void).void } + def self.send_error(error, opentelemetry_scope: nil, &block); end # Set an error on the current transaction. # @@ -544,8 +558,8 @@ module Appsignal # ``` # # _@see_ `https://docs.appsignal.com/ruby/instrumentation/exception-handling.html` — Exception handling guide - sig { params(exception: Exception, block: T.proc.params(transaction: Transaction).void).void } - def self.report_error(exception, &block); end + sig { params(exception: Exception, opentelemetry_scope: T.untyped, block: T.proc.params(transaction: Transaction).void).void } + def self.report_error(exception, opentelemetry_scope: nil, &block); end # Set a custom action name for the current transaction. # @@ -923,10 +937,11 @@ module Appsignal body: T.nilable(String), body_format: Integer, opentelemetry_kind: T.untyped, + opentelemetry_scope: T.untyped, block: T.untyped ).returns(Object) end - def self.instrument(name, title = nil, body = nil, body_format = Appsignal::EventFormatter::DEFAULT, opentelemetry_kind: nil, &block); end + def self.instrument(name, title = nil, body = nil, body_format = Appsignal::EventFormatter::DEFAULT, opentelemetry_kind: nil, opentelemetry_scope: nil, &block); end # Instrumentation helper for SQL queries. # @@ -966,10 +981,11 @@ module Appsignal name: String, title: T.nilable(String), body: T.nilable(String), + opentelemetry_scope: T.untyped, block: T.untyped ).returns(Object) end - def self.instrument_sql(name, title = nil, body = nil, &block); end + def self.instrument_sql(name, title = nil, body = nil, opentelemetry_scope: nil, &block); end # Convenience method for ignoring instrumentation events in a block of # code. @@ -1695,8 +1711,8 @@ module Appsignal # transaction. # # _@param_ `namespace` — Namespace of the to be created transaction. - sig { params(namespace: String, opentelemetry_context: T.untyped).returns(Transaction) } - def self.create(namespace, opentelemetry_context: nil); end + sig { params(namespace: String, opentelemetry_context: T.untyped, opentelemetry_scope: T.untyped).returns(Transaction) } + def self.create(namespace, opentelemetry_context: nil, opentelemetry_scope: nil); end # Returns currently active transaction or a {NilTransaction} if none is # active. @@ -2080,8 +2096,15 @@ module Appsignal # ``` # # _@see_ `https://docs.appsignal.com/ruby/instrumentation/background-jobs.html` — Monitor guide - sig { params(action: T.any(String, Symbol, NilClass), namespace: T.nilable(T.any(String, Symbol)), blk: T.proc.returns(Object)).returns(T.nilable(Object)) } - def monitor(action:, namespace: nil, &blk); end + sig do + params( + action: T.any(String, Symbol, NilClass), + namespace: T.nilable(T.any(String, Symbol)), + opentelemetry_scope: T.untyped, + blk: T.proc.returns(Object) + ).returns(T.nilable(Object)) + end + def monitor(action:, namespace: nil, opentelemetry_scope: nil, &blk); end # Instrument a block of code and stop AppSignal. # @@ -2098,8 +2121,15 @@ module Appsignal # _@return_ — The value of the given block is returned. # # _@see_ `monitor` - sig { params(action: T.any(String, Symbol, NilClass), namespace: T.nilable(T.any(String, Symbol)), block: T.proc.returns(Object)).returns(T.nilable(Object)) } - def monitor_and_stop(action:, namespace: nil, &block); end + sig do + params( + action: T.any(String, Symbol, NilClass), + namespace: T.nilable(T.any(String, Symbol)), + opentelemetry_scope: T.untyped, + block: T.proc.returns(Object) + ).returns(T.nilable(Object)) + end + def monitor_and_stop(action:, namespace: nil, opentelemetry_scope: nil, &block); end # Send an error to AppSignal regardless of the context. # @@ -2138,8 +2168,8 @@ module Appsignal # ``` # # _@see_ `https://docs.appsignal.com/ruby/instrumentation/exception-handling.html` — Exception handling guide - sig { params(error: Exception, block: T.proc.params(transaction: Transaction).void).void } - def send_error(error, &block); end + sig { params(error: Exception, opentelemetry_scope: T.untyped, block: T.proc.params(transaction: Transaction).void).void } + def send_error(error, opentelemetry_scope: nil, &block); end # Set an error on the current transaction. # @@ -2233,8 +2263,8 @@ module Appsignal # ``` # # _@see_ `https://docs.appsignal.com/ruby/instrumentation/exception-handling.html` — Exception handling guide - sig { params(exception: Exception, block: T.proc.params(transaction: Transaction).void).void } - def report_error(exception, &block); end + sig { params(exception: Exception, opentelemetry_scope: T.untyped, block: T.proc.params(transaction: Transaction).void).void } + def report_error(exception, opentelemetry_scope: nil, &block); end # Set a custom action name for the current transaction. # @@ -2612,10 +2642,11 @@ module Appsignal body: T.nilable(String), body_format: Integer, opentelemetry_kind: T.untyped, + opentelemetry_scope: T.untyped, block: T.untyped ).returns(Object) end - def instrument(name, title = nil, body = nil, body_format = Appsignal::EventFormatter::DEFAULT, opentelemetry_kind: nil, &block); end + def instrument(name, title = nil, body = nil, body_format = Appsignal::EventFormatter::DEFAULT, opentelemetry_kind: nil, opentelemetry_scope: nil, &block); end # Instrumentation helper for SQL queries. # @@ -2655,10 +2686,11 @@ module Appsignal name: String, title: T.nilable(String), body: T.nilable(String), + opentelemetry_scope: T.untyped, block: T.untyped ).returns(Object) end - def instrument_sql(name, title = nil, body = nil, &block); end + def instrument_sql(name, title = nil, body = nil, opentelemetry_scope: nil, &block); end # Convenience method for ignoring instrumentation events in a block of # code. diff --git a/sig/appsignal.rbs b/sig/appsignal.rbs index a4e13872d..d6ead1ba6 100644 --- a/sig/appsignal.rbs +++ b/sig/appsignal.rbs @@ -349,7 +349,7 @@ module Appsignal # ``` # # _@see_ `https://docs.appsignal.com/ruby/instrumentation/background-jobs.html` — Monitor guide - def self.monitor: (action: (String | Symbol | NilClass), ?namespace: (String | Symbol)?) ?{ () -> Object } -> Object? + def self.monitor: (action: (String | Symbol | NilClass), ?namespace: (String | Symbol)?, ?opentelemetry_scope: untyped) ?{ () -> Object } -> Object? # Instrument a block of code and stop AppSignal. # @@ -366,7 +366,7 @@ module Appsignal # _@return_ — The value of the given block is returned. # # _@see_ `monitor` - def self.monitor_and_stop: (action: (String | Symbol | NilClass), ?namespace: (String | Symbol)?) ?{ () -> Object } -> Object? + def self.monitor_and_stop: (action: (String | Symbol | NilClass), ?namespace: (String | Symbol)?, ?opentelemetry_scope: untyped) ?{ () -> Object } -> Object? # Send an error to AppSignal regardless of the context. # @@ -405,7 +405,7 @@ module Appsignal # ``` # # _@see_ `https://docs.appsignal.com/ruby/instrumentation/exception-handling.html` — Exception handling guide - def self.send_error: (Exception error) ?{ (Transaction transaction) -> void } -> void + def self.send_error: (Exception error, ?opentelemetry_scope: untyped) ?{ (Transaction transaction) -> void } -> void # Set an error on the current transaction. # @@ -498,7 +498,7 @@ module Appsignal # ``` # # _@see_ `https://docs.appsignal.com/ruby/instrumentation/exception-handling.html` — Exception handling guide - def self.report_error: (Exception exception) ?{ (Transaction transaction) -> void } -> void + def self.report_error: (Exception exception, ?opentelemetry_scope: untyped) ?{ (Transaction transaction) -> void } -> void # Set a custom action name for the current transaction. # @@ -863,7 +863,8 @@ module Appsignal ?String? title, ?String? body, ?Integer body_format, - ?opentelemetry_kind: untyped + ?opentelemetry_kind: untyped, + ?opentelemetry_scope: untyped ) -> Object # Instrumentation helper for SQL queries. @@ -899,7 +900,12 @@ module Appsignal # _@see_ `https://docs.appsignal.com/ruby/instrumentation/instrumentation.html` — AppSignal custom instrumentation guide # # _@see_ `https://docs.appsignal.com/api/event-names.html` — AppSignal event naming guide - def self.instrument_sql: (String name, ?String? title, ?String? body) -> Object + def self.instrument_sql: ( + String name, + ?String? title, + ?String? body, + ?opentelemetry_scope: untyped + ) -> Object # Convenience method for ignoring instrumentation events in a block of # code. @@ -1557,7 +1563,7 @@ module Appsignal # transaction. # # _@param_ `namespace` — Namespace of the to be created transaction. - def self.create: (String namespace, ?opentelemetry_context: untyped) -> Transaction + def self.create: (String namespace, ?opentelemetry_context: untyped, ?opentelemetry_scope: untyped) -> Transaction # Returns currently active transaction or a {NilTransaction} if none is # active. @@ -1920,7 +1926,7 @@ module Appsignal # ``` # # _@see_ `https://docs.appsignal.com/ruby/instrumentation/background-jobs.html` — Monitor guide - def monitor: (action: (String | Symbol | NilClass), ?namespace: (String | Symbol)?) ?{ () -> Object } -> Object? + def monitor: (action: (String | Symbol | NilClass), ?namespace: (String | Symbol)?, ?opentelemetry_scope: untyped) ?{ () -> Object } -> Object? # Instrument a block of code and stop AppSignal. # @@ -1937,7 +1943,7 @@ module Appsignal # _@return_ — The value of the given block is returned. # # _@see_ `monitor` - def monitor_and_stop: (action: (String | Symbol | NilClass), ?namespace: (String | Symbol)?) ?{ () -> Object } -> Object? + def monitor_and_stop: (action: (String | Symbol | NilClass), ?namespace: (String | Symbol)?, ?opentelemetry_scope: untyped) ?{ () -> Object } -> Object? # Send an error to AppSignal regardless of the context. # @@ -1976,7 +1982,7 @@ module Appsignal # ``` # # _@see_ `https://docs.appsignal.com/ruby/instrumentation/exception-handling.html` — Exception handling guide - def send_error: (Exception error) ?{ (Transaction transaction) -> void } -> void + def send_error: (Exception error, ?opentelemetry_scope: untyped) ?{ (Transaction transaction) -> void } -> void # Set an error on the current transaction. # @@ -2069,7 +2075,7 @@ module Appsignal # ``` # # _@see_ `https://docs.appsignal.com/ruby/instrumentation/exception-handling.html` — Exception handling guide - def report_error: (Exception exception) ?{ (Transaction transaction) -> void } -> void + def report_error: (Exception exception, ?opentelemetry_scope: untyped) ?{ (Transaction transaction) -> void } -> void # Set a custom action name for the current transaction. # @@ -2434,7 +2440,8 @@ module Appsignal ?String? title, ?String? body, ?Integer body_format, - ?opentelemetry_kind: untyped + ?opentelemetry_kind: untyped, + ?opentelemetry_scope: untyped ) -> Object # Instrumentation helper for SQL queries. @@ -2470,7 +2477,12 @@ module Appsignal # _@see_ `https://docs.appsignal.com/ruby/instrumentation/instrumentation.html` — AppSignal custom instrumentation guide # # _@see_ `https://docs.appsignal.com/api/event-names.html` — AppSignal event naming guide - def instrument_sql: (String name, ?String? title, ?String? body) -> Object + def instrument_sql: ( + String name, + ?String? title, + ?String? body, + ?opentelemetry_scope: untyped + ) -> Object # Convenience method for ignoring instrumentation events in a block of # code. diff --git a/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb b/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb index f500e7196..03df7e6a2 100644 --- a/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb +++ b/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb @@ -39,8 +39,9 @@ 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 } + def create_backend(namespace = "http_request", opentelemetry_scope: nil) + described_class.new("abc-123", namespace, :opentelemetry_scope => opentelemetry_scope) + .tap { |b| @backends_created << b } end def foreign_tracer @@ -67,6 +68,72 @@ def event_names(finished) Array(finished&.events).map(&:name) end + describe "instrumentation scope" do + def root_span + span_exporter.finished_spans.find { |s| [:server, :consumer].include?(s.kind) } + end + + def event_spans + span_exporter.finished_spans.reject { |s| [:server, :consumer].include?(s.kind) } + end + + def scope_of(span) + [span.instrumentation_scope.name, span.instrumentation_scope.version] + end + + it "puts the root span under the given scope" do + backend = create_backend( + "http_request", + :opentelemetry_scope => ["appsignal-ruby/rack", "1.2.3"] + ) + backend.set_action("MyAction") + backend.complete + + expect(scope_of(root_span)).to eq(["appsignal-ruby/rack", "1.2.3"]) + end + + it "puts an event span under the scope passed at start_event" do + backend = create_backend + backend.start_event(:opentelemetry_scope => ["appsignal-ruby/redis", "4.5.6"]) + backend.finish_event("query.redis", "GET foo", nil, Appsignal::EventFormatter::DEFAULT) + backend.set_action("MyAction") + backend.complete + + expect(scope_of(event_spans.first)).to eq(["appsignal-ruby/redis", "4.5.6"]) + end + + it "puts a recorded event span under the scope passed at record_event" do + backend = create_backend + backend.record_event( + "query.data_mapper", "DM Query", nil, Appsignal::EventFormatter::DEFAULT, 1000, + :opentelemetry_scope => ["appsignal-ruby/data_mapper", "7.8.9"] + ) + backend.set_action("MyAction") + backend.complete + + expect(scope_of(event_spans.first)).to eq(["appsignal-ruby/data_mapper", "7.8.9"]) + end + + it "falls back to the default scope when none is given" do + backend = create_backend + backend.start_event + backend.finish_event("query.redis", "GET foo", nil, Appsignal::EventFormatter::DEFAULT) + backend.set_action("MyAction") + backend.complete + + expect(scope_of(root_span)).to eq(["appsignal-ruby", Appsignal::VERSION]) + expect(scope_of(event_spans.first)).to eq(["appsignal-ruby", Appsignal::VERSION]) + end + + it "falls back to the default scope when the name is blank" do + backend = create_backend("http_request", :opentelemetry_scope => ["", "1.2.3"]) + backend.set_action("MyAction") + backend.complete + + expect(scope_of(root_span)).to eq(["appsignal-ruby", Appsignal::VERSION]) + end + end + describe "#initialize" do it "constructs without raising" do expect { create_backend }.not_to raise_error diff --git a/spec/lib/appsignal/transaction_spec.rb b/spec/lib/appsignal/transaction_spec.rb index 8135e5608..b2c8ca1e3 100644 --- a/spec/lib/appsignal/transaction_spec.rb +++ b/spec/lib/appsignal/transaction_spec.rb @@ -4473,18 +4473,26 @@ def exception_event it "starts the event in the extension" do expect(transaction.backend).to receive(:start_event) - .with(:opentelemetry_kind => nil).and_call_original + .with(:opentelemetry_kind => nil, :opentelemetry_scope => nil).and_call_original transaction.start_event end it "passes the opentelemetry_kind to the backend" do expect(transaction.backend).to receive(:start_event) - .with(:opentelemetry_kind => :client).and_call_original + .with(:opentelemetry_kind => :client, :opentelemetry_scope => nil).and_call_original transaction.start_event(:opentelemetry_kind => :client) end + it "passes the opentelemetry_scope to the backend" do + expect(transaction.backend).to receive(:start_event) + .with(:opentelemetry_kind => nil, :opentelemetry_scope => ["appsignal-ruby/redis", "1.0"]) + .and_call_original + + transaction.start_event(:opentelemetry_scope => ["appsignal-ruby/redis", "1.0"]) + end + context "when transaction is paused" do it "does not start the event" do transaction.pause! @@ -4550,7 +4558,8 @@ def exception_event "body", 1, 1000, - :opentelemetry_kind => nil + :opentelemetry_kind => nil, + :opentelemetry_scope => nil ).and_call_original transaction.record_event( @@ -4562,6 +4571,27 @@ def exception_event ) end + it "passes the opentelemetry_scope to the backend" do + expect(transaction.backend).to receive(:record_event).with( + "name", + "title", + "body", + 1, + 1000, + :opentelemetry_kind => nil, + :opentelemetry_scope => ["appsignal-ruby/data_mapper", "1.0"] + ).and_call_original + + transaction.record_event( + "name", + "title", + "body", + 1000, + 1, + :opentelemetry_scope => ["appsignal-ruby/data_mapper", "1.0"] + ) + end + it "should finish the event in the extension with nil arguments" do expect(transaction.backend).to receive(:record_event).with( "name", @@ -4569,7 +4599,8 @@ def exception_event "", 0, 1000, - :opentelemetry_kind => nil + :opentelemetry_kind => nil, + :opentelemetry_scope => nil ).and_call_original transaction.record_event( diff --git a/spec/lib/appsignal_spec.rb b/spec/lib/appsignal_spec.rb index 95f0b679c..da24085b5 100644 --- a/spec/lib/appsignal_spec.rb +++ b/spec/lib/appsignal_spec.rb @@ -1181,6 +1181,39 @@ def perform expect(exception_events).to be_empty expect(event_spans).to be_empty expect(last_transaction).to be_completed + # No scope given, so the transaction uses the default scope. + expect(scope_of(root_span)).to eq(["appsignal-ruby", Appsignal::VERSION]) + end + end + + describe "passing an OpenTelemetry scope" do + let(:scope) { ["appsignal-ruby/custom", "1.2.3"] } + + it "monitor records the transaction under that scope", :collector_mode do + start_collector_agent + Appsignal.monitor(:action => "MyAction", :opentelemetry_scope => scope) + + expect(scope_of(root_span)).to eq(scope) + end + + it "send_error records the error transaction under that scope", :collector_mode do + start_collector_agent + Appsignal.send_error( + ExampleException.new("error"), + :opentelemetry_scope => scope + ) + + expect(scope_of(root_span)).to eq(scope) + end + + it "report_error records the error transaction under that scope", :collector_mode do + start_collector_agent + Appsignal.report_error( + ExampleException.new("error"), + :opentelemetry_scope => scope + ) { |t| t.set_action("MyAction") } + + expect(scope_of(root_span)).to eq(scope) end end diff --git a/spec/support/shared_contexts/collector_mode.rb b/spec/support/shared_contexts/collector_mode.rb index a660111a6..fea592b30 100644 --- a/spec/support/shared_contexts/collector_mode.rb +++ b/spec/support/shared_contexts/collector_mode.rb @@ -103,6 +103,12 @@ def event_spans span_exporter.finished_spans.reject { |s| [:server, :consumer].include?(s.kind) } end + # The `[name, version]` instrumentation scope a finished span was recorded + # under. Integrations tag their spans with their own scope in collector mode. + def scope_of(span) + [span.instrumentation_scope.name, span.instrumentation_scope.version] + end + # 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). From caced5c347a3b17314fcdfc1747e560628bca83c Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Mon, 20 Jul 2026 17:56:51 +0200 Subject: [PATCH 19/69] Derive AS::N span scope from the event group ActiveSupport::Notifications bridges many Rails components through one code path, so a single scope would lump them all together. Derive the instrumentation scope from the event name group, which is the part after the last dot. That puts each component under its own scope, such as appsignal-ruby/active_record and appsignal-ruby/action_view. An event name without a group falls back to the default scope. --- .../active_support_notifications.rb | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/lib/appsignal/integrations/active_support_notifications.rb b/lib/appsignal/integrations/active_support_notifications.rb index 78cb621f3..b6892bceb 100644 --- a/lib/appsignal/integrations/active_support_notifications.rb +++ b/lib/appsignal/integrations/active_support_notifications.rb @@ -35,10 +35,28 @@ def start_event(name) return unless record_event?(name) Appsignal::Transaction.current.start_event( - :opentelemetry_kind => CLIENT_EVENT_NAMES.include?(name.to_s) ? :client : nil + :opentelemetry_kind => CLIENT_EVENT_NAMES.include?(name.to_s) ? :client : nil, + :opentelemetry_scope => scope_for(name) ) end + # ActiveSupport::Notifications bridges many Rails components through this + # one path (`sql.active_record`, `render_template.action_view`, ...), so + # derive the instrumentation scope from the event name's group: the part + # after the last dot. That gives each Rails component its own scope + # (`appsignal-ruby/active_record`, `appsignal-ruby/action_view`, ...) + # rather than lumping them under one. A name without a group falls back + # to the default scope in the backend. + def scope_for(name) + # Only names with a group (a dot) map to a component scope. A name + # without one has no component to attribute it to, so it falls back to + # the default scope in the backend (returning nil here). + parts = name.to_s.split(".") + return if parts.length < 2 || parts.last.empty? + + ["appsignal-ruby/#{parts.last}", Appsignal::VERSION] + end + def finish_event(name, payload = {}) return unless record_event?(name) From ad024aaf18c6315d852d2aad9491c3670735e50b Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Mon, 20 Jul 2026 17:56:51 +0200 Subject: [PATCH 20/69] Test AS::N derived instrumentation scope The scope is derived from the event name rather than declared, so the cases that matter are the ones where the name's shape decides the answer. Cover a name with a known group, a dotted custom name, and a name with no group at all. --- .../instrument_shared_examples.rb | 6 ++++++ 1 file changed, 6 insertions(+) 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 c69c53c0f..bbec2c6c3 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 @@ -38,6 +38,8 @@ def perform 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") + # The scope is derived from the event group (the part after the last dot). + expect(scope_of(span)).to eq(["appsignal-ruby/active_record", Appsignal::VERSION]) expect(span.attributes).not_to have_key("appsignal.body") end end @@ -85,6 +87,7 @@ def perform 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.sequel") + expect(scope_of(span)).to eq(["appsignal-ruby/sequel", Appsignal::VERSION]) expect(span.attributes).not_to have_key("appsignal.body") end end @@ -127,6 +130,7 @@ def perform 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(scope_of(span)).to eq(["appsignal-ruby/formatter", Appsignal::VERSION]) expect(span.attributes).not_to have_key("db.query.text") expect(span.attributes).not_to have_key("db.system.name") end @@ -167,6 +171,8 @@ def perform 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") + # No group (no dot) in the name, so it falls back to the default scope. + expect(scope_of(span)).to eq(["appsignal-ruby", Appsignal::VERSION]) end end From 9fad25b5bbe55b9aadee8b77e7bdfdac44ad9e79 Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Tue, 21 Jul 2026 15:07:12 +0200 Subject: [PATCH 21/69] Tag Rack and web framework spans with scope The Rack middleware takes an `opentelemetry_scope` option and passes it when it creates the request transaction and its wrapping event. The generic middleware, the response body wrapper and the Rack event handler share the `appsignal-ruby/rack` scope. Each web framework middleware sets its own through the same option, so a request span is grouped under the framework that served it. --- lib/appsignal/loaders/padrino.rb | 3 ++- lib/appsignal/rack/abstract_middleware.rb | 12 ++++++++-- lib/appsignal/rack/body_wrapper.rb | 23 +++++++++++++++---- lib/appsignal/rack/event_handler.rb | 7 ++++-- lib/appsignal/rack/grape_middleware.rb | 1 + lib/appsignal/rack/hanami_middleware.rb | 1 + .../rack/instrumentation_middleware.rb | 1 + lib/appsignal/rack/rails_instrumentation.rb | 1 + lib/appsignal/rack/sinatra_instrumentation.rb | 1 + 9 files changed, 40 insertions(+), 10 deletions(-) diff --git a/lib/appsignal/loaders/padrino.rb b/lib/appsignal/loaders/padrino.rb index 2dde17f84..dadaadd62 100644 --- a/lib/appsignal/loaders/padrino.rb +++ b/lib/appsignal/loaders/padrino.rb @@ -20,7 +20,8 @@ def on_start Padrino.before_load do Padrino.use Appsignal::Rack::EventMiddleware Padrino.use Appsignal::Rack::SinatraBaseInstrumentation, - :instrument_event_name => "process_action.padrino" + :instrument_event_name => "process_action.padrino", + :opentelemetry_scope => ["appsignal-ruby/padrino", Appsignal::VERSION] end end diff --git a/lib/appsignal/rack/abstract_middleware.rb b/lib/appsignal/rack/abstract_middleware.rb index 6758aedfb..05e652b60 100644 --- a/lib/appsignal/rack/abstract_middleware.rb +++ b/lib/appsignal/rack/abstract_middleware.rb @@ -20,6 +20,10 @@ def initialize(app, options = {}) @options = options @request_class = options.fetch(:request_class, ::Rack::Request) @instrument_event_name = options.fetch(:instrument_event_name, nil) + # The OpenTelemetry instrumentation scope for the request's spans in + # collector mode. Each framework middleware sets its own; nil falls back + # to the default scope in the backend. + @opentelemetry_scope = options.fetch(:opentelemetry_scope, nil) @report_errors = options.fetch(:report_errors, DEFAULT_ERROR_REPORTING) end @@ -35,7 +39,8 @@ def call(env) else Appsignal::Transaction.create( Appsignal::Transaction::HTTP_REQUEST, - :opentelemetry_context => Appsignal::OpenTelemetry.extract_rack_context(env) + :opentelemetry_context => Appsignal::OpenTelemetry.extract_rack_context(env), + :opentelemetry_scope => @opentelemetry_scope ) end @@ -82,7 +87,10 @@ def call(env) # @see #instrument_app_call_with_exception_handling def instrument_app_call(env, transaction) if @instrument_event_name - Appsignal.instrument(@instrument_event_name) do + Appsignal.instrument( + @instrument_event_name, + :opentelemetry_scope => @opentelemetry_scope + ) do call_app(env, transaction) end else diff --git a/lib/appsignal/rack/body_wrapper.rb b/lib/appsignal/rack/body_wrapper.rb index 901330f76..e3504e83b 100644 --- a/lib/appsignal/rack/body_wrapper.rb +++ b/lib/appsignal/rack/body_wrapper.rb @@ -48,7 +48,10 @@ def close # of the body has already closed itself (as prescribed) we do not # attempt to close it twice if !@body_already_closed && @body.respond_to?(:close) - Appsignal.instrument("close_response_body.rack") { @body.close } + Appsignal.instrument( + "close_response_body.rack", + :opentelemetry_scope => ["appsignal-ruby/rack", Appsignal::VERSION] + ) { @body.close } end @body_already_closed = true rescue *IGNORED_ERRORS # Do not report @@ -104,7 +107,11 @@ def each(&blk) # in a blockless way it is still a good idea to have it in place. return enum_for(:each) unless block_given? - Appsignal.instrument("process_response_body.rack", "Process Rack response body (#each)") do + Appsignal.instrument( + "process_response_body.rack", + "Process Rack response body (#each)", + :opentelemetry_scope => ["appsignal-ruby/rack", Appsignal::VERSION] + ) do @body.each(&blk) end rescue *IGNORED_ERRORS # Do not report @@ -125,7 +132,11 @@ class CallableBodyWrapper < BodyWrapper def call(stream) # `stream` will be closed by the app we are calling, no need for us # to close it ourselves - Appsignal.instrument("process_response_body.rack", "Process Rack response body (#call)") do + Appsignal.instrument( + "process_response_body.rack", + "Process Rack response body (#call)", + :opentelemetry_scope => ["appsignal-ruby/rack", Appsignal::VERSION] + ) do @body.call(stream) end rescue *IGNORED_ERRORS # Do not report @@ -150,7 +161,8 @@ def to_ary @body_already_closed = true Appsignal.instrument( "process_response_body.rack", - "Process Rack response body (#to_ary)" + "Process Rack response body (#to_ary)", + :opentelemetry_scope => ["appsignal-ruby/rack", Appsignal::VERSION] ) do @body.to_ary end @@ -168,7 +180,8 @@ class PathableBodyWrapper < EnumerableBodyWrapper def to_path Appsignal.instrument( "process_response_body.rack", - "Process Rack response body (#to_path)" + "Process Rack response body (#to_path)", + :opentelemetry_scope => ["appsignal-ruby/rack", Appsignal::VERSION] ) do @body.to_path end diff --git a/lib/appsignal/rack/event_handler.rb b/lib/appsignal/rack/event_handler.rb index 6ae605c02..13ca8e098 100644 --- a/lib/appsignal/rack/event_handler.rb +++ b/lib/appsignal/rack/event_handler.rb @@ -65,9 +65,12 @@ def on_start(request, _response) transaction = Appsignal::Transaction.create( Appsignal::Transaction::HTTP_REQUEST, - :opentelemetry_context => Appsignal::OpenTelemetry.extract_rack_context(request.env) + :opentelemetry_context => Appsignal::OpenTelemetry.extract_rack_context(request.env), + :opentelemetry_scope => ["appsignal-ruby/rack", Appsignal::VERSION] + ) + transaction.start_event( + :opentelemetry_scope => ["appsignal-ruby/rack", Appsignal::VERSION] ) - transaction.start_event request.env[APPSIGNAL_TRANSACTION] = transaction request.env[RACK_AFTER_REPLY] ||= [] diff --git a/lib/appsignal/rack/grape_middleware.rb b/lib/appsignal/rack/grape_middleware.rb index 1446871ae..07ac709fd 100644 --- a/lib/appsignal/rack/grape_middleware.rb +++ b/lib/appsignal/rack/grape_middleware.rb @@ -7,6 +7,7 @@ class GrapeMiddleware < Appsignal::Rack::AbstractMiddleware # @api private def initialize(app, options = {}) options[:instrument_event_name] = "process_request.grape" + options[:opentelemetry_scope] = ["appsignal-ruby/grape", Appsignal::VERSION] options[:report_errors] = lambda { |env| !env["grape.skip_appsignal_error"] } super end diff --git a/lib/appsignal/rack/hanami_middleware.rb b/lib/appsignal/rack/hanami_middleware.rb index bb8805c5f..ff427b87e 100644 --- a/lib/appsignal/rack/hanami_middleware.rb +++ b/lib/appsignal/rack/hanami_middleware.rb @@ -7,6 +7,7 @@ class HanamiMiddleware < AbstractMiddleware def initialize(app, options = {}) options[:params_method] = nil options[:instrument_event_name] ||= "process_action.hanami" + options[:opentelemetry_scope] ||= ["appsignal-ruby/hanami", Appsignal::VERSION] super end diff --git a/lib/appsignal/rack/instrumentation_middleware.rb b/lib/appsignal/rack/instrumentation_middleware.rb index 3b1f6b445..e633c5302 100644 --- a/lib/appsignal/rack/instrumentation_middleware.rb +++ b/lib/appsignal/rack/instrumentation_middleware.rb @@ -55,6 +55,7 @@ module Rack class InstrumentationMiddleware < AbstractMiddleware def initialize(app, options = {}) options[:instrument_event_name] ||= "process_request_middleware.rack" + options[:opentelemetry_scope] ||= ["appsignal-ruby/rack", Appsignal::VERSION] super end end diff --git a/lib/appsignal/rack/rails_instrumentation.rb b/lib/appsignal/rack/rails_instrumentation.rb index 764822da7..d13f7e50f 100644 --- a/lib/appsignal/rack/rails_instrumentation.rb +++ b/lib/appsignal/rack/rails_instrumentation.rb @@ -8,6 +8,7 @@ def initialize(app, options = {}) options[:request_class] ||= ActionDispatch::Request options[:params_method] ||= :filtered_parameters options[:instrument_event_name] = nil + options[:opentelemetry_scope] = ["appsignal-ruby/rails", Appsignal::VERSION] options[:report_errors] = true super end diff --git a/lib/appsignal/rack/sinatra_instrumentation.rb b/lib/appsignal/rack/sinatra_instrumentation.rb index ce0e66606..435a86d4a 100644 --- a/lib/appsignal/rack/sinatra_instrumentation.rb +++ b/lib/appsignal/rack/sinatra_instrumentation.rb @@ -33,6 +33,7 @@ def initialize(app, options = {}) options[:request_class] ||= Sinatra::Request options[:params_method] ||= :params options[:instrument_event_name] ||= "process_action.sinatra" + options[:opentelemetry_scope] ||= ["appsignal-ruby/sinatra", Appsignal::VERSION] super @raise_errors_on = raise_errors?(app) end From b28e289823752706b79e22ba1d546ae88070f32f Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Tue, 21 Jul 2026 15:07:12 +0200 Subject: [PATCH 22/69] Test Rack and web framework span scope The Rails case is the one worth singling out. A full Rails stack usually has the Rack event handler create the transaction first, under the Rack scope, so the example covers the middleware creating it itself. The Padrino loader spec changes too, because the loader now registers the Sinatra middleware with an `opentelemetry_scope` option. --- spec/lib/appsignal/loaders/padrino_spec.rb | 5 ++++- spec/lib/appsignal/rack/event_handler_spec.rb | 1 + spec/lib/appsignal/rack/grape_middleware_spec.rb | 1 + spec/lib/appsignal/rack/hanami_middleware_spec.rb | 1 + .../rack/instrumentation_middleware_spec.rb | 2 ++ .../appsignal/rack/rails_instrumentation_spec.rb | 14 ++++++++++++++ .../appsignal/rack/sinatra_instrumentation_spec.rb | 2 ++ 7 files changed, 25 insertions(+), 1 deletion(-) diff --git a/spec/lib/appsignal/loaders/padrino_spec.rb b/spec/lib/appsignal/loaders/padrino_spec.rb index eb6242d8b..d9298c700 100644 --- a/spec/lib/appsignal/loaders/padrino_spec.rb +++ b/spec/lib/appsignal/loaders/padrino_spec.rb @@ -48,7 +48,10 @@ def uninstall_padrino_integration [ Appsignal::Rack::SinatraBaseInstrumentation, [ - { :instrument_event_name => "process_action.padrino" } + { + :instrument_event_name => "process_action.padrino", + :opentelemetry_scope => ["appsignal-ruby/padrino", Appsignal::VERSION] + } ], nil ] diff --git a/spec/lib/appsignal/rack/event_handler_spec.rb b/spec/lib/appsignal/rack/event_handler_spec.rb index bdb86f6b5..b475f1419 100644 --- a/spec/lib/appsignal/rack/event_handler_spec.rb +++ b/spec/lib/appsignal/rack/event_handler_spec.rb @@ -105,6 +105,7 @@ def perform expect(root_span.attributes["appsignal.namespace"]) .to eq("web") expect(root_span.kind).to eq(:server) + expect(scope_of(root_span)).to eq(["appsignal-ruby/rack", Appsignal::VERSION]) end end diff --git a/spec/lib/appsignal/rack/grape_middleware_spec.rb b/spec/lib/appsignal/rack/grape_middleware_spec.rb index 007ef2b96..ca4ccca42 100644 --- a/spec/lib/appsignal/rack/grape_middleware_spec.rb +++ b/spec/lib/appsignal/rack/grape_middleware_spec.rb @@ -129,6 +129,7 @@ def perform expect(root_span.name).to eq("GET::GrapeExample::Api#/hello") expect(root_span.kind).to eq(:server) + expect(scope_of(root_span)).to eq(["appsignal-ruby/grape", Appsignal::VERSION]) expect(root_span.attributes["appsignal.action_name"]) .to eq("GET::GrapeExample::Api#/hello") expect(root_span.attributes["appsignal.tag.path"]).to eq("/hello") diff --git a/spec/lib/appsignal/rack/hanami_middleware_spec.rb b/spec/lib/appsignal/rack/hanami_middleware_spec.rb index ea7e90598..988a88dde 100644 --- a/spec/lib/appsignal/rack/hanami_middleware_spec.rb +++ b/spec/lib/appsignal/rack/hanami_middleware_spec.rb @@ -69,6 +69,7 @@ def perform perform expect(root_span.kind).to eq(:server) + expect(scope_of(root_span)).to eq(["appsignal-ruby/hanami", Appsignal::VERSION]) params = JSON.parse(root_span.attributes["appsignal.request.payload"]) expect(params).to include("param1" => "value1", "param2" => "value2") end diff --git a/spec/lib/appsignal/rack/instrumentation_middleware_spec.rb b/spec/lib/appsignal/rack/instrumentation_middleware_spec.rb index c53166b27..a3b182831 100644 --- a/spec/lib/appsignal/rack/instrumentation_middleware_spec.rb +++ b/spec/lib/appsignal/rack/instrumentation_middleware_spec.rb @@ -29,6 +29,8 @@ def perform span = event_spans.find { |s| s.name == "process_request_middleware.rack" } expect(span).not_to be_nil expect(span.parent_span_id).to eq(root_span.span_id) + expect(scope_of(root_span)).to eq(["appsignal-ruby/rack", Appsignal::VERSION]) + expect(scope_of(span)).to eq(["appsignal-ruby/rack", Appsignal::VERSION]) end end end diff --git a/spec/lib/appsignal/rack/rails_instrumentation_spec.rb b/spec/lib/appsignal/rack/rails_instrumentation_spec.rb index 55850bee7..e5853dbf4 100644 --- a/spec/lib/appsignal/rack/rails_instrumentation_spec.rb +++ b/spec/lib/appsignal/rack/rails_instrumentation_spec.rb @@ -172,6 +172,20 @@ def perform end end + describe "the instrumentation scope" do + # When no parent transaction is present, this middleware creates the + # transaction itself and tags it with the Rails scope. In a full Rails + # stack the Rack event handler usually creates it first, under the Rack + # scope, and this middleware wraps that instead. + it "records under the Rails scope when it creates the transaction", + :collector_mode do + start_collector_agent + make_request + + expect(scope_of(root_span)).to eq(["appsignal-ruby/rails", Appsignal::VERSION]) + end + end + describe "sets request metadata on the transaction" do def perform setup_transaction diff --git a/spec/lib/appsignal/rack/sinatra_instrumentation_spec.rb b/spec/lib/appsignal/rack/sinatra_instrumentation_spec.rb index 343ea40ce..35c5088cd 100644 --- a/spec/lib/appsignal/rack/sinatra_instrumentation_spec.rb +++ b/spec/lib/appsignal/rack/sinatra_instrumentation_spec.rb @@ -145,6 +145,8 @@ def perform span = event_spans.find { |s| s.name == "process_action.sinatra" } expect(span).not_to be_nil expect(span.parent_span_id).to eq(root_span.span_id) + expect(scope_of(root_span)).to eq(["appsignal-ruby/sinatra", Appsignal::VERSION]) + expect(scope_of(span)).to eq(["appsignal-ruby/sinatra", Appsignal::VERSION]) end end end From d9c57a1e2c39ec123308c3157dab25f9d25aa6bd Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Tue, 21 Jul 2026 15:07:12 +0200 Subject: [PATCH 23/69] Tag remaining integration spans with scope Each remaining integration passes its own instrumentation scope, a `[name, version]` pair at the gem version, when it creates a transaction or records an event. `report_error` passes a scope too, but only applies it when it creates the transaction. An error reported onto an existing transaction keeps that transaction's scope. --- lib/appsignal/hooks/action_cable.rb | 20 ++++++++++++++---- lib/appsignal/hooks/active_job.rb | 6 ++++-- lib/appsignal/hooks/at_exit.rb | 5 ++++- lib/appsignal/hooks/sequel.rb | 6 ++++-- lib/appsignal/integrations/action_cable.rb | 5 ++++- lib/appsignal/integrations/data_mapper.rb | 3 ++- .../integrations/delayed_job_plugin.rb | 13 +++++++++--- lib/appsignal/integrations/dry_monitor.rb | 3 ++- lib/appsignal/integrations/faraday.rb | 3 ++- lib/appsignal/integrations/http.rb | 1 + .../integrations/mongo_ruby_driver.rb | 5 ++++- lib/appsignal/integrations/net_http.rb | 3 ++- lib/appsignal/integrations/puma.rb | 5 ++++- lib/appsignal/integrations/que.rb | 15 ++++++++++--- lib/appsignal/integrations/railtie.rb | 5 ++++- lib/appsignal/integrations/rake.rb | 10 +++++++-- lib/appsignal/integrations/redis.rb | 3 ++- lib/appsignal/integrations/redis_client.rb | 3 ++- lib/appsignal/integrations/resque.rb | 11 +++++++--- lib/appsignal/integrations/shoryuken.rb | 12 ++++++++--- lib/appsignal/integrations/sidekiq.rb | 21 +++++++++++++++---- lib/appsignal/integrations/webmachine.rb | 8 +++++-- 22 files changed, 127 insertions(+), 39 deletions(-) diff --git a/lib/appsignal/hooks/action_cable.rb b/lib/appsignal/hooks/action_cable.rb index eba19f49f..b6d1ef5d2 100644 --- a/lib/appsignal/hooks/action_cable.rb +++ b/lib/appsignal/hooks/action_cable.rb @@ -37,10 +37,16 @@ def install_subscribe_callback env[Appsignal::Hooks::ActionCableHook::REQUEST_ID] ||= request_id transaction = - Appsignal::Transaction.create(Appsignal::Transaction::ACTION_CABLE) + Appsignal::Transaction.create( + Appsignal::Transaction::ACTION_CABLE, + :opentelemetry_scope => ["appsignal-ruby/action_cable", Appsignal::VERSION] + ) begin - Appsignal.instrument "subscribed.action_cable" do + Appsignal.instrument( + "subscribed.action_cable", + :opentelemetry_scope => ["appsignal-ruby/action_cable", Appsignal::VERSION] + ) do inner.call end rescue Exception => exception @@ -73,10 +79,16 @@ def install_unsubscribe_callback env[Appsignal::Hooks::ActionCableHook::REQUEST_ID] ||= request_id transaction = - Appsignal::Transaction.create(Appsignal::Transaction::ACTION_CABLE) + Appsignal::Transaction.create( + Appsignal::Transaction::ACTION_CABLE, + :opentelemetry_scope => ["appsignal-ruby/action_cable", Appsignal::VERSION] + ) begin - Appsignal.instrument "unsubscribed.action_cable" do + Appsignal.instrument( + "unsubscribed.action_cable", + :opentelemetry_scope => ["appsignal-ruby/action_cable", Appsignal::VERSION] + ) do inner.call end rescue Exception => exception diff --git a/lib/appsignal/hooks/active_job.rb b/lib/appsignal/hooks/active_job.rb index 11e315a69..35a90bea1 100644 --- a/lib/appsignal/hooks/active_job.rb +++ b/lib/appsignal/hooks/active_job.rb @@ -77,7 +77,8 @@ def execute(job) # Prefer job_id from provider, instead of ActiveJob's internal ID. Appsignal::Transaction.create( Appsignal::Transaction::BACKGROUND_JOB, - :opentelemetry_context => Appsignal::OpenTelemetry.extract_job_context(job) + :opentelemetry_context => Appsignal::OpenTelemetry.extract_job_context(job), + :opentelemetry_scope => ["appsignal-ruby/active_job", Appsignal::VERSION] ) end @@ -168,7 +169,8 @@ def enqueue(*, **) Appsignal.instrument( "enqueue.active_job", "enqueue #{self.class.name} job", - :opentelemetry_kind => :producer + :opentelemetry_kind => :producer, + :opentelemetry_scope => ["appsignal-ruby/active_job", Appsignal::VERSION] ) do Appsignal::OpenTelemetry.inject_context(__otel_headers) # Active Job enqueues through an adapter (Sidekiq, Resque, ...) that diff --git a/lib/appsignal/hooks/at_exit.rb b/lib/appsignal/hooks/at_exit.rb index df7610c08..810abfcbb 100644 --- a/lib/appsignal/hooks/at_exit.rb +++ b/lib/appsignal/hooks/at_exit.rb @@ -38,7 +38,10 @@ def self.call report_error = true - Appsignal.report_error(error) do |transaction| + Appsignal.report_error( + error, + :opentelemetry_scope => ["appsignal-ruby/at_exit", Appsignal::VERSION] + ) do |transaction| transaction.set_namespace("unhandled") end ensure diff --git a/lib/appsignal/hooks/sequel.rb b/lib/appsignal/hooks/sequel.rb index c9356a85c..b7ce7ea8f 100644 --- a/lib/appsignal/hooks/sequel.rb +++ b/lib/appsignal/hooks/sequel.rb @@ -11,7 +11,8 @@ def log_yield(sql, args = nil) nil, sql, Appsignal::EventFormatter::SQL_BODY_FORMAT, - :opentelemetry_kind => :client + :opentelemetry_kind => :client, + :opentelemetry_scope => ["appsignal-ruby/sequel", Appsignal::VERSION] ) do super end @@ -27,7 +28,8 @@ def log_connection_yield(sql, conn, args = nil) nil, sql, Appsignal::EventFormatter::SQL_BODY_FORMAT, - :opentelemetry_kind => :client + :opentelemetry_kind => :client, + :opentelemetry_scope => ["appsignal-ruby/sequel", Appsignal::VERSION] ) do super end diff --git a/lib/appsignal/integrations/action_cable.rb b/lib/appsignal/integrations/action_cable.rb index 98c52c40c..130898747 100644 --- a/lib/appsignal/integrations/action_cable.rb +++ b/lib/appsignal/integrations/action_cable.rb @@ -11,7 +11,10 @@ def perform_action(*args, &block) request_id = request.request_id || SecureRandom.uuid env[Appsignal::Hooks::ActionCableHook::REQUEST_ID] ||= request_id - transaction = Appsignal::Transaction.create(Appsignal::Transaction::ACTION_CABLE) + transaction = Appsignal::Transaction.create( + Appsignal::Transaction::ACTION_CABLE, + :opentelemetry_scope => ["appsignal-ruby/action_cable", Appsignal::VERSION] + ) begin super diff --git a/lib/appsignal/integrations/data_mapper.rb b/lib/appsignal/integrations/data_mapper.rb index 50ef9797d..26f8a3482 100644 --- a/lib/appsignal/integrations/data_mapper.rb +++ b/lib/appsignal/integrations/data_mapper.rb @@ -29,7 +29,8 @@ def log(message) body_content, message.duration, body_format, - :opentelemetry_kind => :client + :opentelemetry_kind => :client, + :opentelemetry_scope => ["appsignal-ruby/data_mapper", Appsignal::VERSION] ) super end diff --git a/lib/appsignal/integrations/delayed_job_plugin.rb b/lib/appsignal/integrations/delayed_job_plugin.rb index 196f521e7..e371d028e 100644 --- a/lib/appsignal/integrations/delayed_job_plugin.rb +++ b/lib/appsignal/integrations/delayed_job_plugin.rb @@ -35,7 +35,8 @@ def self.enqueue_with_instrumentation(job, block) Appsignal.instrument( "enqueue.delayed_job", "enqueue #{enqueue_name(job)} job", - :opentelemetry_kind => :producer + :opentelemetry_kind => :producer, + :opentelemetry_scope => ["appsignal-ruby/delayed_job", Appsignal::VERSION] ) do block.call(job) end @@ -57,10 +58,16 @@ def self.enqueue_name(job) def self.invoke_with_instrumentation(job, block) transaction = - Appsignal::Transaction.create(Appsignal::Transaction::BACKGROUND_JOB) + Appsignal::Transaction.create( + Appsignal::Transaction::BACKGROUND_JOB, + :opentelemetry_scope => ["appsignal-ruby/delayed_job", Appsignal::VERSION] + ) begin - Appsignal.instrument("perform_job.delayed_job") do + Appsignal.instrument( + "perform_job.delayed_job", + :opentelemetry_scope => ["appsignal-ruby/delayed_job", Appsignal::VERSION] + ) do block.call(job) end rescue Exception => error diff --git a/lib/appsignal/integrations/dry_monitor.rb b/lib/appsignal/integrations/dry_monitor.rb index 9bb1f0dc4..4f276454c 100644 --- a/lib/appsignal/integrations/dry_monitor.rb +++ b/lib/appsignal/integrations/dry_monitor.rb @@ -9,7 +9,8 @@ module DryMonitorIntegration # kind is immutable, so it has to be set here at event start. def instrument(event_id, payload = {}, &block) Appsignal::Transaction.current.start_event( - :opentelemetry_kind => event_id.to_s == "sql" ? :client : nil + :opentelemetry_kind => event_id.to_s == "sql" ? :client : nil, + :opentelemetry_scope => ["appsignal-ruby/dry_monitor", Appsignal::VERSION] ) super diff --git a/lib/appsignal/integrations/faraday.rb b/lib/appsignal/integrations/faraday.rb index 38cd4336d..822cde389 100644 --- a/lib/appsignal/integrations/faraday.rb +++ b/lib/appsignal/integrations/faraday.rb @@ -18,7 +18,8 @@ def call(env) Appsignal.instrument( "request.faraday", "#{http_method} #{uri.scheme}://#{uri.host}", - :opentelemetry_kind => :client + :opentelemetry_kind => :client, + :opentelemetry_scope => ["appsignal-ruby/faraday", Appsignal::VERSION] ) do # Write trace context onto the outgoing request so the called service # joins this trace. Injected inside the instrument block, so the written diff --git a/lib/appsignal/integrations/http.rb b/lib/appsignal/integrations/http.rb index 80227d928..1a082766a 100644 --- a/lib/appsignal/integrations/http.rb +++ b/lib/appsignal/integrations/http.rb @@ -13,6 +13,7 @@ def self.instrument(verb, uri, &block) "request.http_rb", "#{verb.to_s.upcase} #{request_uri}", :opentelemetry_kind => :client, + :opentelemetry_scope => ["appsignal-ruby/http_rb", Appsignal::VERSION], &block ) end diff --git a/lib/appsignal/integrations/mongo_ruby_driver.rb b/lib/appsignal/integrations/mongo_ruby_driver.rb index c3e9841de..d944747d6 100644 --- a/lib/appsignal/integrations/mongo_ruby_driver.rb +++ b/lib/appsignal/integrations/mongo_ruby_driver.rb @@ -22,7 +22,10 @@ def started(event) store[event.request_id] = command # Start this event. The query is an outgoing client call. - transaction.start_event(:opentelemetry_kind => :client) + transaction.start_event( + :opentelemetry_kind => :client, + :opentelemetry_scope => ["appsignal-ruby/mongo", Appsignal::VERSION] + ) end # Called by Mongo::Monitor when query succeeds diff --git a/lib/appsignal/integrations/net_http.rb b/lib/appsignal/integrations/net_http.rb index 7dba3fac8..494f03679 100644 --- a/lib/appsignal/integrations/net_http.rb +++ b/lib/appsignal/integrations/net_http.rb @@ -15,7 +15,8 @@ def request(request, body = nil, &block) Appsignal.instrument( "request.net_http", "#{request.method} #{use_ssl? ? "https" : "http"}://#{request["host"] || address}", - :opentelemetry_kind => :client + :opentelemetry_kind => :client, + :opentelemetry_scope => ["appsignal-ruby/net_http", Appsignal::VERSION] ) do # Write trace context onto the outgoing request so the called service # joins this trace. No-op outside collector mode. The request object diff --git a/lib/appsignal/integrations/puma.rb b/lib/appsignal/integrations/puma.rb index 2123256f8..9a0ae22ad 100644 --- a/lib/appsignal/integrations/puma.rb +++ b/lib/appsignal/integrations/puma.rb @@ -13,7 +13,10 @@ def lowlevel_error(error, env, response_status = 500) end unless PumaServerHelper.ignored_error?(error) - Appsignal.report_error(error) do |transaction| + Appsignal.report_error( + error, + :opentelemetry_scope => ["appsignal-ruby/puma", Appsignal::VERSION] + ) do |transaction| Appsignal::Rack::ApplyRackRequest .new(::Rack::Request.new(env)) .apply_to(transaction) diff --git a/lib/appsignal/integrations/que.rb b/lib/appsignal/integrations/que.rb index a144400b1..770c8d318 100644 --- a/lib/appsignal/integrations/que.rb +++ b/lib/appsignal/integrations/que.rb @@ -83,11 +83,15 @@ def _run(*args) transaction = Appsignal::Transaction.create( Appsignal::Transaction::BACKGROUND_JOB, - :opentelemetry_context => QueTraceContext.extract(local_attrs.dig(:data, :tags)) + :opentelemetry_context => QueTraceContext.extract(local_attrs.dig(:data, :tags)), + :opentelemetry_scope => ["appsignal-ruby/que", Appsignal::VERSION] ) begin - Appsignal.instrument("perform_job.que") { super } + Appsignal.instrument( + "perform_job.que", + :opentelemetry_scope => ["appsignal-ruby/que", Appsignal::VERSION] + ) { super } rescue Exception => error transaction.set_error(error) raise error @@ -169,7 +173,12 @@ def record_enqueue(job_options, event_name, title) return yield job_options_with_context(job_options) end - Appsignal.instrument(event_name, title, :opentelemetry_kind => :producer) do + Appsignal.instrument( + event_name, + title, + :opentelemetry_kind => :producer, + :opentelemetry_scope => ["appsignal-ruby/que", Appsignal::VERSION] + ) do yield job_options_with_context(job_options) end end diff --git a/lib/appsignal/integrations/railtie.rb b/lib/appsignal/integrations/railtie.rb index 82e6eda8a..3860c89c6 100644 --- a/lib/appsignal/integrations/railtie.rb +++ b/lib/appsignal/integrations/railtie.rb @@ -96,7 +96,10 @@ def report(error, handled:, severity:, context: {}, source: nil) # rubocop:disab is_rails_runner = source == "application.runner.railties" namespace, action_name, tags, custom_data = context_for(context.dup) - Appsignal.report_error(error) do |transaction| + Appsignal.report_error( + error, + :opentelemetry_scope => ["appsignal-ruby/rails", Appsignal::VERSION] + ) do |transaction| if namespace transaction.set_namespace(namespace) elsif is_rails_runner diff --git a/lib/appsignal/integrations/rake.rb b/lib/appsignal/integrations/rake.rb index 0ae8d0e22..2a5bce69a 100644 --- a/lib/appsignal/integrations/rake.rb +++ b/lib/appsignal/integrations/rake.rb @@ -22,7 +22,10 @@ def execute(*args) end begin - Appsignal.instrument "task.rake" do + Appsignal.instrument( + "task.rake", + :opentelemetry_scope => ["appsignal-ruby/rake", Appsignal::VERSION] + ) do super end rescue Exception => error @@ -47,7 +50,10 @@ def execute(*args) private def _appsignal_create_transaction - Appsignal::Transaction.create("rake") + Appsignal::Transaction.create( + "rake", + :opentelemetry_scope => ["appsignal-ruby/rake", Appsignal::VERSION] + ) end end diff --git a/lib/appsignal/integrations/redis.rb b/lib/appsignal/integrations/redis.rb index 898f60cee..def2a74c9 100644 --- a/lib/appsignal/integrations/redis.rb +++ b/lib/appsignal/integrations/redis.rb @@ -16,7 +16,8 @@ def write(command) "query.redis", id, sanitized_command, - :opentelemetry_kind => :client + :opentelemetry_kind => :client, + :opentelemetry_scope => ["appsignal-ruby/redis", Appsignal::VERSION] ) do super end diff --git a/lib/appsignal/integrations/redis_client.rb b/lib/appsignal/integrations/redis_client.rb index d30230248..1cb4cff22 100644 --- a/lib/appsignal/integrations/redis_client.rb +++ b/lib/appsignal/integrations/redis_client.rb @@ -16,7 +16,8 @@ def write(command) "query.redis", @config.id, sanitized_command, - :opentelemetry_kind => :client + :opentelemetry_kind => :client, + :opentelemetry_scope => ["appsignal-ruby/redis_client", Appsignal::VERSION] ) do super end diff --git a/lib/appsignal/integrations/resque.rb b/lib/appsignal/integrations/resque.rb index d7154f87a..5bdcc5602 100644 --- a/lib/appsignal/integrations/resque.rb +++ b/lib/appsignal/integrations/resque.rb @@ -9,10 +9,14 @@ def perform # enqueuer. No-op outside collector mode. transaction = Appsignal::Transaction.create( Appsignal::Transaction::BACKGROUND_JOB, - :opentelemetry_context => Appsignal::OpenTelemetry.extract_job_context(payload) + :opentelemetry_context => Appsignal::OpenTelemetry.extract_job_context(payload), + :opentelemetry_scope => ["appsignal-ruby/resque", Appsignal::VERSION] ) - Appsignal.instrument "perform.resque" do + Appsignal.instrument( + "perform.resque", + :opentelemetry_scope => ["appsignal-ruby/resque", Appsignal::VERSION] + ) do super end rescue Exception => exception @@ -54,7 +58,8 @@ def push(queue, item) Appsignal.instrument( "enqueue.resque", "enqueue #{item["class"]} job", - :opentelemetry_kind => :producer + :opentelemetry_kind => :producer, + :opentelemetry_scope => ["appsignal-ruby/resque", Appsignal::VERSION] ) do Appsignal::OpenTelemetry.inject_context(item) super diff --git a/lib/appsignal/integrations/shoryuken.rb b/lib/appsignal/integrations/shoryuken.rb index 6b79894c1..b3d0a063e 100644 --- a/lib/appsignal/integrations/shoryuken.rb +++ b/lib/appsignal/integrations/shoryuken.rb @@ -79,10 +79,15 @@ def call(worker_instance, queue, sqs_msg, body, &block) transaction = Appsignal::Transaction.create( Appsignal::Transaction::BACKGROUND_JOB, - :opentelemetry_context => context + :opentelemetry_context => context, + :opentelemetry_scope => ["appsignal-ruby/shoryuken", Appsignal::VERSION] ) - Appsignal.instrument("perform_job.shoryuken", &block) + Appsignal.instrument( + "perform_job.shoryuken", + :opentelemetry_scope => ["appsignal-ruby/shoryuken", Appsignal::VERSION], + &block + ) rescue Exception => error transaction.set_error(error) raise @@ -170,7 +175,8 @@ def call(options) Appsignal.instrument( "enqueue.shoryuken", enqueue_title(options), - :opentelemetry_kind => :producer + :opentelemetry_kind => :producer, + :opentelemetry_scope => ["appsignal-ruby/shoryuken", Appsignal::VERSION] ) do ShoryukenTraceContext.inject(options) yield diff --git a/lib/appsignal/integrations/sidekiq.rb b/lib/appsignal/integrations/sidekiq.rb index 1135e9431..182924786 100644 --- a/lib/appsignal/integrations/sidekiq.rb +++ b/lib/appsignal/integrations/sidekiq.rb @@ -38,7 +38,10 @@ def call(exception, sidekiq_context, _sidekiq_config = nil) # Sidekiq error outside of the middleware scope. # Can be a job JSON parse error or some other error happening in # Sidekiq. - transaction = Appsignal::Transaction.create(Appsignal::Transaction::BACKGROUND_JOB) + transaction = Appsignal::Transaction.create( + Appsignal::Transaction::BACKGROUND_JOB, + :opentelemetry_scope => ["appsignal-ruby/sidekiq", Appsignal::VERSION] + ) transaction.set_action_if_nil("SidekiqInternal") transaction.set_metadata("sidekiq_error", sidekiq_context[:context]) transaction.add_params_if_nil(:jobstr => sidekiq_context[:jobstr]) @@ -120,7 +123,12 @@ def call(_worker_class, job, _queue, _redis_pool) end title = "enqueue #{SidekiqActionName.parse_action_name(job)} job" - Appsignal.instrument("enqueue.sidekiq", title, :opentelemetry_kind => :producer) do + Appsignal.instrument( + "enqueue.sidekiq", + title, + :opentelemetry_kind => :producer, + :opentelemetry_scope => ["appsignal-ruby/sidekiq", Appsignal::VERSION] + ) do Appsignal::OpenTelemetry.inject_context(job) yield end @@ -150,7 +158,8 @@ def call(_worker, item, _queue, &block) # enqueuer. No-op outside collector mode. transaction = Appsignal::Transaction.create( Appsignal::Transaction::BACKGROUND_JOB, - :opentelemetry_context => Appsignal::OpenTelemetry.extract_job_context(item) + :opentelemetry_context => Appsignal::OpenTelemetry.extract_job_context(item), + :opentelemetry_scope => ["appsignal-ruby/sidekiq", Appsignal::VERSION] ) transaction.set_action_if_nil(action_name) @@ -159,7 +168,11 @@ def call(_worker, item, _queue, &block) end begin - Appsignal.instrument "perform_job.sidekiq", &block + Appsignal.instrument( + "perform_job.sidekiq", + :opentelemetry_scope => ["appsignal-ruby/sidekiq", Appsignal::VERSION], + &block + ) rescue Exception => exception job_status = :failed raise exception diff --git a/lib/appsignal/integrations/webmachine.rb b/lib/appsignal/integrations/webmachine.rb index cf5381c80..fd4cdbd08 100644 --- a/lib/appsignal/integrations/webmachine.rb +++ b/lib/appsignal/integrations/webmachine.rb @@ -18,7 +18,8 @@ def run Appsignal::Transaction::HTTP_REQUEST, :opentelemetry_context => Appsignal::OpenTelemetry.if_started do ::OpenTelemetry.propagation.extract(request.headers) - end + end, + :opentelemetry_scope => ["appsignal-ruby/webmachine", Appsignal::VERSION] ) end @@ -26,7 +27,10 @@ def run transaction.add_params_if_nil { request.query } transaction.add_headers_if_nil { request.headers if request.respond_to?(:headers) } - Appsignal.instrument("process_action.webmachine") do + Appsignal.instrument( + "process_action.webmachine", + :opentelemetry_scope => ["appsignal-ruby/webmachine", Appsignal::VERSION] + ) do super end ensure From 07a56bd48a2a29a435692c416b1078e8abe7837c Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Tue, 21 Jul 2026 15:07:12 +0200 Subject: [PATCH 24/69] Test remaining integration scopes Each integration records its spans under its own scope. The Puma case also covers the precedence: reporting an error onto an existing transaction keeps that transaction's scope rather than taking Puma's. --- spec/lib/appsignal/hooks/action_cable_spec.rb | 3 +++ spec/lib/appsignal/hooks/activejob_spec.rb | 2 ++ spec/lib/appsignal/hooks/at_exit_spec.rb | 1 + spec/lib/appsignal/hooks/dry_monitor_spec.rb | 1 + spec/lib/appsignal/hooks/rake_spec.rb | 3 +++ spec/lib/appsignal/hooks/redis_client_spec.rb | 1 + spec/lib/appsignal/hooks/redis_spec.rb | 1 + spec/lib/appsignal/hooks/sequel_spec.rb | 1 + spec/lib/appsignal/integrations/data_mapper_spec.rb | 2 ++ .../appsignal/integrations/delayed_job_plugin_spec.rb | 4 ++++ spec/lib/appsignal/integrations/faraday_spec.rb | 1 + spec/lib/appsignal/integrations/http_spec.rb | 10 ++++++++++ .../appsignal/integrations/mongo_ruby_driver_spec.rb | 2 ++ spec/lib/appsignal/integrations/net_http_spec.rb | 2 ++ spec/lib/appsignal/integrations/puma_spec.rb | 4 ++++ spec/lib/appsignal/integrations/que_spec.rb | 3 +++ spec/lib/appsignal/integrations/railtie_spec.rb | 1 + spec/lib/appsignal/integrations/resque_spec.rb | 5 +++++ spec/lib/appsignal/integrations/shoryuken_spec.rb | 3 +++ spec/lib/appsignal/integrations/sidekiq_spec.rb | 7 +++++++ spec/lib/appsignal/integrations/webmachine_spec.rb | 1 + 21 files changed, 58 insertions(+) diff --git a/spec/lib/appsignal/hooks/action_cable_spec.rb b/spec/lib/appsignal/hooks/action_cable_spec.rb index b5b4e4041..ea423e964 100644 --- a/spec/lib/appsignal/hooks/action_cable_spec.rb +++ b/spec/lib/appsignal/hooks/action_cable_spec.rb @@ -108,6 +108,7 @@ def perform expect(root_span.attributes["appsignal.namespace"]) .to eq(Appsignal::Transaction::ACTION_CABLE) expect(root_span.attributes["appsignal.action_name"]).to eq("MyChannel#speak") + expect(scope_of(root_span)).to eq(["appsignal-ruby/action_cable", Appsignal::VERSION]) expect(exception_events).to be_empty expect(root_span.attributes["appsignal.tag.method"]).to eq("websocket") expect(root_span.attributes["appsignal.tag.path"]).to eq("/blog") @@ -276,6 +277,8 @@ def perform span = event_spans.find { |s| s.name == "subscribed.action_cable" } expect(span).not_to be_nil expect(span.parent_span_id).to eq(root_span.span_id) + expect(scope_of(root_span)).to eq(["appsignal-ruby/action_cable", Appsignal::VERSION]) + expect(scope_of(span)).to eq(["appsignal-ruby/action_cable", Appsignal::VERSION]) expect(JSON.parse(root_span.attributes["appsignal.request.session_data"])) .to eq("user_id" => "123", "session" => "yes") expect(root_span.attributes["appsignal.tag.request_id"]).to eq(request_id) diff --git a/spec/lib/appsignal/hooks/activejob_spec.rb b/spec/lib/appsignal/hooks/activejob_spec.rb index b35b55212..beb8ae003 100644 --- a/spec/lib/appsignal/hooks/activejob_spec.rb +++ b/spec/lib/appsignal/hooks/activejob_spec.rb @@ -154,6 +154,7 @@ def perform last_transaction.complete expect(root_span.kind).to eq(:consumer) + expect(scope_of(root_span)).to eq(["appsignal-ruby/active_job", Appsignal::VERSION]) expect(root_span.attributes["appsignal.namespace"]).to eq("background") expect(root_span.attributes["appsignal.action_name"]).to eq("ActiveJobTestJob#perform") expect(exception_events).to be_empty @@ -599,6 +600,7 @@ def enqueue_within_transaction # 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(scope_of(producer)).to eq(["appsignal-ruby/active_job", Appsignal::VERSION]) expect(producer.kind).to eq(:producer) expect(producer.parent_span_id).to eq(root_span.span_id) diff --git a/spec/lib/appsignal/hooks/at_exit_spec.rb b/spec/lib/appsignal/hooks/at_exit_spec.rb index 8d8da81c1..a03cb7f30 100644 --- a/spec/lib/appsignal/hooks/at_exit_spec.rb +++ b/spec/lib/appsignal/hooks/at_exit_spec.rb @@ -83,6 +83,7 @@ def perform expect { perform }.to change { created_transactions.count }.by(1) expect(root_span.attributes["appsignal.namespace"]).to eq("unhandled") + expect(scope_of(root_span)).to eq(["appsignal-ruby/at_exit", Appsignal::VERSION]) event = root_span.events.find { |e| e.name == "exception" } expect(event).not_to be_nil expect(event.attributes["exception.type"]).to eq("ExampleException") diff --git a/spec/lib/appsignal/hooks/dry_monitor_spec.rb b/spec/lib/appsignal/hooks/dry_monitor_spec.rb index cacf7dfe1..26890d10b 100644 --- a/spec/lib/appsignal/hooks/dry_monitor_spec.rb +++ b/spec/lib/appsignal/hooks/dry_monitor_spec.rb @@ -79,6 +79,7 @@ def perform 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.rom") + expect(scope_of(span)).to eq(["appsignal-ruby/dry_monitor", Appsignal::VERSION]) expect(attrs).not_to have_key("appsignal.body") end end diff --git a/spec/lib/appsignal/hooks/rake_spec.rb b/spec/lib/appsignal/hooks/rake_spec.rb index 7a8b64614..e56e6baa7 100644 --- a/spec/lib/appsignal/hooks/rake_spec.rb +++ b/spec/lib/appsignal/hooks/rake_spec.rb @@ -74,6 +74,9 @@ def perform expect(root_span.attributes["appsignal.action_name"]).to eq("task:name") expect(exception_events).to be_empty expect(event_spans.map(&:name)).to include("task.rake") + expect(scope_of(root_span)).to eq(["appsignal-ruby/rake", Appsignal::VERSION]) + task_span = event_spans.find { |s| s.name == "task.rake" } + expect(scope_of(task_span)).to eq(["appsignal-ruby/rake", Appsignal::VERSION]) expect(last_transaction).to be_completed end end diff --git a/spec/lib/appsignal/hooks/redis_client_spec.rb b/spec/lib/appsignal/hooks/redis_client_spec.rb index a9fa819cf..ec9d10549 100644 --- a/spec/lib/appsignal/hooks/redis_client_spec.rb +++ b/spec/lib/appsignal/hooks/redis_client_spec.rb @@ -111,6 +111,7 @@ def perform 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(scope_of(span)).to eq(["appsignal-ruby/redis_client", Appsignal::VERSION]) expect(span.attributes).not_to have_key("db.query.text") end end diff --git a/spec/lib/appsignal/hooks/redis_spec.rb b/spec/lib/appsignal/hooks/redis_spec.rb index 28db616a4..a5ea2ef59 100644 --- a/spec/lib/appsignal/hooks/redis_spec.rb +++ b/spec/lib/appsignal/hooks/redis_spec.rb @@ -105,6 +105,7 @@ def perform 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(scope_of(span)).to eq(["appsignal-ruby/redis", Appsignal::VERSION]) expect(span.attributes).not_to have_key("db.query.text") end end diff --git a/spec/lib/appsignal/hooks/sequel_spec.rb b/spec/lib/appsignal/hooks/sequel_spec.rb index d82805af7..8d26d0181 100644 --- a/spec/lib/appsignal/hooks/sequel_spec.rb +++ b/spec/lib/appsignal/hooks/sequel_spec.rb @@ -50,6 +50,7 @@ def perform 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") + expect(scope_of(span)).to eq(["appsignal-ruby/sequel", Appsignal::VERSION]) end end else diff --git a/spec/lib/appsignal/integrations/data_mapper_spec.rb b/spec/lib/appsignal/integrations/data_mapper_spec.rb index 57240a140..ec92b5810 100644 --- a/spec/lib/appsignal/integrations/data_mapper_spec.rb +++ b/spec/lib/appsignal/integrations/data_mapper_spec.rb @@ -62,6 +62,7 @@ def perform 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(scope_of(span)).to eq(["appsignal-ruby/data_mapper", Appsignal::VERSION]) expect(attrs).not_to have_key("appsignal.body") observed = span.end_timestamp - span.start_timestamp expect(observed).to be_within(50_000_000).of(100_000_000) @@ -108,6 +109,7 @@ def perform expect(span.parent_span_id).to eq(root_span.span_id) attrs = span.attributes expect(attrs["appsignal.category"]).to eq("query.data_mapper") + expect(scope_of(span)).to eq(["appsignal-ruby/data_mapper", Appsignal::VERSION]) expect(attrs).not_to have_key("appsignal.body") expect(attrs).not_to have_key("db.query.text") expect(attrs).not_to have_key("db.system.name") diff --git a/spec/lib/appsignal/integrations/delayed_job_plugin_spec.rb b/spec/lib/appsignal/integrations/delayed_job_plugin_spec.rb index 10c0c781e..fff70cffa 100644 --- a/spec/lib/appsignal/integrations/delayed_job_plugin_spec.rb +++ b/spec/lib/appsignal/integrations/delayed_job_plugin_spec.rb @@ -60,6 +60,7 @@ def perform_job(job) # producer span is not linked to the later perform. producer = event_spans.find { |s| s.name == "enqueue DelayedTestJob job" } expect(producer.attributes["appsignal.category"]).to eq("enqueue.delayed_job") + expect(scope_of(producer)).to eq(["appsignal-ruby/delayed_job", Appsignal::VERSION]) expect(producer.kind).to eq(:producer) expect(producer.parent_span_id).to eq(root_span.span_id) end @@ -166,6 +167,9 @@ def appsignal_name expect(root_span.attributes["appsignal.action_name"]).to eq("DelayedTestJob#perform") expect(root_span.attributes["appsignal.namespace"]).to eq("background") expect(event_spans.map(&:name)).to include("perform_job.delayed_job") + perform_span = event_spans.find { |s| s.name == "perform_job.delayed_job" } + expect(scope_of(root_span)).to eq(["appsignal-ruby/delayed_job", Appsignal::VERSION]) + expect(scope_of(perform_span)).to eq(["appsignal-ruby/delayed_job", Appsignal::VERSION]) end end diff --git a/spec/lib/appsignal/integrations/faraday_spec.rb b/spec/lib/appsignal/integrations/faraday_spec.rb index 45affbb9c..c20d5bff3 100644 --- a/spec/lib/appsignal/integrations/faraday_spec.rb +++ b/spec/lib/appsignal/integrations/faraday_spec.rb @@ -46,6 +46,7 @@ def perform 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) + expect(scope_of(faraday_span)).to eq(["appsignal-ruby/faraday", Appsignal::VERSION]) # Net::HTTP is suppressed, so there's no nested net_http span. expect(event_span("request.net_http")).to be_nil diff --git a/spec/lib/appsignal/integrations/http_spec.rb b/spec/lib/appsignal/integrations/http_spec.rb index b48478ecd..6a6e627e5 100644 --- a/spec/lib/appsignal/integrations/http_spec.rb +++ b/spec/lib/appsignal/integrations/http_spec.rb @@ -41,6 +41,7 @@ def perform 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(scope_of(span)).to eq(["appsignal-ruby/http_rb", Appsignal::VERSION]) expect(span.attributes).not_to have_key("appsignal.body") # The outgoing request carries a W3C traceparent for the client span, so @@ -85,6 +86,7 @@ def perform 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(scope_of(span)).to eq(["appsignal-ruby/http_rb", Appsignal::VERSION]) expect(span.attributes).not_to have_key("appsignal.body") expect(injected_traceparent("https://www.google.com/")) @@ -121,6 +123,7 @@ def perform 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(scope_of(span)).to eq(["appsignal-ruby/http_rb", Appsignal::VERSION]) expect(span.attributes).not_to have_key("appsignal.body") end end @@ -154,6 +157,7 @@ def perform 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(scope_of(span)).to eq(["appsignal-ruby/http_rb", Appsignal::VERSION]) expect(span.attributes).not_to have_key("appsignal.body") end end @@ -196,6 +200,7 @@ def perform 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") + expect(scope_of(span)).to eq(["appsignal-ruby/http_rb", Appsignal::VERSION]) end end @@ -234,6 +239,7 @@ def to_s span = event_spans.first expect(span.name).to eq("GET http://www.google.com") expect(span.attributes["appsignal.category"]).to eq("request.http_rb") + expect(scope_of(span)).to eq(["appsignal-ruby/http_rb", Appsignal::VERSION]) end end @@ -265,6 +271,7 @@ def perform span = event_spans.first expect(span.name).to eq("GET http://www.google.com") expect(span.attributes["appsignal.category"]).to eq("request.http_rb") + expect(scope_of(span)).to eq(["appsignal-ruby/http_rb", Appsignal::VERSION]) end end @@ -296,6 +303,7 @@ def perform span = event_spans.first expect(span.name).to eq("GET http://www.google.com") expect(span.attributes["appsignal.category"]).to eq("request.http_rb") + expect(scope_of(span)).to eq(["appsignal-ruby/http_rb", Appsignal::VERSION]) end end @@ -327,6 +335,7 @@ def perform span = event_spans.first expect(span.name).to eq("GET http://www.google.com") expect(span.attributes["appsignal.category"]).to eq("request.http_rb") + expect(scope_of(span)).to eq(["appsignal-ruby/http_rb", Appsignal::VERSION]) end end @@ -358,6 +367,7 @@ def perform span = event_spans.first expect(span.name).to eq("GET http://www.example.com") expect(span.attributes["appsignal.category"]).to eq("request.http_rb") + expect(scope_of(span)).to eq(["appsignal-ruby/http_rb", Appsignal::VERSION]) 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 9c5bc9aa0..d59397091 100644 --- a/spec/lib/appsignal/integrations/mongo_ruby_driver_spec.rb +++ b/spec/lib/appsignal/integrations/mongo_ruby_driver_spec.rb @@ -91,6 +91,7 @@ def perform 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(scope_of(span)).to eq(["appsignal-ruby/mongo", Appsignal::VERSION]) expect(span.attributes["appsignal.body"]).to eq("{\"foo\":\"?\"}") snapshot = metric_snapshot("mongodb_query_duration") @@ -135,6 +136,7 @@ def perform expect(span.name).to eq("find | test | FAILED") expect(span.kind).to eq(:client) expect(span.attributes["appsignal.category"]).to eq("query.mongodb") + expect(scope_of(span)).to eq(["appsignal-ruby/mongo", Appsignal::VERSION]) expect(span.attributes["appsignal.body"]).to eq("{\"foo\":\"?\"}") end end diff --git a/spec/lib/appsignal/integrations/net_http_spec.rb b/spec/lib/appsignal/integrations/net_http_spec.rb index 76238c2f0..4c1a873d5 100644 --- a/spec/lib/appsignal/integrations/net_http_spec.rb +++ b/spec/lib/appsignal/integrations/net_http_spec.rb @@ -34,6 +34,7 @@ def perform 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(scope_of(span)).to eq(["appsignal-ruby/net_http", Appsignal::VERSION]) expect(span.attributes).not_to have_key("appsignal.body") # The outgoing request carries a W3C traceparent for the client span, so @@ -79,6 +80,7 @@ def perform 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(scope_of(span)).to eq(["appsignal-ruby/net_http", Appsignal::VERSION]) expect(span.attributes).not_to have_key("appsignal.body") expect(injected_traceparent("https://www.google.com/")) diff --git a/spec/lib/appsignal/integrations/puma_spec.rb b/spec/lib/appsignal/integrations/puma_spec.rb index e739fef12..952fea4e1 100644 --- a/spec/lib/appsignal/integrations/puma_spec.rb +++ b/spec/lib/appsignal/integrations/puma_spec.rb @@ -69,6 +69,9 @@ def perform expect(root_span.status.code).to eq(::OpenTelemetry::Trace::Status::ERROR) expect(root_span.kind).to eq(:server) expect(root_span.attributes["appsignal.tag.reported_by"]).to eq("puma_lowlevel_error") + # The transaction already existed, so report_error reuses it and keeps + # its default scope rather than applying the Puma scope. + expect(scope_of(root_span)).to eq(["appsignal-ruby", Appsignal::VERSION]) end end @@ -104,6 +107,7 @@ def perform expect(root_span.status.code).to eq(::OpenTelemetry::Trace::Status::ERROR) expect(root_span.kind).to eq(:server) expect(root_span.attributes["appsignal.tag.reported_by"]).to eq("puma_lowlevel_error") + expect(scope_of(root_span)).to eq(["appsignal-ruby/puma", Appsignal::VERSION]) end end diff --git a/spec/lib/appsignal/integrations/que_spec.rb b/spec/lib/appsignal/integrations/que_spec.rb index c87e1f2ad..d0d34d226 100644 --- a/spec/lib/appsignal/integrations/que_spec.rb +++ b/spec/lib/appsignal/integrations/que_spec.rb @@ -89,6 +89,8 @@ def perform 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") + expect(scope_of(root_span)).to eq(["appsignal-ruby/que", Appsignal::VERSION]) + expect(scope_of(span)).to eq(["appsignal-ruby/que", Appsignal::VERSION]) expected_params = { "arguments" => %w[post_id_123 user_id_123] } expected_params["keyword_arguments"] = {} if DependencyHelper.que2_present? expect(JSON.parse(root_span.attributes["appsignal.function.parameters"])) @@ -450,6 +452,7 @@ def expect_job_arguments_untouched # 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(scope_of(producer)).to eq(["appsignal-ruby/que", Appsignal::VERSION]) expect(producer.kind).to eq(:producer) expect(producer.parent_span_id).to eq(root_span.span_id) diff --git a/spec/lib/appsignal/integrations/railtie_spec.rb b/spec/lib/appsignal/integrations/railtie_spec.rb index 652d60acb..3fc4ca3a2 100644 --- a/spec/lib/appsignal/integrations/railtie_spec.rb +++ b/spec/lib/appsignal/integrations/railtie_spec.rb @@ -281,6 +281,7 @@ def perform expect(event.attributes["exception.stacktrace"]).to be_a(String) expect(event.attributes["appsignal.alert_this_error"]).to eq(true) expect(root_span.status.code).to eq(::OpenTelemetry::Trace::Status::ERROR) + expect(scope_of(root_span)).to eq(["appsignal-ruby/rails", Appsignal::VERSION]) end end diff --git a/spec/lib/appsignal/integrations/resque_spec.rb b/spec/lib/appsignal/integrations/resque_spec.rb index cf9c574f7..0bb883345 100644 --- a/spec/lib/appsignal/integrations/resque_spec.rb +++ b/spec/lib/appsignal/integrations/resque_spec.rb @@ -61,6 +61,8 @@ def perform span = event_spans.find { |s| s.name == "perform.resque" } expect(span).not_to be_nil expect(span.parent_span_id).to eq(root_span.span_id) + expect(scope_of(root_span)).to eq(["appsignal-ruby/resque", Appsignal::VERSION]) + expect(scope_of(span)).to eq(["appsignal-ruby/resque", Appsignal::VERSION]) end end @@ -145,6 +147,8 @@ def perform span = event_spans.find { |s| s.name == "perform.resque" } expect(span).not_to be_nil expect(span.parent_span_id).to eq(root_span.span_id) + expect(scope_of(root_span)).to eq(["appsignal-ruby/resque", Appsignal::VERSION]) + expect(scope_of(span)).to eq(["appsignal-ruby/resque", Appsignal::VERSION]) end end @@ -264,6 +268,7 @@ def enqueue # 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(scope_of(producer)).to eq(["appsignal-ruby/resque", Appsignal::VERSION]) expect(producer.kind).to eq(:producer) expect(producer.parent_span_id).to eq(root_span.span_id) diff --git a/spec/lib/appsignal/integrations/shoryuken_spec.rb b/spec/lib/appsignal/integrations/shoryuken_spec.rb index 2774a8906..c029f84a2 100644 --- a/spec/lib/appsignal/integrations/shoryuken_spec.rb +++ b/spec/lib/appsignal/integrations/shoryuken_spec.rb @@ -90,6 +90,8 @@ def perform 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(scope_of(root_span)).to eq(["appsignal-ruby/shoryuken", Appsignal::VERSION]) + expect(scope_of(span)).to eq(["appsignal-ruby/shoryuken", Appsignal::VERSION]) expect(JSON.parse(root_span.attributes["appsignal.function.parameters"])) .to eq("foo" => "Foo", "bar" => "Bar") expect(root_span.attributes["appsignal.tag.message_id"]).to eq("msg1") @@ -409,6 +411,7 @@ def perform # 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(scope_of(producer)).to eq(["appsignal-ruby/shoryuken", Appsignal::VERSION]) expect(producer.kind).to eq(:producer) expect(producer.parent_span_id).to eq(root_span.span_id) diff --git a/spec/lib/appsignal/integrations/sidekiq_spec.rb b/spec/lib/appsignal/integrations/sidekiq_spec.rb index f80dc37fa..5a3123857 100644 --- a/spec/lib/appsignal/integrations/sidekiq_spec.rb +++ b/spec/lib/appsignal/integrations/sidekiq_spec.rb @@ -140,6 +140,7 @@ def perform perform expect(root_span.kind).to eq(:consumer) + expect(scope_of(root_span)).to eq(["appsignal-ruby/sidekiq", Appsignal::VERSION]) expect(root_span.attributes["appsignal.action_name"]) .to eq("SidekiqInternal") event = root_span.events.find { |e| e.name == "exception" } @@ -362,6 +363,7 @@ def enqueue 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) + expect(scope_of(producer)).to eq(["appsignal-ruby/sidekiq", Appsignal::VERSION]) # The job carries the producer span's trace context, so the job that # performs can link back to it. @@ -901,6 +903,11 @@ def perform 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") + + # Both the job's root span and its perform event carry the Sidekiq + # instrumentation scope. + expect(scope_of(root_span)).to eq(["appsignal-ruby/sidekiq", Appsignal::VERSION]) + expect(scope_of(span)).to eq(["appsignal-ruby/sidekiq", Appsignal::VERSION]) end end end diff --git a/spec/lib/appsignal/integrations/webmachine_spec.rb b/spec/lib/appsignal/integrations/webmachine_spec.rb index 2f1990dd9..e7a96858b 100644 --- a/spec/lib/appsignal/integrations/webmachine_spec.rb +++ b/spec/lib/appsignal/integrations/webmachine_spec.rb @@ -70,6 +70,7 @@ def perform expect(root_span.name).to eq("MyResource#GET") expect(root_span.kind).to eq(:server) expect(root_span.attributes["appsignal.action_name"]).to eq("MyResource#GET") + expect(scope_of(root_span)).to eq(["appsignal-ruby/webmachine", Appsignal::VERSION]) end end From 9b122f6fcd04dba1e6d1881cac50f29325a90fe4 Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Fri, 24 Jul 2026 13:31:35 +0200 Subject: [PATCH 25/69] Add the appsignal-opentelemetry companion gem Collector mode needs a specific set of OpenTelemetry gems. They cannot go in the appsignal gemspec, because they require Ruby 3.1 while the appsignal gem still supports Ruby 2.7, so a collector-mode user had to list all seven by hand. The `appsignal-opentelemetry` gem has no code of its own. It depends on appsignal, pinned to the current version, and on the OpenTelemetry gems at the versions collector mode needs. It lives under `packages/opentelemetry` so its gemspec can generate those dependencies from `Appsignal::OpenTelemetry::REQUIRED_GEMS`, which is the single source of truth for the set, so they cannot drift from what the runtime requires. The release tool treats the repository as two packages that always release together at one shared version. The appsignal gemspec ignores the packages directory, so the companion gem is never packaged into the main gem. --- Rakefile | 16 ++++++ appsignal.gemspec | 1 + mono.yml | 4 ++ packages/opentelemetry/.changesets/.gitkeep | 0 .../add-appsignal-opentelemetry-gem.md | 13 +++++ packages/opentelemetry/CHANGELOG.md | 1 + packages/opentelemetry/Gemfile | 13 +++++ packages/opentelemetry/README.md | 18 +++++++ packages/opentelemetry/Rakefile | 22 ++++++++ .../appsignal-opentelemetry.gemspec | 52 +++++++++++++++++++ .../lib/appsignal_opentelemetry/version.rb | 6 +++ packages/opentelemetry/spec/gemspec_spec.rb | 33 ++++++++++++ 12 files changed, 179 insertions(+) create mode 100644 packages/opentelemetry/.changesets/.gitkeep create mode 100644 packages/opentelemetry/.changesets/add-appsignal-opentelemetry-gem.md create mode 100644 packages/opentelemetry/CHANGELOG.md create mode 100644 packages/opentelemetry/Gemfile create mode 100644 packages/opentelemetry/README.md create mode 100644 packages/opentelemetry/Rakefile create mode 100644 packages/opentelemetry/appsignal-opentelemetry.gemspec create mode 100644 packages/opentelemetry/lib/appsignal_opentelemetry/version.rb create mode 100644 packages/opentelemetry/spec/gemspec_spec.rb diff --git a/Rakefile b/Rakefile index 66c53f814..08c0c3617 100644 --- a/Rakefile +++ b/Rakefile @@ -358,6 +358,22 @@ namespace :build do desc "Build all gem versions" task :all => ["ruby:gem", "jruby:gem"] + desc "Build every package in this repository" + task :packages => :all do + # Build the companion gem by delegating to its own Rakefile rather than + # duplicating its build logic here. mono builds each package directly, so + # this task is only a convenience for developers building from the root. + # + # Clear the Bundler environment first so the sub-build resolves against the + # package's own Gemfile instead of inheriting this repository's + # BUNDLE_GEMFILE. + Bundler.with_unbundled_env do + Dir.chdir("packages/opentelemetry") do + sh "bundle exec rake build:all" + end + end + end + desc "Clean up all gem build artifacts" task :clean do FileUtils.rm_rf File.expand_path("pkg", __dir__) diff --git a/appsignal.gemspec b/appsignal.gemspec index 07969a87f..5711bc360 100644 --- a/appsignal.gemspec +++ b/appsignal.gemspec @@ -7,6 +7,7 @@ IGNORED_PATHS = [ ".changesets/", ".github/", "gemfiles/", + "packages/", "script/", "spec/", diff --git a/mono.yml b/mono.yml index 7999f97e8..d226a70c6 100644 --- a/mono.yml +++ b/mono.yml @@ -1,6 +1,10 @@ --- language: ruby repo: "https://github.com/appsignal/appsignal-ruby" +packages: + appsignal: "." + appsignal-opentelemetry: "packages/opentelemetry" +version_lock: true bootstrap: post: - "rake extension:install" diff --git a/packages/opentelemetry/.changesets/.gitkeep b/packages/opentelemetry/.changesets/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/packages/opentelemetry/.changesets/add-appsignal-opentelemetry-gem.md b/packages/opentelemetry/.changesets/add-appsignal-opentelemetry-gem.md new file mode 100644 index 000000000..05a91c4c3 --- /dev/null +++ b/packages/opentelemetry/.changesets/add-appsignal-opentelemetry-gem.md @@ -0,0 +1,13 @@ +--- +bump: major +type: add +--- + +Add the `appsignal-opentelemetry` gem. It installs the OpenTelemetry gems that AppSignal needs to run in collector mode. Add it alongside `appsignal` to opt into collector mode with a single gem instead of listing each OpenTelemetry gem yourself: + +```ruby +gem "appsignal" +gem "appsignal-opentelemetry" +``` + +Collector mode requires Ruby 3.1 or newer, so this gem does too. Its version stays in lockstep with the `appsignal` gem. diff --git a/packages/opentelemetry/CHANGELOG.md b/packages/opentelemetry/CHANGELOG.md new file mode 100644 index 000000000..d2d231d64 --- /dev/null +++ b/packages/opentelemetry/CHANGELOG.md @@ -0,0 +1 @@ +# AppSignal OpenTelemetry gem Changelog diff --git a/packages/opentelemetry/Gemfile b/packages/opentelemetry/Gemfile new file mode 100644 index 000000000..ae25e99af --- /dev/null +++ b/packages/opentelemetry/Gemfile @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +source "https://rubygems.org" + +# Build against the appsignal gem in this repository, not a released version, +# so the two are always tested together. +gem "appsignal", :path => "../.." + +gemspec + +gem "rake", ">= 12" +gem "rspec", "~> 3.8" +gem "rubocop", "~> 1.87.0" diff --git a/packages/opentelemetry/README.md b/packages/opentelemetry/README.md new file mode 100644 index 000000000..4ab9b693e --- /dev/null +++ b/packages/opentelemetry/README.md @@ -0,0 +1,18 @@ +# AppSignal OpenTelemetry gem + +A companion gem for [`appsignal`](https://github.com/appsignal/appsignal-ruby). +It installs the OpenTelemetry gems that AppSignal needs to run in collector +mode, so you can opt into collector mode by adding a single gem to your +`Gemfile`: + +```ruby +gem "appsignal" +gem "appsignal-opentelemetry" +``` + +Collector mode requires Ruby 3.1 or newer. + +The gem itself has no runtime functionality of its own. Its only job is to depend on `appsignal` and +on the right versions of the OpenTelemetry gems. Its version is kept in lockstep +with the `appsignal` gem, so `appsignal-opentelemetry X.Y.Z` always pairs with +`appsignal X.Y.Z`. diff --git a/packages/opentelemetry/Rakefile b/packages/opentelemetry/Rakefile new file mode 100644 index 000000000..33352d654 --- /dev/null +++ b/packages/opentelemetry/Rakefile @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +require "fileutils" +require_relative "lib/appsignal_opentelemetry/version" + +namespace :build do + desc "Build the gem into pkg/" + task :all do + FileUtils.mkdir_p("pkg") + output = "pkg/appsignal-opentelemetry-#{AppsignalOpentelemetry::VERSION}.gem" + sh "gem build appsignal-opentelemetry.gemspec --output #{output}" + end +end + +begin + require "rspec/core/rake_task" + + desc "Run the gem test suite." + RSpec::Core::RakeTask.new(:test) +rescue LoadError + # RSpec is not available in every environment, such as during a bare install. +end diff --git a/packages/opentelemetry/appsignal-opentelemetry.gemspec b/packages/opentelemetry/appsignal-opentelemetry.gemspec new file mode 100644 index 000000000..69fba9a1c --- /dev/null +++ b/packages/opentelemetry/appsignal-opentelemetry.gemspec @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +require_relative "lib/appsignal_opentelemetry/version" + +# The OpenTelemetry gems and their minimum versions live in this +# dependency-free file in the main gem, which is the single source of truth +# for the set collector mode requires. +require_relative "../../lib/appsignal/opentelemetry/dependencies" + +Gem::Specification.new do |gem| + gem.name = "appsignal-opentelemetry" + gem.version = AppsignalOpentelemetry::VERSION + gem.authors = [ + "Robert Beekman", + "Thijs Cadier", + "Tom de Bruijn" + ] + gem.email = ["support@appsignal.com"] + gem.summary = "Installs the OpenTelemetry gems AppSignal collector mode needs" + gem.description = "A companion gem for appsignal that pulls in the OpenTelemetry " \ + "gems required to run AppSignal in collector mode." + gem.homepage = "https://github.com/appsignal/appsignal-ruby" + gem.license = "MIT" + + # Collector mode requires Ruby 3.1 or newer, so this companion gem does too. + # This mirrors `MIN_RUBY_VERSION_FOR_COLLECTOR_MODE` in `Appsignal::Config`, + # which is not cheap to load from a gemspec, so the value is hardcoded here. + gem.required_ruby_version = ">= 3.1" + + # Build the file list from a local glob rather than `git ls-files` so this + # gem only ever packages its own directory. + gem.files = Dir.chdir(__dir__) do + Dir["lib/**/*.rb", "*.gemspec", "CHANGELOG.md", "README.md"] + end + gem.require_paths = ["lib"] + + gem.metadata = { + "rubygems_mfa_required" => "true", + "changelog_uri" => + "https://github.com/appsignal/appsignal-ruby/blob/main/packages/opentelemetry/CHANGELOG.md", + "source_code_uri" => "https://github.com/appsignal/appsignal-ruby" + } + + gem.add_dependency "appsignal", "4.10.1" + + # Add the OpenTelemetry gems by looping over the shared list instead of + # listing them here. This keeps `REQUIRED_GEMS` the single source of truth, + # so this gem's dependencies can never drift from what the runtime requires. + Appsignal::OpenTelemetry::REQUIRED_GEMS.each do |name, minimum_version| + gem.add_dependency name, ">= #{minimum_version}" + end +end diff --git a/packages/opentelemetry/lib/appsignal_opentelemetry/version.rb b/packages/opentelemetry/lib/appsignal_opentelemetry/version.rb new file mode 100644 index 000000000..81034baf7 --- /dev/null +++ b/packages/opentelemetry/lib/appsignal_opentelemetry/version.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +module AppsignalOpentelemetry + # @return [String] + VERSION = "4.10.1" +end diff --git a/packages/opentelemetry/spec/gemspec_spec.rb b/packages/opentelemetry/spec/gemspec_spec.rb new file mode 100644 index 000000000..726922600 --- /dev/null +++ b/packages/opentelemetry/spec/gemspec_spec.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +require_relative "../../../lib/appsignal/opentelemetry/dependencies" + +RSpec.describe "appsignal-opentelemetry.gemspec" do + let(:gemspec_path) do + File.expand_path("../appsignal-opentelemetry.gemspec", __dir__) + end + let(:gemspec) { Gem::Specification.load(gemspec_path) } + + it "loads as a valid gemspec named appsignal-opentelemetry" do + expect(gemspec).to be_a(Gem::Specification) + expect(gemspec.name).to eq("appsignal-opentelemetry") + expect { gemspec.validate }.to_not raise_error + end + + it "requires Ruby 3.1 or newer" do + expect(gemspec.required_ruby_version.to_s).to eq(">= 3.1") + end + + it "depends on appsignal plus every required OpenTelemetry gem at its floor" do + expected = { "appsignal" => "= #{gemspec.version}" } + Appsignal::OpenTelemetry::REQUIRED_GEMS.each do |name, minimum_version| + expected[name] = ">= #{minimum_version}" + end + + actual = gemspec.dependencies.to_h do |dependency| + [dependency.name, dependency.requirement.to_s] + end + + expect(actual).to eq(expected) + end +end From e5a91ac7b66dc7efbb1685d7f5f0dcd609acad83 Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Mon, 27 Jul 2026 13:44:04 +0200 Subject: [PATCH 26/69] Bound collector OpenTelemetry gem versions Each OpenTelemetry gem collector mode needs now carries a full version requirement rather than only a minimum. The floor is the version the minimum supported Ruby resolves to in CI. The ceiling is a loose pessimistic cap at the next major version, so it does not block customers from updating these gems within a major version. `REQUIRED_GEMS` maps each gem to an array of requirement strings, so a gem can carry more than one constraint. The runtime gate uses `Gem::Requirement#satisfied_by?`, so it flags an installed gem that is too new as well as one that is too old. Its warning recommends the `appsignal-opentelemetry` gem and lists only the gems installed at an unsupported version. A missing gem is the ordinary "not set up yet" case, which the recommendation already covers, while an unsupported version usually points at a constraint elsewhere in the bundle that the companion gem cannot override. --- gemfiles/collector.rb | 4 +- lib/appsignal/opentelemetry.rb | 58 ++++++++++++++---- lib/appsignal/opentelemetry/dependencies.rb | 35 +++++------ .../appsignal-opentelemetry.gemspec | 4 +- packages/opentelemetry/spec/gemspec_spec.rb | 6 +- spec/lib/appsignal/opentelemetry_spec.rb | 60 +++++++++++++++++++ 6 files changed, 131 insertions(+), 36 deletions(-) diff --git a/gemfiles/collector.rb b/gemfiles/collector.rb index 15be7a64b..383581571 100644 --- a/gemfiles/collector.rb +++ b/gemfiles/collector.rb @@ -6,6 +6,6 @@ # the runtime version gate. require_relative "../lib/appsignal/opentelemetry/dependencies" -Appsignal::OpenTelemetry::REQUIRED_GEMS.each do |name, minimum_version| - gem name, ">= #{minimum_version}" +Appsignal::OpenTelemetry::REQUIRED_GEMS.each do |name, constraints| + gem name, *constraints end diff --git a/lib/appsignal/opentelemetry.rb b/lib/appsignal/opentelemetry.rb index c2171de61..48dae01c8 100644 --- a/lib/appsignal/opentelemetry.rb +++ b/lib/appsignal/opentelemetry.rb @@ -266,29 +266,63 @@ def require_sdk_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? + incompatible = incompatible_gems + return true if incompatible.nil? @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." + collector_gems_warning(incompatible) ) 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| + # Builds the warning shown when the OpenTelemetry gems collector mode needs + # are not all installed at a supported version. `incompatible` describes + # the gems that are installed but at a version we do not support. + # + # The message always recommends the `appsignal-opentelemetry` gem, which + # installs the whole set. It only lists gems when some are installed at an + # incompatible version, because that usually means a constraint in the + # bundle that the gem cannot override on its own. + def collector_gems_warning(incompatible) + message = + "AppSignal collector mode requires a set of OpenTelemetry gems. " \ + "Add the `appsignal-opentelemetry` gem to your bundle to install " \ + "them. The AppSignal agent will be used instead." + + unless incompatible.empty? + message += "\n\nThese installed OpenTelemetry gems are not compatible " \ + "with this AppSignal version. Update them or remove a version " \ + "constraint:\n" + message += incompatible.map { |line| "- #{line}" }.join("\n") + end + + message + end + + # Checks the installed OpenTelemetry gems against {REQUIRED_GEMS}. Returns + # `nil` when every required gem is installed at a supported version. + # Otherwise returns the descriptions of gems that are installed but at a + # version we do not support. That list is empty when the only problem is + # that some required gems are not installed at all. + def incompatible_gems + missing = false + incompatible = [] + + REQUIRED_GEMS.each do |name, constraints| spec = Gem.loaded_specs[name] + requirement = Gem::Requirement.new(*constraints) + if spec.nil? - "#{name} (not installed)" - elsif spec.version < Gem::Version.new(minimum) - "#{name} #{spec.version} (requires >= #{minimum})" + missing = true + elsif !requirement.satisfied_by?(spec.version) + incompatible << "#{name} #{spec.version} (requires #{requirement})" end end + + return nil if !missing && incompatible.empty? + + incompatible end end end diff --git a/lib/appsignal/opentelemetry/dependencies.rb b/lib/appsignal/opentelemetry/dependencies.rb index ce13ebfd4..af571d18e 100644 --- a/lib/appsignal/opentelemetry/dependencies.rb +++ b/lib/appsignal/opentelemetry/dependencies.rb @@ -4,31 +4,32 @@ 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 + # The OpenTelemetry gems collector mode supports, mapped to their version + # requirements. Each gem maps to an array of requirement strings so a gem + # can carry more than one constraint later, even though each holds a single + # `~>` requirement today. These gems are *not* declared in the gemspec: + # they are optional and only required when collector mode is active. Apps + # that opt into collector mode install them into their own bundle (see the # collector documentation). # - # 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`). + # Each requirement pins a floor and a ceiling. The floor is the version the + # minimum supported Ruby (3.1) resolves to in our CI collector matrix. The + # ceiling is a pessimistic `~>` cap at the next major version. We set the + # cap loosely on purpose so it does not block customers from updating these + # gems within a major version. # # This file must stay free of any other dependency so it can be required # directly from a Gemfile (see `gemfiles/collector.rb`) and from the # runtime version gate in `Appsignal::OpenTelemetry.configure` without # loading the rest of the gem. REQUIRED_GEMS = { - "opentelemetry-sdk" => "1.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" + "opentelemetry-sdk" => ["~> 1.10"], + "opentelemetry-common" => ["~> 0.23"], + "opentelemetry-metrics-sdk" => ["~> 0.12"], + "opentelemetry-logs-sdk" => ["~> 0.4"], + "opentelemetry-exporter-otlp" => ["~> 0.32"], + "opentelemetry-exporter-otlp-metrics" => ["~> 0.7"], + "opentelemetry-exporter-otlp-logs" => ["~> 0.3"] }.freeze end end diff --git a/packages/opentelemetry/appsignal-opentelemetry.gemspec b/packages/opentelemetry/appsignal-opentelemetry.gemspec index 69fba9a1c..1f2327552 100644 --- a/packages/opentelemetry/appsignal-opentelemetry.gemspec +++ b/packages/opentelemetry/appsignal-opentelemetry.gemspec @@ -46,7 +46,7 @@ Gem::Specification.new do |gem| # Add the OpenTelemetry gems by looping over the shared list instead of # listing them here. This keeps `REQUIRED_GEMS` the single source of truth, # so this gem's dependencies can never drift from what the runtime requires. - Appsignal::OpenTelemetry::REQUIRED_GEMS.each do |name, minimum_version| - gem.add_dependency name, ">= #{minimum_version}" + Appsignal::OpenTelemetry::REQUIRED_GEMS.each do |name, constraints| + gem.add_dependency name, *constraints end end diff --git a/packages/opentelemetry/spec/gemspec_spec.rb b/packages/opentelemetry/spec/gemspec_spec.rb index 726922600..453241231 100644 --- a/packages/opentelemetry/spec/gemspec_spec.rb +++ b/packages/opentelemetry/spec/gemspec_spec.rb @@ -18,10 +18,10 @@ expect(gemspec.required_ruby_version.to_s).to eq(">= 3.1") end - it "depends on appsignal plus every required OpenTelemetry gem at its floor" do + it "depends on appsignal plus every required OpenTelemetry gem at its constraints" do expected = { "appsignal" => "= #{gemspec.version}" } - Appsignal::OpenTelemetry::REQUIRED_GEMS.each do |name, minimum_version| - expected[name] = ">= #{minimum_version}" + Appsignal::OpenTelemetry::REQUIRED_GEMS.each do |name, constraints| + expected[name] = Gem::Requirement.new(*constraints).to_s end actual = gemspec.dependencies.to_h do |dependency| diff --git a/spec/lib/appsignal/opentelemetry_spec.rb b/spec/lib/appsignal/opentelemetry_spec.rb index ab2f081f2..d61d2466d 100644 --- a/spec/lib/appsignal/opentelemetry_spec.rb +++ b/spec/lib/appsignal/opentelemetry_spec.rb @@ -423,3 +423,63 @@ def resource_attrs(resource) end end end + +# The version gate compares installed gem versions against `REQUIRED_GEMS`. +# These specs stub `Gem.loaded_specs`, so they need no OpenTelemetry gems +# installed and run outside the `opentelemetry_present?` guard above. +describe Appsignal::OpenTelemetry, "collector-mode gem version gate" do + before { described_class.reset! } + after { described_class.reset! } + + # Stand in for the installed gems using a `{ name => version }` map. Any gem + # not in the map is treated as not installed. + def stub_loaded_specs(versions) + specs = versions.transform_values do |version| + instance_double(Gem::Specification, :version => Gem::Version.new(version)) + end + allow(Gem).to receive(:loaded_specs).and_return(specs) + end + + # Capture the message passed to the warning logger by the gate. + def captured_warning + message = nil + allow(Appsignal::Utils::StdoutAndLoggerMessage) + .to receive(:warning) { |msg| message = msg } + expect(described_class.send(:required_gem_versions_met?)).to be(false) + message + end + + context "when every required gem is missing" do + before { stub_loaded_specs({}) } + + it "warns with only the appsignal-opentelemetry recommendation" do + message = captured_warning + + expect(message).to include("Add the `appsignal-opentelemetry` gem") + expect(message).to_not include("not compatible") + expect(message).to_not include("- opentelemetry") + end + end + + context "when an installed gem is older than the supported version" do + before { stub_loaded_specs("opentelemetry-common" => "0.19.0") } + + it "warns with the recommendation and a line for that gem" do + message = captured_warning + + expect(message).to include("Add the `appsignal-opentelemetry` gem") + expect(message).to include("not compatible") + expect(message).to include("- opentelemetry-common 0.19.0 (requires ~> 0.23)") + end + end + + context "when an installed gem is newer than the supported version" do + before { stub_loaded_specs("opentelemetry-common" => "1.0.0") } + + it "flags the too-new gem as incompatible" do + message = captured_warning + + expect(message).to include("- opentelemetry-common 1.0.0 (requires ~> 0.23)") + end + end +end From 2dfc1eb8ebdbde9f7f01ae7d4286bb142daf4162 Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Mon, 27 Jul 2026 21:37:04 +0200 Subject: [PATCH 27/69] Install package deps in build:packages task The `build:packages` convenience task builds the companion gem by running `bundle exec rake build:all` in the package directory. On a fresh checkout that directory's bundle is not installed yet, so the command fails before it can build. Run `bundle install` there first, so the task works on its own without a separate setup step. --- Rakefile | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Rakefile b/Rakefile index 08c0c3617..0fd72468b 100644 --- a/Rakefile +++ b/Rakefile @@ -366,9 +366,10 @@ namespace :build do # # Clear the Bundler environment first so the sub-build resolves against the # package's own Gemfile instead of inheriting this repository's - # BUNDLE_GEMFILE. + # BUNDLE_GEMFILE. Install its dependencies first, then build it. Bundler.with_unbundled_env do Dir.chdir("packages/opentelemetry") do + sh "bundle install" sh "bundle exec rake build:all" end end From 082782da912997b3e0dc243ac4850fdfbf3f373f Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Mon, 20 Jul 2026 14:02:55 +0200 Subject: [PATCH 28/69] Don't emit metrics for actionless transactions In collector mode a transaction that never set an action name has nothing to group by, and agent mode does not report it at all. The actionless transaction was already dropped from traces by flagging its subtrace as ignored, but it still contributed to the `transaction_queue_duration` metric. The metric is gated on the transaction having an action, so an actionless transaction contributes to no aggregate. --- .../transaction/opentelemetry_backend.rb | 21 ++++++++----------- .../transaction/opentelemetry_backend_spec.rb | 12 +++++++++++ spec/lib/appsignal/transaction_spec.rb | 3 +++ 3 files changed, 24 insertions(+), 12 deletions(-) diff --git a/lib/appsignal/transaction/opentelemetry_backend.rb b/lib/appsignal/transaction/opentelemetry_backend.rb index 8204cd0f9..b13ed1a7e 100644 --- a/lib/appsignal/transaction/opentelemetry_backend.rb +++ b/lib/appsignal/transaction/opentelemetry_backend.rb @@ -254,7 +254,10 @@ def complete # `teardown` sets `@completed`, so this guard also makes the body # idempotent across a double `complete`, and skips it on `discard`. unless @completed - emit_queue_duration_metric + # The queue metric is only emitted for a transaction that set an + # action to group by. An actionless transaction is never reported in + # agent mode, so it must contribute to no aggregate here either. + emit_queue_duration_metric if @action_set ignore_subtrace_without_action end teardown @@ -308,17 +311,11 @@ def teardown @span&.finish end - # An action name is required for performance monitoring, and a transaction - # that never set one has nothing to group by. Agent mode simply does not - # report such a transaction (e.g. a static-asset or otherwise unrouted - # request). Collector mode can't represent "no name": the root span keeps - # the placeholder name it was created with (`appsignal.transaction - # `), so without this every actionless request would surface - # under that shared placeholder action. Mirror agent mode by flagging the - # subtrace so the collector drops it, exactly as `discard` does. The flag - # must be set before `teardown` finishes the span, since attributes set on - # an ended span are dropped. This is orthogonal to the queue-duration - # metric above, which is a namespace-level signal on its own stream. + # A transaction that never set an action has nothing to group by, and agent + # mode does not report one at all. Collector mode cannot represent "no + # action", so the subtrace is flagged for the collector to drop instead, + # the same way `discard` does. The flag has to be set before `teardown` + # finishes the span, because attributes set on an ended span are dropped. def ignore_subtrace_without_action return if @action_set diff --git a/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb b/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb index 03df7e6a2..26af788ac 100644 --- a/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb +++ b/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb @@ -337,6 +337,7 @@ def event_span_for(category) it "emits the queue duration metric in two series on completion" do backend = create_backend("background_job") + backend.set_action("BackgroundJob#perform") start_time = backend.instance_variable_get(:@start_time) queue_start = ((start_time.to_f * 1000) - 5_000).round @@ -352,9 +353,20 @@ def event_span_for(category) backend.complete end + it "does not emit the queue duration metric when no action was set" do + expect(metrics).to_not receive(:add_distribution_value) + backend = create_backend("background_job") + start_time = backend.instance_variable_get(:@start_time) + queue_start = ((start_time.to_f * 1000) - 5_000).round + + backend.set_queue_start(queue_start) + backend.complete + end + it "ignores values below the epoch-ms floor" do expect(metrics).to_not receive(:add_distribution_value) backend = create_backend + backend.set_action("PagesController#show") backend.set_queue_start(10) backend.complete diff --git a/spec/lib/appsignal/transaction_spec.rb b/spec/lib/appsignal/transaction_spec.rb index b2c8ca1e3..d13c58c50 100644 --- a/spec/lib/appsignal/transaction_spec.rb +++ b/spec/lib/appsignal/transaction_spec.rb @@ -2835,6 +2835,9 @@ def perform it "in collector mode", :collector_mode do start_collector_agent + # An action is required for the aggregate metric to be emitted; an + # actionless transaction contributes to no metric in collector mode. + transaction.set_action("PagesController#show") # An epoch-ms timestamp shortly before the transaction started, so the # duration comes out to a known positive delta. start_time = transaction.backend.instance_variable_get(:@start_time) From 1d522b97727205f955f1cc07e777ee1a7b5193b4 Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Mon, 20 Jul 2026 14:03:13 +0200 Subject: [PATCH 29/69] Report allocation counts in collector mode Agent mode tracks how many Ruby objects a transaction and its events allocate, and collector mode did not report it. A thread-local counter in the C extension is read through `Appsignal::Extension.allocation_count`, and the backend snapshots it at the start of the transaction and each event and subtracts at their finish. Every span carries `appsignal.self_allocation_count`, the allocations it made excluding its children, so a consumer gets the per-layer breakdown without walking the span tree. Event spans also carry `appsignal.allocation_count` for their whole subtree, and the root span's total is `appsignal.transaction_allocation_count`, which is also emitted as a counter metric. The counter is thread-local, so a transaction or event that finishes on a different thread than it started on reads a lower value than its start. The count is dropped and a warning logged rather than reporting a wrong number. This follows the existing `enable_allocation_tracking` option and, like agent mode, is not available on JRuby. --- ext/appsignal_extension.c | 14 + lib/appsignal/extension.rb | 4 + .../transaction/opentelemetry_backend.rb | 144 +++++++++- .../integration/collector_mode_traces_spec.rb | 14 + spec/lib/appsignal/extension_spec.rb | 12 + .../transaction/opentelemetry_backend_spec.rb | 252 ++++++++++++++++++ 6 files changed, 428 insertions(+), 12 deletions(-) diff --git a/ext/appsignal_extension.c b/ext/appsignal_extension.c index 97e0c84a7..12bc81e34 100644 --- a/ext/appsignal_extension.c +++ b/ext/appsignal_extension.c @@ -834,10 +834,23 @@ static VALUE add_distribution_value(VALUE self, VALUE key, VALUE value, VALUE ta return Qnil; } +// Per-thread running total of object allocations, incremented on every Ruby +// NEWOBJ event. Thread-local because MRI maps each Ruby thread to its own OS +// thread, so this attributes allocations to the thread doing the work, which is +// the thread the transaction runs on. Collector mode reads it through +// Appsignal::Extension.allocation_count and diffs two snapshots to get the +// allocations made during a transaction or an event. +static __thread unsigned long long appsignal_thread_allocation_count = 0; + static void track_allocation(rb_event_flag_t flag, VALUE arg1, VALUE arg2, ID arg3, VALUE arg4) { + appsignal_thread_allocation_count++; appsignal_track_allocation(); } +static VALUE allocation_count(VALUE self) { + return ULL2NUM(appsignal_thread_allocation_count); +} + static VALUE install_allocation_event_hook(VALUE self) { // This event hook is only available on Ruby 2.1 and 2.2 #if defined(RUBY_INTERNAL_EVENT_NEWOBJ) @@ -956,6 +969,7 @@ void Init_appsignal_extension(void) { // Other helper methods rb_define_singleton_method(Extension, "install_allocation_event_hook", install_allocation_event_hook, 0); + rb_define_singleton_method(Extension, "allocation_count", allocation_count, 0); rb_define_singleton_method(Extension, "running_in_container?", running_in_container, 0); rb_define_singleton_method(Extension, "set_environment_metadata", set_environment_metadata, 2); diff --git a/lib/appsignal/extension.rb b/lib/appsignal/extension.rb index d8da1fa5b..498199c2d 100644 --- a/lib/appsignal/extension.rb +++ b/lib/appsignal/extension.rb @@ -46,6 +46,10 @@ def data_map_new def data_array_new Appsignal::Extension::MockData.new end + + def allocation_count + 0 + end end end diff --git a/lib/appsignal/transaction/opentelemetry_backend.rb b/lib/appsignal/transaction/opentelemetry_backend.rb index b13ed1a7e..b3e9992c0 100644 --- a/lib/appsignal/transaction/opentelemetry_backend.rb +++ b/lib/appsignal/transaction/opentelemetry_backend.rb @@ -54,6 +54,14 @@ class OpenTelemetryBackend < BaseBackend # queue duration when `queue_start_ms > 946_681_200_000`. QUEUE_START_MIN = 946_681_200_000 + # One open event on the event stack. Holds the OpenTelemetry span and the + # context token attached for it, plus the allocation bookkeeping for the + # event. `allocation_start` is the allocation counter when the event began + # (nil when allocation tracking is off), and `child_allocation_count` + # accumulates the full allocation counts of the event's finished children, + # so the event's own allocations are `full - child_allocation_count`. + EventFrame = Struct.new(:span, :token, :allocation_start, :child_allocation_count) + def initialize(transaction_id, namespace, opentelemetry_context: nil, opentelemetry_scope: nil, **) super() @@ -66,6 +74,9 @@ def initialize(transaction_id, namespace, @queue_start = nil @start_time = Time.now @action_set = false + @action = nil + @allocation_start = current_allocation_count + @root_child_allocation_count = 0 kind = SPAN_KIND_BY_NAMESPACE.fetch(namespace, DEFAULT_SPAN_KIND) @span = start_transaction_span(namespace, kind, opentelemetry_context) @@ -87,17 +98,18 @@ def start_event(opentelemetry_kind: nil, opentelemetry_scope: nil) token = ::OpenTelemetry::Context.attach( ::OpenTelemetry::Trace.context_with_span(span) ) - @event_stack.push([span, token]) + push_event(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 + frame = @event_stack.pop + write_event_name_attributes(frame.span, name, title) + write_event_body_attributes(frame.span, body, body_format) + write_event_allocation_count(frame) + ::OpenTelemetry::Context.detach(frame.token) + frame.span.finish end # `opentelemetry_kind` is set at span creation (kind is immutable in OTel), @@ -114,6 +126,10 @@ def record_event( # rubocop:disable Metrics/ParameterLists ) write_event_name_attributes(span, name, title) write_event_body_attributes(span, body, body_format) + # A recorded event has no start hook, so we never measured its + # allocations. We deliberately set no allocation attribute rather than a + # misleading zero. Its allocations instead fall into the enclosing + # event's own count, matching agent mode. span.finish end @@ -123,6 +139,7 @@ def set_action(action) # the collector treats the span name as authoritative for display. @span.name = action @span.set_attribute("appsignal.action_name", action) + @action = action @action_set = true end @@ -254,10 +271,11 @@ def complete # `teardown` sets `@completed`, so this guard also makes the body # idempotent across a double `complete`, and skips it on `discard`. unless @completed - # The queue metric is only emitted for a transaction that set an + # Aggregate metrics are only emitted for a transaction that set an # action to group by. An actionless transaction is never reported in # agent mode, so it must contribute to no aggregate here either. emit_queue_duration_metric if @action_set + report_allocation_count ignore_subtrace_without_action end teardown @@ -303,9 +321,9 @@ def teardown # 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 + frame = @event_stack.pop + ::OpenTelemetry::Context.detach(frame.token) + frame.span.finish end ::OpenTelemetry::Context.detach(@context_token) if @context_token @span&.finish @@ -341,6 +359,103 @@ def emit_queue_duration_metric ) end + # Sets the transaction's allocation counts on the root span, and, when the + # transaction has an action to group by, emits the total as a counter + # metric. The counter is read once so the attributes and the metric share + # the same value. + # + # The root's total is `appsignal.transaction_allocation_count`, named apart + # from an event's `appsignal.allocation_count` because it resets per + # transaction: it is the whole that a span's `self_allocation_count` is a + # part of, including across a distributed trace. + # + # The metric is emitted in both the per-namespace and + # per-namespace-and-action series the allocation graph reads, as a counter, + # never host-tagged. Nothing downstream fans these out. + def report_allocation_count + return unless @allocation_start + + count = Appsignal::Extension.allocation_count - @allocation_start + return if allocation_count_reversed?(count) + + @span&.set_attribute("appsignal.transaction_allocation_count", count) + @span&.set_attribute( + "appsignal.self_allocation_count", + count - @root_child_allocation_count + ) + + return unless @action_set && count.positive? + + namespace = display_namespace(@namespace) + Appsignal::Metrics::OpenTelemetryBackend.increment_counter( + "transaction_allocation_count", count, :namespace => namespace + ) + Appsignal::Metrics::OpenTelemetryBackend.increment_counter( + "transaction_allocation_count", count, + :namespace => namespace, :action => @action + ) + end + + # Sets a finished event's allocation counts and rolls its full count up to + # its parent. `appsignal.allocation_count` covers the event's whole + # subtree; `appsignal.self_allocation_count` excludes its children, so + # allocations can be attributed to a layer without walking the span tree. + # + # Only the immediate parent is updated, because each event's full count + # already includes its whole subtree. Nothing is set when allocation + # tracking is off. + def write_event_allocation_count(frame) + return unless frame.allocation_start + + full = Appsignal::Extension.allocation_count - frame.allocation_start + return if allocation_count_reversed?(full) + + self_count = full - frame.child_allocation_count + # Roll the full count up to the parent so it can compute its own self. + # A top-level event has no parent event; its full count belongs to the + # transaction, so credit the root accumulator instead. + if (parent = @event_stack.last) + parent.child_allocation_count += full + else + @root_child_allocation_count += full + end + frame.span.set_attribute("appsignal.allocation_count", full) + frame.span.set_attribute("appsignal.self_allocation_count", self_count) + end + + # The allocation counter is thread-local and only ever increases, so a + # negative delta means the transaction or event finished on a different + # thread than it started on. The count is then meaningless, so warn and + # tell the caller to drop it rather than report a wrong value. + def allocation_count_reversed?(delta) + return false unless delta.negative? + + Appsignal.internal_logger.warn( + "Not reporting an allocation count in transaction " \ + "'#{@transaction_id}'. The thread-local allocation counter decreased " \ + "between the start and finish, which happens when the work starts and " \ + "finishes on different threads." + ) + true + end + + # The thread's cumulative object allocation count, or nil when allocation + # tracking is off. Callers snapshot this at a start boundary and subtract + # it from a later read to get the allocations made in between; a nil + # snapshot disables allocation reporting for that transaction or event. + def current_allocation_count + Appsignal::Extension.allocation_count if allocation_tracking? + end + + # Allocation tracking runs only when enabled by config and not on JRuby, + # matching the condition under which `Appsignal.start` installs the + # allocation event hook that feeds the counter. + def allocation_tracking? + return false unless Appsignal.config&.[](:enable_allocation_tracking) + + !Appsignal::System.jruby? + end + def hostname Appsignal.config&.[](:hostname) || Socket.gethostname end @@ -368,8 +483,13 @@ def tracer_for(scope) # 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 + @event_stack.last&.span || @span + end + + # Pushes an open event onto the stack, snapshotting the allocation counter + # so `finish_event` can measure the event's allocations as the delta since. + def push_event(span, token) + @event_stack.push(EventFrame.new(span, token, current_allocation_count, 0)) end def placeholder_span_name(namespace) diff --git a/spec/integration/collector_mode_traces_spec.rb b/spec/integration/collector_mode_traces_spec.rb index 81a314c69..f7ed90e02 100644 --- a/spec/integration/collector_mode_traces_spec.rb +++ b/spec/integration/collector_mode_traces_spec.rb @@ -45,11 +45,25 @@ # `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") + + # Allocation counts: the transaction total on the root span and a per-event + # count on each event span. The values are real allocations, so assert the + # wiring (present and non-negative) rather than exact counts. The monitored + # block always allocates, so the transaction total is positive. + expect(int_attribute_value(root, "appsignal.transaction_allocation_count")).to be > 0 + expect(int_attribute_value(root, "appsignal.self_allocation_count")).to be >= 0 + expect(int_attribute_value(sql, "appsignal.allocation_count")).to be >= 0 + expect(int_attribute_value(sql, "appsignal.self_allocation_count")).to be >= 0 end def attribute_value(span, key) pair = span.attributes.find { |attr| attr.key == key } pair&.value&.string_value end + + def int_attribute_value(span, key) + pair = span.attributes.find { |attr| attr.key == key } + pair&.value&.int_value + end end end diff --git a/spec/lib/appsignal/extension_spec.rb b/spec/lib/appsignal/extension_spec.rb index b75baffeb..3c9e4b410 100644 --- a/spec/lib/appsignal/extension_spec.rb +++ b/spec/lib/appsignal/extension_spec.rb @@ -12,6 +12,18 @@ it { is_expected.to be_kind_of(String) } end + # Allocation tracking is MRI-only: the JRuby extension defines neither the + # allocation event hook nor this counter, so the method only exists here. + describe ".allocation_count", :if => !DependencyHelper.running_jruby? do + subject { Appsignal::Extension.allocation_count } + + # The counter only climbs once the allocation event hook is installed, which + # is a process-wide side effect avoided here. This checks the getter is + # wired; the climbing behavior is covered end-to-end by the collector-mode + # trace integration spec. + it { is_expected.to be_kind_of(Integer) } + end + context "when the extension library can be loaded" do subject { Appsignal::Extension } diff --git a/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb b/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb index 26af788ac..07f2d7f86 100644 --- a/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb +++ b/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb @@ -375,6 +375,258 @@ def event_span_for(category) end end + describe "allocation counts" do + let(:metrics) { Appsignal::Metrics::OpenTelemetryBackend } + + before do + configure(:options => { :enable_allocation_tracking => true }) + allow(metrics).to receive(:increment_counter) + # Drive the thread's allocation counter by hand so deltas are exact. + @allocations = 0 + allow(Appsignal::Extension).to receive(:allocation_count) { @allocations } + end + + def event_span(category) + span_exporter.finished_spans.find { |s| s.attributes["appsignal.category"] == category } + end + + it "sets the transaction total on the root span from the delta since start" do + @allocations = 100 + backend = create_backend + backend.set_action("PagesController#show") + @allocations = 450 + span = backend.instance_variable_get(:@span) + backend.complete + + attributes = finished_span(span).attributes + expect(attributes["appsignal.transaction_allocation_count"]).to eq(350) + # No events, so all of it is the transaction's own self. + expect(attributes["appsignal.self_allocation_count"]).to eq(350) + end + + it "sets the root self to the transaction's allocations outside any event" do + @allocations = 100 + backend = create_backend + backend.set_action("PagesController#show") + @allocations = 130 # 30 before the event + backend.start_event + @allocations = 175 # 45 inside the event + backend.finish_event("sql.query", "SQL", "SELECT 1", 0) + @allocations = 200 # 25 after the event + span = backend.instance_variable_get(:@span) + backend.complete + + attributes = finished_span(span).attributes + # Total 100 -> 200 = 100; the event took 45, so 55 happened outside it. + expect(attributes["appsignal.transaction_allocation_count"]).to eq(100) + expect(attributes["appsignal.self_allocation_count"]).to eq(55) + end + + it "sets a childless event's full and self counts to the delta over the event" do + @allocations = 100 + backend = create_backend + @allocations = 130 + backend.start_event + @allocations = 175 + backend.finish_event("sql.query", "SQL", "SELECT 1", 0) + + attributes = event_span("sql.query").attributes + expect(attributes["appsignal.allocation_count"]).to eq(45) + expect(attributes["appsignal.self_allocation_count"]).to eq(45) + end + + it "excludes a child event's allocations from the parent's self count" do + @allocations = 100 + backend = create_backend + @allocations = 110 + backend.start_event # outer + @allocations = 130 + backend.start_event # inner + @allocations = 175 + backend.finish_event("sql.query", "SQL", "SELECT 1", 0) + @allocations = 200 + backend.finish_event("template.render", "Render", "", 0) + + inner = event_span("sql.query").attributes + outer = event_span("template.render").attributes + # Inner: full == self == 45 (no children). + expect(inner["appsignal.allocation_count"]).to eq(45) + expect(inner["appsignal.self_allocation_count"]).to eq(45) + # Outer: full 90 covers the inner event; self 45 excludes it. + expect(outer["appsignal.allocation_count"]).to eq(90) + expect(outer["appsignal.self_allocation_count"]).to eq(45) + end + + it "sets no allocation attribute for a recorded event" do + backend = create_backend + backend.record_event("sql.query", "SQL", "SELECT 1", 0, 1_000_000) + + attributes = event_span("sql.query").attributes + expect(attributes).to_not have_key("appsignal.allocation_count") + expect(attributes).to_not have_key("appsignal.self_allocation_count") + end + + # Exercises a tree deeper and wider than a single parent-child: three levels + # of nesting, two siblings, allocations by the parent between its children, + # and a recorded event whose allocations must fall into the enclosing event. + # A two-level test can't catch a parent credited with a child's self instead + # of its full count, because a childless child has self == full; here `e2` + # has its own child, so the two differ. + # + # transaction root (start 0) + # e1 (start 10) + # e2 (start 15) + # e3 full 15, self 15 (20 -> 35) + # e2 own work: 5 before + 7 after e3 -> self 12, full 27 + # r (recorded): its 8 allocs stay in e1 (42 -> 50) + # e4 full 12, self 12 (58 -> 70) + # e1 own: 5 + 8 (r) + 8 + 10 = 31 self, full 70 + # 10 allocs happen before e1 starts, so the transaction total is 80. + it "computes self correctly across a deep, wide tree with a recorded event" do + @allocations = 0 + backend = create_backend + backend.set_action("PagesController#show") + + @allocations = 10 + backend.start_event # e1 + @allocations = 15 + backend.start_event # e2 + @allocations = 20 + backend.start_event # e3 + @allocations = 35 + backend.finish_event("e3", "e3", "", 0) + @allocations = 42 + backend.finish_event("e2", "e2", "", 0) + @allocations = 50 # e1's own allocations, recorded below (must not be excluded) + backend.record_event("r", "r", "", 0, 1_000_000) + @allocations = 58 + backend.start_event # e4 + @allocations = 70 + backend.finish_event("e4", "e4", "", 0) + @allocations = 80 + backend.finish_event("e1", "e1", "", 0) + + span = backend.instance_variable_get(:@span) + backend.complete + + # [full, self] for an event span, by category name. + counts = lambda do |category| + attributes = event_span(category).attributes + [attributes["appsignal.allocation_count"], attributes["appsignal.self_allocation_count"]] + end + + expect(counts.call("e3")).to eq([15, 15]) + expect(counts.call("e2")).to eq([27, 12]) + expect(counts.call("e4")).to eq([12, 12]) + # e1's self (31) includes the recorded event's 8 allocations and excludes + # both e2 (27, which itself includes e3) and e4 (12). + expect(counts.call("e1")).to eq([70, 31]) + + expect(event_span("r").attributes).to_not have_key("appsignal.allocation_count") + root = finished_span(span).attributes + # Transaction total spans everything, including the 10 allocations before e1. + expect(root["appsignal.transaction_allocation_count"]).to eq(80) + # Root self excludes e1 (the only top-level event, full 70), leaving the 10 + # allocations that happened before any event started. + expect(root["appsignal.self_allocation_count"]).to eq(10) + end + + it "emits the allocation count metric in two series when an action was set" do + @allocations = 100 + backend = create_backend + backend.set_action("PagesController#show") + @allocations = 300 + + expect(metrics).to receive(:increment_counter).with( + "transaction_allocation_count", 200, :namespace => "web" + ) + expect(metrics).to receive(:increment_counter).with( + "transaction_allocation_count", 200, + :namespace => "web", :action => "PagesController#show" + ) + + backend.complete + end + + it "does not emit the allocation count metric when no action was set" do + @allocations = 100 + backend = create_backend + @allocations = 300 + expect(metrics).to_not receive(:increment_counter) + + backend.complete + end + + it "does not emit the allocation count metric when the delta is zero" do + @allocations = 100 + backend = create_backend + backend.set_action("PagesController#show") + expect(metrics).to_not receive(:increment_counter) + + backend.complete + end + + it "does not emit the allocation count metric when discarded" do + @allocations = 100 + backend = create_backend + backend.set_action("PagesController#show") + @allocations = 300 + expect(metrics).to_not receive(:increment_counter) + + backend.discard + end + + # The counter is thread-local and only climbs, so a lower value at finish + # than at start means the work moved threads. The delta is meaningless. + it "drops an event's allocation counts and warns when the counter reversed" do + @allocations = 200 + backend = create_backend + @allocations = 250 + backend.start_event + @allocations = 100 # finished on another thread: counter went backwards + logs = capture_logs { backend.finish_event("sql.query", "SQL", "SELECT 1", 0) } + + attributes = event_span("sql.query").attributes + expect(attributes).to_not have_key("appsignal.allocation_count") + expect(attributes).to_not have_key("appsignal.self_allocation_count") + expect(logs).to include("allocation counter decreased") + end + + it "drops the transaction allocation counts and warns when the counter reversed" do + @allocations = 500 + backend = create_backend + backend.set_action("PagesController#show") + @allocations = 100 # finished on another thread: counter went backwards + span = backend.instance_variable_get(:@span) + expect(metrics).to_not receive(:increment_counter) + + logs = capture_logs { backend.complete } + + attributes = finished_span(span).attributes + expect(attributes).to_not have_key("appsignal.transaction_allocation_count") + expect(attributes).to_not have_key("appsignal.self_allocation_count") + expect(logs).to include("allocation counter decreased") + end + + context "when allocation tracking is disabled" do + before { configure(:options => { :enable_allocation_tracking => false }) } + + it "sets no allocation attributes and emits no metric" do + @allocations = 100 + backend = create_backend + backend.set_action("PagesController#show") + @allocations = 300 + span = backend.instance_variable_get(:@span) + expect(metrics).to_not receive(:increment_counter) + + backend.complete + + expect(finished_span(span).attributes) + .to_not have_key("appsignal.transaction_allocation_count") + end + end + end + describe "#set_action" do it "renames the root span to the action" do backend = create_backend From 2cabd150d8c88b28a213f153115deacab27d58dd Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Wed, 22 Jul 2026 12:30:46 +0200 Subject: [PATCH 30/69] Use a clearer placeholder for unfinished events When an event span is started but never finished, `complete` drains it with a placeholder name. That name was `appsignal.event`, which looks like a real event category and is easy to mistake for one in a trace. It is now `[unfinished transaction event]`, where the brackets and the wording make clear it is synthetic. --- lib/appsignal/transaction/opentelemetry_backend.rb | 10 ++++++---- .../transaction/opentelemetry_backend_spec.rb | 2 +- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/lib/appsignal/transaction/opentelemetry_backend.rb b/lib/appsignal/transaction/opentelemetry_backend.rb index b3e9992c0..8c43dab32 100644 --- a/lib/appsignal/transaction/opentelemetry_backend.rb +++ b/lib/appsignal/transaction/opentelemetry_backend.rb @@ -39,10 +39,12 @@ class OpenTelemetryBackend < BaseBackend }.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" + # `finish_event`. `finish_event` overwrites it with the AS::N event name, + # so it only surfaces when `complete` has to drain a span that was started + # but never finished. It is deliberately an obvious placeholder rather + # than a plausible event name, so such a span reads as the unfinished + # event it is and is not mistaken for a real one. + EVENT_SPAN_PLACEHOLDER_NAME = "[unfinished transaction event]" # Sentinel value the AppSignal collector recognizes as "a SQL system # we don't know the specific dialect of" — sufficient to trigger SQL diff --git a/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb b/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb index 07f2d7f86..135b6885b 100644 --- a/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb +++ b/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb @@ -1171,7 +1171,7 @@ def new_transaction_with_otel_backend(namespace = Appsignal::Transaction::HTTP_R # 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) + expect(names.count("[unfinished transaction event]")).to eq(2) end end end From 7e227c8f9b06a3fcd54550989dd08959fd5f350d Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Wed, 22 Jul 2026 18:41:37 +0200 Subject: [PATCH 31/69] Set transaction span kind and relationship In collector mode the transaction backend derived the OpenTelemetry span kind from the namespace, and derived whether an incoming trace context was used as a parent or a link from that kind. Both are now explicit arguments on `Transaction.create` and on the `monitor`, `send_error` and `report_error` helpers. The kind is `:server`, `:consumer`, `:producer` or `:internal`, and defaults to `:server`. The relationship is `:parent`, `:link`, `:both` or `:none`, and defaults to `:parent`. The helpers also accept an `opentelemetry_context`, so the relationship has a context to act on. Every integration passes what it was getting by derivation, so no behaviour changes. --- lib/appsignal/helpers/instrumentation.rb | 68 +++++++- lib/appsignal/hooks/active_job.rb | 4 +- .../integrations/delayed_job_plugin.rb | 4 +- lib/appsignal/integrations/que.rb | 4 +- lib/appsignal/integrations/resque.rb | 4 +- lib/appsignal/integrations/shoryuken.rb | 4 +- lib/appsignal/integrations/sidekiq.rb | 8 +- lib/appsignal/transaction.rb | 35 +++- .../transaction/extension_backend.rb | 21 ++- .../transaction/opentelemetry_backend.rb | 154 ++++++++++++++---- sig/appsignal.rbi | 116 +++++++++++-- sig/appsignal.rbs | 100 +++++++++++- .../transaction/opentelemetry_backend_spec.rb | 142 ++++++++++++---- spec/lib/appsignal/transaction_spec.rb | 61 ++++--- spec/lib/appsignal_spec.rb | 94 +++++++++++ 15 files changed, 696 insertions(+), 123 deletions(-) diff --git a/lib/appsignal/helpers/instrumentation.rb b/lib/appsignal/helpers/instrumentation.rb index a0d0f5e87..90cfbec84 100644 --- a/lib/appsignal/helpers/instrumentation.rb +++ b/lib/appsignal/helpers/instrumentation.rb @@ -102,6 +102,14 @@ module Instrumentation # within the block with {#set_action}. # This will not update the active transaction's action if # {.monitor} is called when another transaction is already active. + # @param opentelemetry_kind [Symbol] In collector mode, the OpenTelemetry + # span kind: one of `:server`, `:consumer`, `:producer` or `:internal`. + # Defaults to `:server`. + # @param opentelemetry_relationship [Symbol] In collector mode, how an + # incoming `opentelemetry_context` relates to this transaction's span: + # one of `:parent`, `:link`, `:both` or `:none`. Defaults to `:parent`. + # @param opentelemetry_context In collector mode, an incoming OpenTelemetry + # trace context to relate this transaction's span to. # @yield [] The block to monitor. # @yieldreturn [Object] The return value of the block # @raise [Exception] Any exception that occurs within the given block is @@ -112,7 +120,14 @@ module Instrumentation # # @see https://docs.appsignal.com/ruby/instrumentation/background-jobs.html # Monitor guide - def monitor(action:, namespace: nil, opentelemetry_scope: nil) + def monitor( # rubocop:disable Metrics/ParameterLists + action:, + namespace: nil, + opentelemetry_context: nil, + opentelemetry_scope: nil, + opentelemetry_kind: nil, + opentelemetry_relationship: nil + ) return yield unless Appsignal.active? has_parent_transaction = Appsignal::Transaction.current? @@ -135,7 +150,10 @@ def monitor(action:, namespace: nil, opentelemetry_scope: nil) else Appsignal::Transaction.create( namespace || Appsignal::Transaction::HTTP_REQUEST, - :opentelemetry_scope => opentelemetry_scope + :opentelemetry_context => opentelemetry_context, + :opentelemetry_scope => opentelemetry_scope, + :opentelemetry_kind => opentelemetry_kind, + :opentelemetry_relationship => opentelemetry_relationship ) end @@ -226,6 +244,14 @@ def monitor_and_stop(action:, namespace: nil, opentelemetry_scope: nil, &block) # # @since 0.6.0 # @param error [Exception] The error to send to AppSignal. + # @param opentelemetry_kind [Symbol] In collector mode, the OpenTelemetry + # span kind: one of `:server`, `:consumer`, `:producer` or `:internal`. + # Defaults to `:server`. + # @param opentelemetry_relationship [Symbol] In collector mode, how an + # incoming `opentelemetry_context` relates to this transaction's span: + # one of `:parent`, `:link`, `:both` or `:none`. Defaults to `:parent`. + # @param opentelemetry_context In collector mode, an incoming OpenTelemetry + # trace context to relate this transaction's span to. # @yield [transaction] yields block to allow modification of the # transaction before it's send. # @yieldparam transaction [Transaction] yields the AppSignal transaction @@ -234,7 +260,14 @@ def monitor_and_stop(action:, namespace: nil, opentelemetry_scope: nil, &block) # # @see https://docs.appsignal.com/ruby/instrumentation/exception-handling.html # Exception handling guide - def send_error(error, opentelemetry_scope: nil, &block) + def send_error( + error, + opentelemetry_context: nil, + opentelemetry_scope: nil, + opentelemetry_kind: nil, + opentelemetry_relationship: nil, + &block + ) return unless Appsignal.active? unless error.is_a?(Exception) @@ -247,7 +280,10 @@ def send_error(error, opentelemetry_scope: nil, &block) transaction = Appsignal::Transaction.new( Appsignal::Transaction::HTTP_REQUEST, - :opentelemetry_scope => opentelemetry_scope + :opentelemetry_context => opentelemetry_context, + :opentelemetry_scope => opentelemetry_scope, + :opentelemetry_kind => opentelemetry_kind, + :opentelemetry_relationship => opentelemetry_relationship ) transaction.set_error(error, :source => "Appsignal.send_error", &block) @@ -359,6 +395,16 @@ def set_error(exception) # @since 4.0.0 # @param exception [Exception] The error to add to the current # transaction. + # @param opentelemetry_kind [Symbol] In collector mode, the OpenTelemetry + # span kind: one of `:server`, `:consumer`, `:producer` or `:internal`. + # Defaults to `:server`. Only used when a new transaction is created. + # @param opentelemetry_relationship [Symbol] In collector mode, how an + # incoming `opentelemetry_context` relates to this transaction's span: + # one of `:parent`, `:link`, `:both` or `:none`. Defaults to `:parent`. + # Only used when a new transaction is created. + # @param opentelemetry_context In collector mode, an incoming OpenTelemetry + # trace context to relate this transaction's span to. Only used when a + # new transaction is created. # @yield [transaction] yields block to allow modification of the # transaction. # @yieldparam transaction [Transaction] yields the AppSignal transaction @@ -367,7 +413,14 @@ def set_error(exception) # # @see https://docs.appsignal.com/ruby/instrumentation/exception-handling.html # Exception handling guide - def report_error(exception, opentelemetry_scope: nil, &block) + def report_error( + exception, + opentelemetry_context: nil, + opentelemetry_scope: nil, + opentelemetry_kind: nil, + opentelemetry_relationship: nil, + &block + ) unless exception.is_a?(Exception) Appsignal.internal_logger.error "Appsignal.report_error: " \ "Cannot add error. " \ @@ -383,7 +436,10 @@ def report_error(exception, opentelemetry_scope: nil, &block) else Appsignal::Transaction.new( Appsignal::Transaction::HTTP_REQUEST, - :opentelemetry_scope => opentelemetry_scope + :opentelemetry_context => opentelemetry_context, + :opentelemetry_scope => opentelemetry_scope, + :opentelemetry_kind => opentelemetry_kind, + :opentelemetry_relationship => opentelemetry_relationship ) end diff --git a/lib/appsignal/hooks/active_job.rb b/lib/appsignal/hooks/active_job.rb index 35a90bea1..4797c8352 100644 --- a/lib/appsignal/hooks/active_job.rb +++ b/lib/appsignal/hooks/active_job.rb @@ -78,7 +78,9 @@ def execute(job) Appsignal::Transaction.create( Appsignal::Transaction::BACKGROUND_JOB, :opentelemetry_context => Appsignal::OpenTelemetry.extract_job_context(job), - :opentelemetry_scope => ["appsignal-ruby/active_job", Appsignal::VERSION] + :opentelemetry_scope => ["appsignal-ruby/active_job", Appsignal::VERSION], + :opentelemetry_kind => :consumer, + :opentelemetry_relationship => :link ) end diff --git a/lib/appsignal/integrations/delayed_job_plugin.rb b/lib/appsignal/integrations/delayed_job_plugin.rb index e371d028e..d21a14845 100644 --- a/lib/appsignal/integrations/delayed_job_plugin.rb +++ b/lib/appsignal/integrations/delayed_job_plugin.rb @@ -60,7 +60,9 @@ def self.invoke_with_instrumentation(job, block) transaction = Appsignal::Transaction.create( Appsignal::Transaction::BACKGROUND_JOB, - :opentelemetry_scope => ["appsignal-ruby/delayed_job", Appsignal::VERSION] + :opentelemetry_scope => ["appsignal-ruby/delayed_job", Appsignal::VERSION], + :opentelemetry_kind => :consumer, + :opentelemetry_relationship => :link ) begin diff --git a/lib/appsignal/integrations/que.rb b/lib/appsignal/integrations/que.rb index 770c8d318..1126411b6 100644 --- a/lib/appsignal/integrations/que.rb +++ b/lib/appsignal/integrations/que.rb @@ -84,7 +84,9 @@ def _run(*args) Appsignal::Transaction.create( Appsignal::Transaction::BACKGROUND_JOB, :opentelemetry_context => QueTraceContext.extract(local_attrs.dig(:data, :tags)), - :opentelemetry_scope => ["appsignal-ruby/que", Appsignal::VERSION] + :opentelemetry_scope => ["appsignal-ruby/que", Appsignal::VERSION], + :opentelemetry_kind => :consumer, + :opentelemetry_relationship => :link ) begin diff --git a/lib/appsignal/integrations/resque.rb b/lib/appsignal/integrations/resque.rb index 5bdcc5602..bf855e4a4 100644 --- a/lib/appsignal/integrations/resque.rb +++ b/lib/appsignal/integrations/resque.rb @@ -10,7 +10,9 @@ def perform transaction = Appsignal::Transaction.create( Appsignal::Transaction::BACKGROUND_JOB, :opentelemetry_context => Appsignal::OpenTelemetry.extract_job_context(payload), - :opentelemetry_scope => ["appsignal-ruby/resque", Appsignal::VERSION] + :opentelemetry_scope => ["appsignal-ruby/resque", Appsignal::VERSION], + :opentelemetry_kind => :consumer, + :opentelemetry_relationship => :link ) Appsignal.instrument( diff --git a/lib/appsignal/integrations/shoryuken.rb b/lib/appsignal/integrations/shoryuken.rb index b3d0a063e..284d54c64 100644 --- a/lib/appsignal/integrations/shoryuken.rb +++ b/lib/appsignal/integrations/shoryuken.rb @@ -80,7 +80,9 @@ def call(worker_instance, queue, sqs_msg, body, &block) transaction = Appsignal::Transaction.create( Appsignal::Transaction::BACKGROUND_JOB, :opentelemetry_context => context, - :opentelemetry_scope => ["appsignal-ruby/shoryuken", Appsignal::VERSION] + :opentelemetry_scope => ["appsignal-ruby/shoryuken", Appsignal::VERSION], + :opentelemetry_kind => :consumer, + :opentelemetry_relationship => :link ) Appsignal.instrument( diff --git a/lib/appsignal/integrations/sidekiq.rb b/lib/appsignal/integrations/sidekiq.rb index 182924786..c0441ee9d 100644 --- a/lib/appsignal/integrations/sidekiq.rb +++ b/lib/appsignal/integrations/sidekiq.rb @@ -40,7 +40,9 @@ def call(exception, sidekiq_context, _sidekiq_config = nil) # Sidekiq. transaction = Appsignal::Transaction.create( Appsignal::Transaction::BACKGROUND_JOB, - :opentelemetry_scope => ["appsignal-ruby/sidekiq", Appsignal::VERSION] + :opentelemetry_scope => ["appsignal-ruby/sidekiq", Appsignal::VERSION], + :opentelemetry_kind => :consumer, + :opentelemetry_relationship => :link ) transaction.set_action_if_nil("SidekiqInternal") transaction.set_metadata("sidekiq_error", sidekiq_context[:context]) @@ -159,7 +161,9 @@ def call(_worker, item, _queue, &block) transaction = Appsignal::Transaction.create( Appsignal::Transaction::BACKGROUND_JOB, :opentelemetry_context => Appsignal::OpenTelemetry.extract_job_context(item), - :opentelemetry_scope => ["appsignal-ruby/sidekiq", Appsignal::VERSION] + :opentelemetry_scope => ["appsignal-ruby/sidekiq", Appsignal::VERSION], + :opentelemetry_kind => :consumer, + :opentelemetry_relationship => :link ) transaction.set_action_if_nil(action_name) diff --git a/lib/appsignal/transaction.rb b/lib/appsignal/transaction.rb index bcf464b4b..d59403aa2 100644 --- a/lib/appsignal/transaction.rb +++ b/lib/appsignal/transaction.rb @@ -28,8 +28,22 @@ class << self # transaction. # # @param namespace [String] Namespace of the to be created transaction. + # @param opentelemetry_kind [Symbol] In collector mode, the OpenTelemetry + # span kind: one of `:server`, `:consumer`, `:producer` or `:internal`. + # Defaults to `:server`. + # @param opentelemetry_relationship [Symbol] In collector mode, how an + # incoming `opentelemetry_context` relates to this transaction's span: + # one of `:parent`, `:link`, `:both` or `:none`. Defaults to `:parent`. + # @param opentelemetry_context In collector mode, an incoming OpenTelemetry + # trace context to relate this transaction's span to. # @return [Transaction] - def create(namespace, opentelemetry_context: nil, opentelemetry_scope: nil) + def create( + namespace, + opentelemetry_context: nil, + opentelemetry_scope: nil, + opentelemetry_kind: nil, + opentelemetry_relationship: nil + ) # Reset the transaction if it was already completed but not cleared if Thread.current[:appsignal_transaction]&.completed? Thread.current[:appsignal_transaction] = nil @@ -41,7 +55,9 @@ def create(namespace, opentelemetry_context: nil, opentelemetry_scope: nil) Appsignal::Transaction.new( namespace, :opentelemetry_context => opentelemetry_context, - :opentelemetry_scope => opentelemetry_scope + :opentelemetry_scope => opentelemetry_scope, + :opentelemetry_kind => opentelemetry_kind, + :opentelemetry_relationship => opentelemetry_relationship ) ) else @@ -166,8 +182,15 @@ def last_errors # @param namespace [String] Namespace of the to be created transaction. # @see create # @!visibility private - def initialize(namespace, id: SecureRandom.uuid, backend: nil, - opentelemetry_context: nil, opentelemetry_scope: nil) + def initialize( # rubocop:disable Metrics/ParameterLists + namespace, + id: SecureRandom.uuid, + backend: nil, + opentelemetry_context: nil, + opentelemetry_scope: nil, + opentelemetry_kind: nil, + opentelemetry_relationship: nil + ) @transaction_id = id @action = nil @namespace = namespace @@ -199,7 +222,9 @@ def initialize(namespace, id: SecureRandom.uuid, backend: nil, @transaction_id, @namespace, :opentelemetry_context => opentelemetry_context, - :opentelemetry_scope => opentelemetry_scope + :opentelemetry_scope => opentelemetry_scope, + :opentelemetry_kind => opentelemetry_kind, + :opentelemetry_relationship => opentelemetry_relationship ) run_after_create_hooks diff --git a/lib/appsignal/transaction/extension_backend.rb b/lib/appsignal/transaction/extension_backend.rb index 46cf74045..9aae1720f 100644 --- a/lib/appsignal/transaction/extension_backend.rb +++ b/lib/appsignal/transaction/extension_backend.rb @@ -20,17 +20,28 @@ class ExtensionBackend < BaseBackend # @!visibility private attr_writer :breadcrumbs - # `opentelemetry_context` is an incoming trace context and - # `opentelemetry_scope` an instrumentation scope, both used only in - # collector mode; agent mode has no notion of either, so they're ignored - # here. - def initialize(transaction_id, namespace, handle: nil, opentelemetry_context: nil, opentelemetry_scope: nil) # rubocop:disable Lint/UnusedMethodArgument, Layout/LineLength + # The `opentelemetry_*` keyword arguments (context, scope, kind and + # relationship) shape the OpenTelemetry span in collector mode. Agent mode + # has no notion of them, so they are accepted and ignored. They are listed + # explicitly, rather than swallowed with `**`, so an unexpected keyword + # still raises, matching the OpenTelemetry backend. + # rubocop:disable Metrics/ParameterLists, Lint/UnusedMethodArgument + def initialize( + transaction_id, + namespace, + handle: nil, + opentelemetry_context: nil, + opentelemetry_scope: nil, + opentelemetry_kind: nil, + opentelemetry_relationship: nil + ) super() @handle = handle || Appsignal::Extension.start_transaction(transaction_id, namespace, 0) || Appsignal::Extension::MockTransaction.new @breadcrumbs = [] end + # rubocop:enable Metrics/ParameterLists, Lint/UnusedMethodArgument # Agent mode has no span kind or instrumentation scope; # `opentelemetry_kind` and `opentelemetry_scope` are ignored here. diff --git a/lib/appsignal/transaction/opentelemetry_backend.rb b/lib/appsignal/transaction/opentelemetry_backend.rb index 8c43dab32..d194cc47c 100644 --- a/lib/appsignal/transaction/opentelemetry_backend.rb +++ b/lib/appsignal/transaction/opentelemetry_backend.rb @@ -14,8 +14,42 @@ class Transaction class OpenTelemetryBackend < BaseBackend TRACER_NAME = "appsignal-ruby" - # Keys correspond to `Appsignal::Transaction::HTTP_REQUEST`, - # `ACTION_CABLE` and `BACKGROUND_JOB` respectively. Spelled as strings + # Guards the process-wide warn-once state, which transactions touch + # concurrently on threaded servers. A constant so it is created once at + # load time rather than lazily (which would race). + WARN_ONCE_LOCK = Mutex.new + + class << self + # Logs the block's message the first time it sees `key`, then stays quiet + # for that key for the rest of the process. Used for warnings that would + # otherwise repeat on every transaction. The message is built lazily, so + # a deduplicated call skips building it. The check-and-set is locked so + # concurrent transactions cannot both warn. + def warn_once(key) + first_time = WARN_ONCE_LOCK.synchronize do + next false if warned_keys.key?(key) + + warned_keys[key] = true + end + Appsignal.internal_logger.warn(yield) if first_time + end + + # @!visibility private + # Resets the warn-once state. Only used to keep test runs isolated. + def clear_warned! + WARN_ONCE_LOCK.synchronize { warned_keys.clear } + end + + private + + def warned_keys + @warned_keys ||= {} + end + end + + # Maps an internal namespace to the params attribute it uses. Message/job + # (CONSUMER-kind) namespaces use the function-parameters channel; + # everything else uses the request-payload channel. 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. @@ -25,11 +59,25 @@ class OpenTelemetryBackend < BaseBackend "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). + # Collector treats SERVER/CONSUMER spans as subtrace roots; SERVER is the + # safe default when no kind is given (a transaction is almost always an + # external-triggered unit of work). DEFAULT_SPAN_KIND = :server + # The span kinds a transaction may take. An unknown value would raise + # inside OpenTelemetry span creation, so it falls back to the default. + SPAN_KINDS = [:server, :consumer, :producer, :internal].freeze + + # How the transaction's span relates to an incoming OpenTelemetry context + # when none is given. A web request continues the upstream trace, so + # parenting is the safe default. + DEFAULT_RELATIONSHIP = :parent + + # How the transaction's span may relate to an incoming context. An unknown + # value would silently behave like `:none`, so it falls back to the + # default instead. + RELATIONSHIPS = [:parent, :link, :both, :none].freeze + # The collector expects "web"/"background"; the agent's processor converts # these internal namespaces in agent mode, but nothing does in collector # mode. Other namespaces pass through unchanged. @@ -64,8 +112,14 @@ class OpenTelemetryBackend < BaseBackend # so the event's own allocations are `full - child_allocation_count`. EventFrame = Struct.new(:span, :token, :allocation_start, :child_allocation_count) - def initialize(transaction_id, namespace, - opentelemetry_context: nil, opentelemetry_scope: nil, **) + def initialize( # rubocop:disable Metrics/ParameterLists + transaction_id, + namespace, + opentelemetry_context: nil, + opentelemetry_scope: nil, + opentelemetry_kind: nil, + opentelemetry_relationship: nil + ) super() @transaction_id = transaction_id @namespace = namespace @@ -80,8 +134,14 @@ def initialize(transaction_id, namespace, @allocation_start = current_allocation_count @root_child_allocation_count = 0 - kind = SPAN_KIND_BY_NAMESPACE.fetch(namespace, DEFAULT_SPAN_KIND) - @span = start_transaction_span(namespace, kind, opentelemetry_context) + kind = validated_option( + opentelemetry_kind, SPAN_KINDS, DEFAULT_SPAN_KIND, "opentelemetry_kind" + ) + relationship = validated_option( + opentelemetry_relationship, RELATIONSHIPS, DEFAULT_RELATIONSHIP, + "opentelemetry_relationship" + ) + @span = start_transaction_span(namespace, kind, relationship, opentelemetry_context) @context_token = ::OpenTelemetry::Context.attach( ::OpenTelemetry::Trace.context_with_span(@span) ) @@ -498,35 +558,71 @@ 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: + # Open the transaction's span, relating it to any incoming trace context + # by the requested relationship: + # + # - `:parent`: parent under the remote span so the transaction continues + # the upstream trace. + # - `:link`: start a fresh trace linked back to the remote span. The + # transaction is its own unit of work decoupled from the caller, so it + # gets its own trace, with a link recording the causal relationship. + # - `:both`: parent under the remote span and also link back to it, so the + # transaction continues the trace and keeps the explicit link. + # - `:none`: a plain root span that ignores any incoming context. # - # - 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) + # With no context or an invalid remote span, every relationship falls back + # to a plain root span, since there is nothing to parent or link to. + def start_transaction_span(namespace, kind, relationship, opentelemetry_context) name = placeholder_span_name(namespace) remote = remote_span_context(opentelemetry_context) tracer = tracer_for(@scope) - 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)] - ) + # With no incoming context (or an invalid remote span) there is nothing + # to parent or link to, so any relationship is just a plain root span. + return tracer.start_root_span(name, :kind => kind) unless remote + + # `:parent` and `:both` continue the trace under the remote span; + # `:link` and `:both` record a link back to it; `:none` does neither. + parent = opentelemetry_context if [:parent, :both].include?(relationship) + links = [::OpenTelemetry::Trace::Link.new(remote)] if [:link, :both].include?(relationship) + + if parent + tracer.start_span(name, :with_parent => parent, :kind => kind, :links => links) else - tracer.start_root_span(name, :kind => kind) + tracer.start_root_span(name, :kind => kind, :links => links) end end + # Returns the given option when it is one of the allowed values, the + # default when it is nil, or the default with a warning when it is an + # unknown value. Keeps an unexpected `opentelemetry_kind` from raising + # inside span creation, and an unexpected `opentelemetry_relationship` + # from silently dropping the incoming context. + def validated_option(value, allowed, default, name) + return default if value.nil? + return value if allowed.include?(value) + + # A bad value is usually a static mistake passed on every transaction, + # so warn once per process to avoid flooding the log. Dedup on the + # option and value, and build the message -- including walking the + # stack for the caller location -- only when actually warning. + self.class.warn_once("#{name}: #{value.inspect}") do + "Unknown #{name} #{value.inspect} passed at #{option_caller_location}, " \ + "falling back to #{default.inspect}. " \ + "Expected one of: #{allowed.map(&:inspect).join(", ")}." + end + default + end + + # The first caller frame outside the gem: where the invalid value was + # passed to `Transaction.create`, `Appsignal.monitor`, etc. Falls back to + # the immediate caller if every frame is inside the gem. Only walks the + # stack when a warning is actually emitted (see `validated_option`). + def option_caller_location + frames = caller + frames.find { |frame| !frame.include?("/lib/appsignal/") } || frames.first + end + # The remote parent's SpanContext from an incoming OTel context, or nil # when there is no context or the remote span is invalid -- in which case # callers fall back to a plain root span. diff --git a/sig/appsignal.rbi b/sig/appsignal.rbi index 48497442b..1204c8ad9 100644 --- a/sig/appsignal.rbi +++ b/sig/appsignal.rbi @@ -301,6 +301,12 @@ module Appsignal # # _@param_ `action` — The action name for the transaction. The action name is required to be set for the transaction to be reported. The argument can be set to `nil` or `:set_later` if the action is set within the block with {#set_action}. This will not update the active transaction's action if {.monitor} is called when another transaction is already active. # + # _@param_ `opentelemetry_kind` — In collector mode, the OpenTelemetry span kind: one of `:server`, `:consumer`, `:producer` or `:internal`. Defaults to `:server`. + # + # _@param_ `opentelemetry_relationship` — In collector mode, how an incoming `opentelemetry_context` relates to this transaction's span: one of `:parent`, `:link`, `:both` or `:none`. Defaults to `:parent`. + # + # _@param_ `opentelemetry_context` — In collector mode, an incoming OpenTelemetry trace context to relate this transaction's span to. + # # _@return_ — The value of the given block is returned. # Returns `nil` if there already is a transaction active and no block # was given. @@ -395,11 +401,14 @@ module Appsignal params( action: T.any(String, Symbol, NilClass), namespace: T.nilable(T.any(String, Symbol)), + opentelemetry_context: T.untyped, opentelemetry_scope: T.untyped, + opentelemetry_kind: T.nilable(Symbol), + opentelemetry_relationship: T.nilable(Symbol), blk: T.proc.returns(Object) ).returns(T.nilable(Object)) end - def self.monitor(action:, namespace: nil, opentelemetry_scope: nil, &blk); end + def self.monitor(action:, namespace: nil, opentelemetry_context: nil, opentelemetry_scope: nil, opentelemetry_kind: nil, opentelemetry_relationship: nil, &blk); end # Instrument a block of code and stop AppSignal. # @@ -443,6 +452,12 @@ module Appsignal # # _@param_ `error` — The error to send to AppSignal. # + # _@param_ `opentelemetry_kind` — In collector mode, the OpenTelemetry span kind: one of `:server`, `:consumer`, `:producer` or `:internal`. Defaults to `:server`. + # + # _@param_ `opentelemetry_relationship` — In collector mode, how an incoming `opentelemetry_context` relates to this transaction's span: one of `:parent`, `:link`, `:both` or `:none`. Defaults to `:parent`. + # + # _@param_ `opentelemetry_context` — In collector mode, an incoming OpenTelemetry trace context to relate this transaction's span to. + # # Send an exception # ```ruby # begin @@ -463,8 +478,17 @@ module Appsignal # ``` # # _@see_ `https://docs.appsignal.com/ruby/instrumentation/exception-handling.html` — Exception handling guide - sig { params(error: Exception, opentelemetry_scope: T.untyped, block: T.proc.params(transaction: Transaction).void).void } - def self.send_error(error, opentelemetry_scope: nil, &block); end + sig do + params( + error: Exception, + opentelemetry_context: T.untyped, + opentelemetry_scope: T.untyped, + opentelemetry_kind: T.nilable(Symbol), + opentelemetry_relationship: T.nilable(Symbol), + block: T.proc.params(transaction: Transaction).void + ).void + end + def self.send_error(error, opentelemetry_context: nil, opentelemetry_scope: nil, opentelemetry_kind: nil, opentelemetry_relationship: nil, &block); end # Set an error on the current transaction. # @@ -537,6 +561,12 @@ module Appsignal # # _@param_ `exception` — The error to add to the current transaction. # + # _@param_ `opentelemetry_kind` — In collector mode, the OpenTelemetry span kind: one of `:server`, `:consumer`, `:producer` or `:internal`. Defaults to `:server`. Only used when a new transaction is created. + # + # _@param_ `opentelemetry_relationship` — In collector mode, how an incoming `opentelemetry_context` relates to this transaction's span: one of `:parent`, `:link`, `:both` or `:none`. Defaults to `:parent`. Only used when a new transaction is created. + # + # _@param_ `opentelemetry_context` — In collector mode, an incoming OpenTelemetry trace context to relate this transaction's span to. Only used when a new transaction is created. + # # ```ruby # class SomeController < ApplicationController # def create @@ -558,8 +588,17 @@ module Appsignal # ``` # # _@see_ `https://docs.appsignal.com/ruby/instrumentation/exception-handling.html` — Exception handling guide - sig { params(exception: Exception, opentelemetry_scope: T.untyped, block: T.proc.params(transaction: Transaction).void).void } - def self.report_error(exception, opentelemetry_scope: nil, &block); end + sig do + params( + exception: Exception, + opentelemetry_context: T.untyped, + opentelemetry_scope: T.untyped, + opentelemetry_kind: T.nilable(Symbol), + opentelemetry_relationship: T.nilable(Symbol), + block: T.proc.params(transaction: Transaction).void + ).void + end + def self.report_error(exception, opentelemetry_context: nil, opentelemetry_scope: nil, opentelemetry_kind: nil, opentelemetry_relationship: nil, &block); end # Set a custom action name for the current transaction. # @@ -1711,8 +1750,22 @@ module Appsignal # transaction. # # _@param_ `namespace` — Namespace of the to be created transaction. - sig { params(namespace: String, opentelemetry_context: T.untyped, opentelemetry_scope: T.untyped).returns(Transaction) } - def self.create(namespace, opentelemetry_context: nil, opentelemetry_scope: nil); end + # + # _@param_ `opentelemetry_kind` — In collector mode, the OpenTelemetry span kind: one of `:server`, `:consumer`, `:producer` or `:internal`. Defaults to `:server`. + # + # _@param_ `opentelemetry_relationship` — In collector mode, how an incoming `opentelemetry_context` relates to this transaction's span: one of `:parent`, `:link`, `:both` or `:none`. Defaults to `:parent`. + # + # _@param_ `opentelemetry_context` — In collector mode, an incoming OpenTelemetry trace context to relate this transaction's span to. + sig do + params( + namespace: String, + opentelemetry_context: T.untyped, + opentelemetry_scope: T.untyped, + opentelemetry_kind: T.nilable(Symbol), + opentelemetry_relationship: T.nilable(Symbol) + ).returns(Transaction) + end + def self.create(namespace, opentelemetry_context: nil, opentelemetry_scope: nil, opentelemetry_kind: nil, opentelemetry_relationship: nil); end # Returns currently active transaction or a {NilTransaction} if none is # active. @@ -2006,6 +2059,12 @@ module Appsignal # # _@param_ `action` — The action name for the transaction. The action name is required to be set for the transaction to be reported. The argument can be set to `nil` or `:set_later` if the action is set within the block with {#set_action}. This will not update the active transaction's action if {.monitor} is called when another transaction is already active. # + # _@param_ `opentelemetry_kind` — In collector mode, the OpenTelemetry span kind: one of `:server`, `:consumer`, `:producer` or `:internal`. Defaults to `:server`. + # + # _@param_ `opentelemetry_relationship` — In collector mode, how an incoming `opentelemetry_context` relates to this transaction's span: one of `:parent`, `:link`, `:both` or `:none`. Defaults to `:parent`. + # + # _@param_ `opentelemetry_context` — In collector mode, an incoming OpenTelemetry trace context to relate this transaction's span to. + # # _@return_ — The value of the given block is returned. # Returns `nil` if there already is a transaction active and no block # was given. @@ -2100,11 +2159,14 @@ module Appsignal params( action: T.any(String, Symbol, NilClass), namespace: T.nilable(T.any(String, Symbol)), + opentelemetry_context: T.untyped, opentelemetry_scope: T.untyped, + opentelemetry_kind: T.nilable(Symbol), + opentelemetry_relationship: T.nilable(Symbol), blk: T.proc.returns(Object) ).returns(T.nilable(Object)) end - def monitor(action:, namespace: nil, opentelemetry_scope: nil, &blk); end + def monitor(action:, namespace: nil, opentelemetry_context: nil, opentelemetry_scope: nil, opentelemetry_kind: nil, opentelemetry_relationship: nil, &blk); end # Instrument a block of code and stop AppSignal. # @@ -2148,6 +2210,12 @@ module Appsignal # # _@param_ `error` — The error to send to AppSignal. # + # _@param_ `opentelemetry_kind` — In collector mode, the OpenTelemetry span kind: one of `:server`, `:consumer`, `:producer` or `:internal`. Defaults to `:server`. + # + # _@param_ `opentelemetry_relationship` — In collector mode, how an incoming `opentelemetry_context` relates to this transaction's span: one of `:parent`, `:link`, `:both` or `:none`. Defaults to `:parent`. + # + # _@param_ `opentelemetry_context` — In collector mode, an incoming OpenTelemetry trace context to relate this transaction's span to. + # # Send an exception # ```ruby # begin @@ -2168,8 +2236,17 @@ module Appsignal # ``` # # _@see_ `https://docs.appsignal.com/ruby/instrumentation/exception-handling.html` — Exception handling guide - sig { params(error: Exception, opentelemetry_scope: T.untyped, block: T.proc.params(transaction: Transaction).void).void } - def send_error(error, opentelemetry_scope: nil, &block); end + sig do + params( + error: Exception, + opentelemetry_context: T.untyped, + opentelemetry_scope: T.untyped, + opentelemetry_kind: T.nilable(Symbol), + opentelemetry_relationship: T.nilable(Symbol), + block: T.proc.params(transaction: Transaction).void + ).void + end + def send_error(error, opentelemetry_context: nil, opentelemetry_scope: nil, opentelemetry_kind: nil, opentelemetry_relationship: nil, &block); end # Set an error on the current transaction. # @@ -2242,6 +2319,12 @@ module Appsignal # # _@param_ `exception` — The error to add to the current transaction. # + # _@param_ `opentelemetry_kind` — In collector mode, the OpenTelemetry span kind: one of `:server`, `:consumer`, `:producer` or `:internal`. Defaults to `:server`. Only used when a new transaction is created. + # + # _@param_ `opentelemetry_relationship` — In collector mode, how an incoming `opentelemetry_context` relates to this transaction's span: one of `:parent`, `:link`, `:both` or `:none`. Defaults to `:parent`. Only used when a new transaction is created. + # + # _@param_ `opentelemetry_context` — In collector mode, an incoming OpenTelemetry trace context to relate this transaction's span to. Only used when a new transaction is created. + # # ```ruby # class SomeController < ApplicationController # def create @@ -2263,8 +2346,17 @@ module Appsignal # ``` # # _@see_ `https://docs.appsignal.com/ruby/instrumentation/exception-handling.html` — Exception handling guide - sig { params(exception: Exception, opentelemetry_scope: T.untyped, block: T.proc.params(transaction: Transaction).void).void } - def report_error(exception, opentelemetry_scope: nil, &block); end + sig do + params( + exception: Exception, + opentelemetry_context: T.untyped, + opentelemetry_scope: T.untyped, + opentelemetry_kind: T.nilable(Symbol), + opentelemetry_relationship: T.nilable(Symbol), + block: T.proc.params(transaction: Transaction).void + ).void + end + def report_error(exception, opentelemetry_context: nil, opentelemetry_scope: nil, opentelemetry_kind: nil, opentelemetry_relationship: nil, &block); end # Set a custom action name for the current transaction. # diff --git a/sig/appsignal.rbs b/sig/appsignal.rbs index d6ead1ba6..e95e54a4c 100644 --- a/sig/appsignal.rbs +++ b/sig/appsignal.rbs @@ -259,6 +259,12 @@ module Appsignal # # _@param_ `action` — The action name for the transaction. The action name is required to be set for the transaction to be reported. The argument can be set to `nil` or `:set_later` if the action is set within the block with {#set_action}. This will not update the active transaction's action if {.monitor} is called when another transaction is already active. # + # _@param_ `opentelemetry_kind` — In collector mode, the OpenTelemetry span kind: one of `:server`, `:consumer`, `:producer` or `:internal`. Defaults to `:server`. + # + # _@param_ `opentelemetry_relationship` — In collector mode, how an incoming `opentelemetry_context` relates to this transaction's span: one of `:parent`, `:link`, `:both` or `:none`. Defaults to `:parent`. + # + # _@param_ `opentelemetry_context` — In collector mode, an incoming OpenTelemetry trace context to relate this transaction's span to. + # # _@return_ — The value of the given block is returned. # Returns `nil` if there already is a transaction active and no block # was given. @@ -349,7 +355,14 @@ module Appsignal # ``` # # _@see_ `https://docs.appsignal.com/ruby/instrumentation/background-jobs.html` — Monitor guide - def self.monitor: (action: (String | Symbol | NilClass), ?namespace: (String | Symbol)?, ?opentelemetry_scope: untyped) ?{ () -> Object } -> Object? + def self.monitor: ( + action: (String | Symbol | NilClass), + ?namespace: (String | Symbol)?, + ?opentelemetry_context: untyped, + ?opentelemetry_scope: untyped, + ?opentelemetry_kind: Symbol?, + ?opentelemetry_relationship: Symbol? + ) ?{ () -> Object } -> Object? # Instrument a block of code and stop AppSignal. # @@ -385,6 +398,12 @@ module Appsignal # # _@param_ `error` — The error to send to AppSignal. # + # _@param_ `opentelemetry_kind` — In collector mode, the OpenTelemetry span kind: one of `:server`, `:consumer`, `:producer` or `:internal`. Defaults to `:server`. + # + # _@param_ `opentelemetry_relationship` — In collector mode, how an incoming `opentelemetry_context` relates to this transaction's span: one of `:parent`, `:link`, `:both` or `:none`. Defaults to `:parent`. + # + # _@param_ `opentelemetry_context` — In collector mode, an incoming OpenTelemetry trace context to relate this transaction's span to. + # # Send an exception # ```ruby # begin @@ -405,7 +424,13 @@ module Appsignal # ``` # # _@see_ `https://docs.appsignal.com/ruby/instrumentation/exception-handling.html` — Exception handling guide - def self.send_error: (Exception error, ?opentelemetry_scope: untyped) ?{ (Transaction transaction) -> void } -> void + def self.send_error: ( + Exception error, + ?opentelemetry_context: untyped, + ?opentelemetry_scope: untyped, + ?opentelemetry_kind: Symbol?, + ?opentelemetry_relationship: Symbol? + ) ?{ (Transaction transaction) -> void } -> void # Set an error on the current transaction. # @@ -477,6 +502,12 @@ module Appsignal # # _@param_ `exception` — The error to add to the current transaction. # + # _@param_ `opentelemetry_kind` — In collector mode, the OpenTelemetry span kind: one of `:server`, `:consumer`, `:producer` or `:internal`. Defaults to `:server`. Only used when a new transaction is created. + # + # _@param_ `opentelemetry_relationship` — In collector mode, how an incoming `opentelemetry_context` relates to this transaction's span: one of `:parent`, `:link`, `:both` or `:none`. Defaults to `:parent`. Only used when a new transaction is created. + # + # _@param_ `opentelemetry_context` — In collector mode, an incoming OpenTelemetry trace context to relate this transaction's span to. Only used when a new transaction is created. + # # ```ruby # class SomeController < ApplicationController # def create @@ -498,7 +529,13 @@ module Appsignal # ``` # # _@see_ `https://docs.appsignal.com/ruby/instrumentation/exception-handling.html` — Exception handling guide - def self.report_error: (Exception exception, ?opentelemetry_scope: untyped) ?{ (Transaction transaction) -> void } -> void + def self.report_error: ( + Exception exception, + ?opentelemetry_context: untyped, + ?opentelemetry_scope: untyped, + ?opentelemetry_kind: Symbol?, + ?opentelemetry_relationship: Symbol? + ) ?{ (Transaction transaction) -> void } -> void # Set a custom action name for the current transaction. # @@ -1563,7 +1600,19 @@ module Appsignal # transaction. # # _@param_ `namespace` — Namespace of the to be created transaction. - def self.create: (String namespace, ?opentelemetry_context: untyped, ?opentelemetry_scope: untyped) -> Transaction + # + # _@param_ `opentelemetry_kind` — In collector mode, the OpenTelemetry span kind: one of `:server`, `:consumer`, `:producer` or `:internal`. Defaults to `:server`. + # + # _@param_ `opentelemetry_relationship` — In collector mode, how an incoming `opentelemetry_context` relates to this transaction's span: one of `:parent`, `:link`, `:both` or `:none`. Defaults to `:parent`. + # + # _@param_ `opentelemetry_context` — In collector mode, an incoming OpenTelemetry trace context to relate this transaction's span to. + def self.create: ( + String namespace, + ?opentelemetry_context: untyped, + ?opentelemetry_scope: untyped, + ?opentelemetry_kind: Symbol?, + ?opentelemetry_relationship: Symbol? + ) -> Transaction # Returns currently active transaction or a {NilTransaction} if none is # active. @@ -1836,6 +1885,12 @@ module Appsignal # # _@param_ `action` — The action name for the transaction. The action name is required to be set for the transaction to be reported. The argument can be set to `nil` or `:set_later` if the action is set within the block with {#set_action}. This will not update the active transaction's action if {.monitor} is called when another transaction is already active. # + # _@param_ `opentelemetry_kind` — In collector mode, the OpenTelemetry span kind: one of `:server`, `:consumer`, `:producer` or `:internal`. Defaults to `:server`. + # + # _@param_ `opentelemetry_relationship` — In collector mode, how an incoming `opentelemetry_context` relates to this transaction's span: one of `:parent`, `:link`, `:both` or `:none`. Defaults to `:parent`. + # + # _@param_ `opentelemetry_context` — In collector mode, an incoming OpenTelemetry trace context to relate this transaction's span to. + # # _@return_ — The value of the given block is returned. # Returns `nil` if there already is a transaction active and no block # was given. @@ -1926,7 +1981,14 @@ module Appsignal # ``` # # _@see_ `https://docs.appsignal.com/ruby/instrumentation/background-jobs.html` — Monitor guide - def monitor: (action: (String | Symbol | NilClass), ?namespace: (String | Symbol)?, ?opentelemetry_scope: untyped) ?{ () -> Object } -> Object? + def monitor: ( + action: (String | Symbol | NilClass), + ?namespace: (String | Symbol)?, + ?opentelemetry_context: untyped, + ?opentelemetry_scope: untyped, + ?opentelemetry_kind: Symbol?, + ?opentelemetry_relationship: Symbol? + ) ?{ () -> Object } -> Object? # Instrument a block of code and stop AppSignal. # @@ -1962,6 +2024,12 @@ module Appsignal # # _@param_ `error` — The error to send to AppSignal. # + # _@param_ `opentelemetry_kind` — In collector mode, the OpenTelemetry span kind: one of `:server`, `:consumer`, `:producer` or `:internal`. Defaults to `:server`. + # + # _@param_ `opentelemetry_relationship` — In collector mode, how an incoming `opentelemetry_context` relates to this transaction's span: one of `:parent`, `:link`, `:both` or `:none`. Defaults to `:parent`. + # + # _@param_ `opentelemetry_context` — In collector mode, an incoming OpenTelemetry trace context to relate this transaction's span to. + # # Send an exception # ```ruby # begin @@ -1982,7 +2050,13 @@ module Appsignal # ``` # # _@see_ `https://docs.appsignal.com/ruby/instrumentation/exception-handling.html` — Exception handling guide - def send_error: (Exception error, ?opentelemetry_scope: untyped) ?{ (Transaction transaction) -> void } -> void + def send_error: ( + Exception error, + ?opentelemetry_context: untyped, + ?opentelemetry_scope: untyped, + ?opentelemetry_kind: Symbol?, + ?opentelemetry_relationship: Symbol? + ) ?{ (Transaction transaction) -> void } -> void # Set an error on the current transaction. # @@ -2054,6 +2128,12 @@ module Appsignal # # _@param_ `exception` — The error to add to the current transaction. # + # _@param_ `opentelemetry_kind` — In collector mode, the OpenTelemetry span kind: one of `:server`, `:consumer`, `:producer` or `:internal`. Defaults to `:server`. Only used when a new transaction is created. + # + # _@param_ `opentelemetry_relationship` — In collector mode, how an incoming `opentelemetry_context` relates to this transaction's span: one of `:parent`, `:link`, `:both` or `:none`. Defaults to `:parent`. Only used when a new transaction is created. + # + # _@param_ `opentelemetry_context` — In collector mode, an incoming OpenTelemetry trace context to relate this transaction's span to. Only used when a new transaction is created. + # # ```ruby # class SomeController < ApplicationController # def create @@ -2075,7 +2155,13 @@ module Appsignal # ``` # # _@see_ `https://docs.appsignal.com/ruby/instrumentation/exception-handling.html` — Exception handling guide - def report_error: (Exception exception, ?opentelemetry_scope: untyped) ?{ (Transaction transaction) -> void } -> void + def report_error: ( + Exception exception, + ?opentelemetry_context: untyped, + ?opentelemetry_scope: untyped, + ?opentelemetry_kind: Symbol?, + ?opentelemetry_relationship: Symbol? + ) ?{ (Transaction transaction) -> void } -> void # Set a custom action name for the current transaction. # diff --git a/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb b/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb index 135b6885b..741df5084 100644 --- a/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb +++ b/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb @@ -16,6 +16,8 @@ before do ::OpenTelemetry.tracer_provider = tracer_provider @backends_created = [] + # Warn-once state is process-wide, so clear it between examples. + described_class.clear_warned! # OTel reports context-balance violations (e.g. DetachError) through its # error handler, which by default only logs. Capture them so the after hook @@ -144,15 +146,42 @@ def scope_of(span) 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) + describe "span kind" do + def create_backend_with_kind(kind) + described_class.new("abc-123", "http_request", :opentelemetry_kind => kind) + .tap { |b| @backends_created << b } + end + + [:server, :consumer, :producer, :internal].each do |kind| + it "uses the given opentelemetry_kind #{kind.inspect} as the SpanKind" do + create_backend_with_kind(kind).complete + expect(span_exporter.finished_spans.first.kind).to eq(kind) + end + end + + it "defaults to a server span when no kind is given" do + create_backend.complete + expect(span_exporter.finished_spans.first.kind).to eq(:server) + end + + it "does not derive the kind from the namespace" do + create_backend("background_job").complete + expect(span_exporter.finished_spans.first.kind).to eq(:server) + end + + it "falls back to the default kind and warns on an unknown value" do + expect(Appsignal.internal_logger).to receive(:warn) + .with(a_string_including("opentelemetry_kind")) + + create_backend_with_kind(:bogus).complete + + expect(span_exporter.finished_spans.first.kind).to eq(:server) + end + + it "warns only once for a repeated unknown value" do + expect(Appsignal.internal_logger).to receive(:warn).once + + 2.times { create_backend_with_kind(:bogus).complete } end end @@ -178,54 +207,105 @@ def scope_of(span) ) end - def create_backend_with_context(namespace, context) - described_class.new("abc-123", namespace, :opentelemetry_context => context) - .tap { |b| @backends_created << b } + def create_backend_with_context(context, relationship: nil) + described_class.new( + "abc-123", + "http_request", + :opentelemetry_context => context, + :opentelemetry_relationship => relationship + ).tap { |b| @backends_created << b } + end + + def link_span_context(root) + root.links.first.span_context end - it "parents a server transaction under the remote span (continues the trace)" do - backend = create_backend_with_context("http_request", remote_context) + it "parents under the remote span by default (continues the trace)" do + backend = create_backend_with_context(remote_context) backend.complete root = finished_span(backend.instance_variable_get(:@span)) expect(root.hex_trace_id).to eq(trace_id_hex) expect(root.parent_span_id.unpack1("H*")).to eq(span_id_hex) - expect(root.kind).to eq(:server) + expect(Array(root.links)).to be_empty end - it "starts a fresh root trace when no context is given" do - backend = create_backend("http_request") + it "parents under the remote span with :parent" do + backend = create_backend_with_context(remote_context, :relationship => :parent) backend.complete root = finished_span(backend.instance_variable_get(:@span)) - expect(root.hex_trace_id).not_to eq(trace_id_hex) - expect(root.parent_span_id).to eq(::OpenTelemetry::Trace::INVALID_SPAN_ID) + expect(root.hex_trace_id).to eq(trace_id_hex) + expect(root.parent_span_id.unpack1("H*")).to eq(span_id_hex) + expect(Array(root.links)).to be_empty end - it "links a consumer transaction back to the remote span (starts a new trace)" do - backend = create_backend_with_context("background_job", remote_context) + it "starts a new trace linked back to the remote span with :link" do + backend = create_backend_with_context(remote_context, :relationship => :link) backend.complete root = finished_span(backend.instance_variable_get(:@span)) - # A job is its own unit of work: new trace, no parent. + # A new unit of work: its own trace, no parent... expect(root.hex_trace_id).not_to eq(trace_id_hex) expect(root.parent_span_id).to eq(::OpenTelemetry::Trace::INVALID_SPAN_ID) - expect(root.kind).to eq(:consumer) - # ... but linked back to the enqueuing span. + # ... but linked back to the remote 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) + expect(link_span_context(root).hex_trace_id).to eq(trace_id_hex) + expect(link_span_context(root).hex_span_id).to eq(span_id_hex) end - it "does not link a consumer transaction when there is no context" do - backend = create_backend("background_job") + it "parents under the remote span and links back to it with :both" do + backend = create_backend_with_context(remote_context, :relationship => :both) backend.complete root = finished_span(backend.instance_variable_get(:@span)) - expect(root.kind).to eq(:consumer) - expect(root.links).to be_nil + # Continues the trace as a child... + expect(root.hex_trace_id).to eq(trace_id_hex) + expect(root.parent_span_id.unpack1("H*")).to eq(span_id_hex) + + # ... and keeps the explicit link back to the same span. + expect(root.links.size).to eq(1) + expect(link_span_context(root).hex_trace_id).to eq(trace_id_hex) + expect(link_span_context(root).hex_span_id).to eq(span_id_hex) + end + + it "ignores the remote span with :none (plain root span)" do + backend = create_backend_with_context(remote_context, :relationship => :none) + backend.complete + root = finished_span(backend.instance_variable_get(:@span)) + + expect(root.hex_trace_id).not_to eq(trace_id_hex) + expect(root.parent_span_id).to eq(::OpenTelemetry::Trace::INVALID_SPAN_ID) + expect(Array(root.links)).to be_empty + end + + it "falls back to the default relationship and warns on an unknown value" do + expect(Appsignal.internal_logger).to receive(:warn) + .with(a_string_including("opentelemetry_relationship")) + + backend = create_backend_with_context(remote_context, :relationship => :bogus) + backend.complete + root = finished_span(backend.instance_variable_get(:@span)) + + # Falls back to :parent, so it continues the trace as a child rather + # than silently dropping the incoming context like :none. + expect(root.hex_trace_id).to eq(trace_id_hex) + expect(root.parent_span_id.unpack1("H*")).to eq(span_id_hex) + end + + [:parent, :link, :both, :none].each do |relationship| + it "starts a plain root span with #{relationship.inspect} when no context is given" do + backend = described_class.new( + "abc-123", "http_request", :opentelemetry_relationship => relationship + ).tap { |b| @backends_created << b } + backend.complete + root = finished_span(backend.instance_variable_get(:@span)) + + expect(root.hex_trace_id).not_to eq(trace_id_hex) + expect(root.parent_span_id).to eq(::OpenTelemetry::Trace::INVALID_SPAN_ID) + expect(Array(root.links)).to be_empty + end end end diff --git a/spec/lib/appsignal/transaction_spec.rb b/spec/lib/appsignal/transaction_spec.rb index d13c58c50..188e85d06 100644 --- a/spec/lib/appsignal/transaction_spec.rb +++ b/spec/lib/appsignal/transaction_spec.rb @@ -55,6 +55,40 @@ end end + context "with OpenTelemetry attributes" do + it "passes the OpenTelemetry context, kind and relationship to the backend" do + otel_context = "some-otel-context" + expect(Appsignal::Backends.transaction).to receive(:new).with( + kind_of(String), + default_namespace, + :opentelemetry_context => otel_context, + :opentelemetry_scope => nil, + :opentelemetry_kind => :consumer, + :opentelemetry_relationship => :both + ).and_call_original + + Appsignal::Transaction.create( + default_namespace, + :opentelemetry_context => otel_context, + :opentelemetry_kind => :consumer, + :opentelemetry_relationship => :both + ) + end + + it "defaults the OpenTelemetry attributes to nil" do + expect(Appsignal::Backends.transaction).to receive(:new).with( + kind_of(String), + default_namespace, + :opentelemetry_context => nil, + :opentelemetry_scope => nil, + :opentelemetry_kind => nil, + :opentelemetry_relationship => nil + ).and_call_original + + Appsignal::Transaction.create(default_namespace) + end + end + context "when a transaction is already running" do before do allow(SecureRandom).to receive(:uuid) @@ -121,7 +155,7 @@ end describe "OpenTelemetry root span" do - it "starts a root span with SpanKind::SERVER for HTTP_REQUEST", :collector_mode do + it "defaults to a SERVER span named after the namespace", :collector_mode do start_collector_agent create_transaction(Appsignal::Transaction::HTTP_REQUEST) Appsignal::Transaction.complete_current! @@ -132,31 +166,16 @@ expect(span.name).to eq("appsignal.transaction http_request") end - it "uses SpanKind::CONSUMER for BACKGROUND_JOB", :collector_mode do + it "uses the given opentelemetry_kind", :collector_mode do start_collector_agent - create_transaction(Appsignal::Transaction::BACKGROUND_JOB) + Appsignal::Transaction.create( + Appsignal::Transaction::HTTP_REQUEST, + :opentelemetry_kind => :consumer + ) Appsignal::Transaction.complete_current! expect(span_exporter.finished_spans.first.kind).to eq(:consumer) end - - 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 diff --git a/spec/lib/appsignal_spec.rb b/spec/lib/appsignal_spec.rb index da24085b5..a49e3a5db 100644 --- a/spec/lib/appsignal_spec.rb +++ b/spec/lib/appsignal_spec.rb @@ -1221,6 +1221,37 @@ def perform expect(Appsignal.monitor(:action => nil) { :return_value }).to eq(:return_value) end + describe "OpenTelemetry attributes" do + it "threads the OpenTelemetry attributes to the created transaction" do + start_agent + + otel_context = "some-otel-context" + expect(Appsignal::Transaction).to receive(:create).with( + Appsignal::Transaction::BACKGROUND_JOB, + :opentelemetry_context => otel_context, + :opentelemetry_scope => nil, + :opentelemetry_kind => :consumer, + :opentelemetry_relationship => :both + ).and_call_original + + Appsignal.monitor( + :action => "MyAction", + :namespace => Appsignal::Transaction::BACKGROUND_JOB, + :opentelemetry_context => otel_context, + :opentelemetry_kind => :consumer, + :opentelemetry_relationship => :both + ) + end + + it "uses the given opentelemetry_kind for the span", :collector_mode do + start_collector_agent + + Appsignal.monitor(:action => "MyAction", :opentelemetry_kind => :consumer) + + expect(root_span.kind).to eq(:consumer) + end + end + describe "setting a custom namespace via the namespace argument" do def perform Appsignal.monitor(:namespace => "custom", :action => nil) @@ -2077,6 +2108,37 @@ def perform keep_transactions { example.run } end + describe "OpenTelemetry attributes" do + it "threads the OpenTelemetry attributes to the created transaction" do + start_agent + allow(Appsignal::Transaction).to receive(:new).and_call_original + + otel_context = "some-otel-context" + Appsignal.send_error( + error, + :opentelemetry_context => otel_context, + :opentelemetry_kind => :consumer, + :opentelemetry_relationship => :both + ) + + expect(Appsignal::Transaction).to have_received(:new).with( + Appsignal::Transaction::HTTP_REQUEST, + :opentelemetry_context => otel_context, + :opentelemetry_scope => nil, + :opentelemetry_kind => :consumer, + :opentelemetry_relationship => :both + ) + end + + it "uses the given opentelemetry_kind for the span", :collector_mode do + start_collector_agent + + Appsignal.send_error(error, :opentelemetry_kind => :consumer) + + expect(root_span.kind).to eq(:consumer) + end + end + describe "sending the error" do def perform Appsignal.send_error(error) @@ -2390,6 +2452,38 @@ def perform let(:error) { ExampleException.new("error message") } around { |example| keep_transactions { example.run } } + describe "OpenTelemetry attributes" do + it "threads the OpenTelemetry attributes to a newly created transaction" do + start_agent + allow(Appsignal::Transaction).to receive(:new).and_call_original + + otel_context = "some-otel-context" + Appsignal.report_error( + error, + :opentelemetry_context => otel_context, + :opentelemetry_kind => :consumer, + :opentelemetry_relationship => :both + ) + + expect(Appsignal::Transaction).to have_received(:new).with( + Appsignal::Transaction::HTTP_REQUEST, + :opentelemetry_context => otel_context, + :opentelemetry_scope => nil, + :opentelemetry_kind => :consumer, + :opentelemetry_relationship => :both + ) + end + + it "uses the given opentelemetry_kind for the span", :collector_mode do + start_collector_agent + + Appsignal.report_error(error, :opentelemetry_kind => :consumer) + Appsignal::Transaction.complete_current! + + expect(root_span.kind).to eq(:consumer) + end + end + context "when the error is not an Exception" do let(:error) { Object.new } From c9898e2470399ee324b11144310b118b70079089 Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Mon, 27 Jul 2026 22:27:20 +0200 Subject: [PATCH 32/69] Split params into request and function channels The AppSignal collector keeps request parameters and function arguments as separate attributes. The gem had a single params blob and chose the attribute from the namespace, so the channel followed the namespace rather than the call. Each transaction backend declares which logical channel maps to which storage bucket. The extension backend maps every channel to one bucket, so agent mode still reports a single merged blob. The OpenTelemetry backend gives each channel its own bucket. `set_empty_params!` empties every channel rather than only the one the old params methods write to, so reporting no parameters also suppresses a background job's arguments. `add_params` and `set_params` keep working and write to the request payload. In collector mode they warn once, because the channel they mean is now explicit. --- lib/appsignal/demo.rb | 2 +- lib/appsignal/helpers/instrumentation.rb | 91 +++++- lib/appsignal/hooks/action_cable.rb | 4 +- lib/appsignal/hooks/active_job.rb | 2 +- lib/appsignal/integrations/action_cable.rb | 2 +- .../integrations/delayed_job_plugin.rb | 4 +- lib/appsignal/integrations/que.rb | 2 +- lib/appsignal/integrations/rake.rb | 2 +- lib/appsignal/integrations/resque.rb | 2 +- lib/appsignal/integrations/shoryuken.rb | 2 +- lib/appsignal/integrations/sidekiq.rb | 4 +- lib/appsignal/integrations/webmachine.rb | 2 +- lib/appsignal/rack.rb | 2 +- lib/appsignal/rack/event_handler.rb | 2 +- lib/appsignal/rack/hanami_middleware.rb | 2 +- lib/appsignal/sample_data.rb | 4 + lib/appsignal/transaction.rb | 239 +++++++++++++- lib/appsignal/transaction/base_backend.rb | 10 + .../transaction/extension_backend.rb | 14 + .../transaction/opentelemetry_backend.rb | 63 ++-- sig/appsignal.rbi | 168 +++++++++- sig/appsignal.rbs | 159 +++++++++- .../appsignal/integrations/webmachine_spec.rb | 8 +- .../transaction/extension_backend_spec.rb | 11 + .../transaction/opentelemetry_backend_spec.rb | 43 ++- spec/lib/appsignal/transaction_spec.rb | 300 +++++++++++++++++- spec/lib/appsignal_spec.rb | 120 +++++++ 27 files changed, 1152 insertions(+), 112 deletions(-) diff --git a/lib/appsignal/demo.rb b/lib/appsignal/demo.rb index 09d93474b..4921f68b1 100644 --- a/lib/appsignal/demo.rb +++ b/lib/appsignal/demo.rb @@ -70,7 +70,7 @@ def add_demo_metadata_to(transaction) end def add_params_to(transaction) - transaction.add_params( + transaction.add_request_payload( "controller" => "demo", "action" => "hello" ) diff --git a/lib/appsignal/helpers/instrumentation.rb b/lib/appsignal/helpers/instrumentation.rb index 90cfbec84..3f0bd0a51 100644 --- a/lib/appsignal/helpers/instrumentation.rb +++ b/lib/appsignal/helpers/instrumentation.rb @@ -672,16 +672,95 @@ def add_params(params = nil, &block) end alias set_params add_params + # Add the request payload to the current transaction. + # + # The request payload is the parameters of an incoming request, such as + # the query string and the request body. In collector mode it maps to its + # own attribute, separate from the function parameters. + # + # Behaves like {#add_params}: merges when called multiple times, and a + # block takes precedence over the argument. + # + # @param params [Hash, Array] The request payload to add to the + # transaction. + # @yield This block is called when the transaction is sampled. The block's + # return value will become the new request payload. + # @yieldreturn [Hash, Array] + # @return [void] + # + # @see #add_function_parameters + def add_request_payload(params = nil, &block) + return unless Appsignal.active? + return unless Appsignal::Transaction.current? + + transaction = Appsignal::Transaction.current + transaction.add_request_payload(params, &block) + end + + # Add the function parameters to the current transaction. + # + # The function parameters are the arguments a background job or function + # was called with. In collector mode they map to their own attribute, + # separate from the request payload. + # + # Behaves like {#add_params}: merges when called multiple times, and a + # block takes precedence over the argument. + # + # @param params [Hash, Array] The function parameters to add to + # the transaction. + # @yield This block is called when the transaction is sampled. The block's + # return value will become the new function parameters. + # @yieldreturn [Hash, Array] + # @return [void] + # + # @see #add_request_payload + def add_function_parameters(params = nil, &block) + return unless Appsignal.active? + return unless Appsignal::Transaction.current? + + transaction = Appsignal::Transaction.current + transaction.add_function_parameters(params, &block) + end + + # Add the query parameters to the current transaction. + # + # The query parameters are the parameters parsed from an incoming + # request's query string. In collector mode they map to their own + # attribute, separate from the request payload and the function + # parameters. + # + # Behaves like {#add_params}: merges when called multiple times, and a + # block takes precedence over the argument. + # + # @param params [Hash, Array] The query parameters to add to the + # transaction. + # @yield This block is called when the transaction is sampled. The block's + # return value will become the new query parameters. + # @yieldreturn [Hash, Array] + # @return [void] + # + # @see #add_request_payload + def add_query_parameters(params = nil, &block) + return unless Appsignal.active? + return unless Appsignal::Transaction.current? + + transaction = Appsignal::Transaction.current + transaction.add_query_parameters(params, &block) + end + # Mark the parameters sample data to be set as an empty value. # - # Use this helper to unset request parameters / background job arguments - # and not report any for this transaction. + # Use this helper to report no parameters for this transaction, whatever + # their source. # - # If parameters would normally be added by AppSignal instrumentations of - # libraries, these parameters will not be added to the Transaction. + # This suppresses every params channel. In collector mode, where the + # request payload and the function parameters (a background job's + # arguments) are tracked as separate attributes, it suppresses both, not + # only the request payload. Parameters that an AppSignal integration would + # otherwise add are not added. # - # Calling {#add_params} after this helper will add new parameters to the - # transaction. + # Calling {#add_params}, {#add_request_payload} or + # {#add_function_parameters} after this helper adds parameters again. # # @since 4.2.0 # @return [void] diff --git a/lib/appsignal/hooks/action_cable.rb b/lib/appsignal/hooks/action_cable.rb index b6d1ef5d2..b6aae106c 100644 --- a/lib/appsignal/hooks/action_cable.rb +++ b/lib/appsignal/hooks/action_cable.rb @@ -56,7 +56,7 @@ def install_subscribe_callback transaction.set_action_if_nil("#{channel.class}#subscribed") transaction.set_metadata("path", request.path) transaction.set_metadata("method", "websocket") - transaction.add_params_if_nil { request.params } + transaction.add_request_payload_if_nil { request.params } transaction.add_headers_if_nil { request.env } transaction.add_session_data { request.session.to_h if request.respond_to? :session } transaction.add_tags(:request_id => request_id) if request_id @@ -98,7 +98,7 @@ def install_unsubscribe_callback transaction.set_action_if_nil("#{channel.class}#unsubscribed") transaction.set_metadata("path", request.path) transaction.set_metadata("method", "websocket") - transaction.add_params_if_nil { request.params } + transaction.add_request_payload_if_nil { request.params } transaction.add_headers_if_nil { request.env } transaction.add_session_data { request.session.to_h if request.respond_to? :session } transaction.add_tags(:request_id => request_id) if request_id diff --git a/lib/appsignal/hooks/active_job.rb b/lib/appsignal/hooks/active_job.rb index 4797c8352..d4088ad76 100644 --- a/lib/appsignal/hooks/active_job.rb +++ b/lib/appsignal/hooks/active_job.rb @@ -85,7 +85,7 @@ def execute(job) end begin - transaction.add_params_if_nil(job["arguments"]) + transaction.add_function_parameters_if_nil(job["arguments"]) transaction_tags = ActiveJobHelpers.transaction_tags_for(job) transaction.add_tags(transaction_tags) diff --git a/lib/appsignal/integrations/action_cable.rb b/lib/appsignal/integrations/action_cable.rb index 130898747..6e53096ee 100644 --- a/lib/appsignal/integrations/action_cable.rb +++ b/lib/appsignal/integrations/action_cable.rb @@ -23,7 +23,7 @@ def perform_action(*args, &block) raise exception ensure transaction.set_action_if_nil("#{self.class}##{args.first["action"]}") - transaction.add_params_if_nil(args.first) + transaction.add_request_payload_if_nil(args.first) transaction.add_session_data { request.session.to_h if request.respond_to? :session } transaction.set_metadata("path", request.path) transaction.set_metadata("method", "websocket") diff --git a/lib/appsignal/integrations/delayed_job_plugin.rb b/lib/appsignal/integrations/delayed_job_plugin.rb index d21a14845..5854bc17f 100644 --- a/lib/appsignal/integrations/delayed_job_plugin.rb +++ b/lib/appsignal/integrations/delayed_job_plugin.rb @@ -81,11 +81,11 @@ def self.invoke_with_instrumentation(job, block) # ActiveJob job_data = payload.job_data transaction.set_action_if_nil("#{job_data["job_class"]}#perform") - transaction.add_params_if_nil(job_data.fetch("arguments", {})) + transaction.add_function_parameters_if_nil(job_data.fetch("arguments", {})) else # Delayed Job transaction.set_action_if_nil(action_name_from_payload(payload, job.name)) - transaction.add_params_if_nil(extract_value(payload, :args, {})) + transaction.add_function_parameters_if_nil(extract_value(payload, :args, {})) end transaction.add_tags( diff --git a/lib/appsignal/integrations/que.rb b/lib/appsignal/integrations/que.rb index 1126411b6..9a4b36d81 100644 --- a/lib/appsignal/integrations/que.rb +++ b/lib/appsignal/integrations/que.rb @@ -99,7 +99,7 @@ def _run(*args) raise error ensure transaction.set_action_if_nil("#{local_attrs[:job_class]}#run") - transaction.add_params_if_nil do + transaction.add_function_parameters_if_nil do { :arguments => local_attrs[:args] }.tap do |hash| diff --git a/lib/appsignal/integrations/rake.rb b/lib/appsignal/integrations/rake.rb index 2a5bce69a..f892c1c27 100644 --- a/lib/appsignal/integrations/rake.rb +++ b/lib/appsignal/integrations/rake.rb @@ -41,7 +41,7 @@ def execute(*args) params, _ = args params = params.to_hash if params.respond_to?(:to_hash) transaction.set_action(name) - transaction.add_params_if_nil(params) + transaction.add_request_payload_if_nil(params) transaction.complete end end diff --git a/lib/appsignal/integrations/resque.rb b/lib/appsignal/integrations/resque.rb index bf855e4a4..54ae846db 100644 --- a/lib/appsignal/integrations/resque.rb +++ b/lib/appsignal/integrations/resque.rb @@ -27,7 +27,7 @@ def perform ensure if transaction transaction.set_action_if_nil("#{payload["class"]}#perform") - transaction.add_params_if_nil { ResqueHelpers.arguments(payload) } + transaction.add_function_parameters_if_nil { ResqueHelpers.arguments(payload) } transaction.add_tags("queue" => queue) Appsignal::Transaction.complete_current! diff --git a/lib/appsignal/integrations/shoryuken.rb b/lib/appsignal/integrations/shoryuken.rb index 284d54c64..1a7fb7db9 100644 --- a/lib/appsignal/integrations/shoryuken.rb +++ b/lib/appsignal/integrations/shoryuken.rb @@ -96,7 +96,7 @@ def call(worker_instance, queue, sqs_msg, body, &block) ensure attributes = fetch_attributes(batch, sqs_msg) transaction.set_action_if_nil("#{worker_instance.class.name}#perform") - transaction.add_params_if_nil { fetch_args(batch, sqs_msg, body) } + transaction.add_function_parameters_if_nil { fetch_args(batch, sqs_msg, body) } transaction.add_tags(attributes) transaction.add_tags("queue" => queue) transaction.add_tags("batch" => true) if batch diff --git a/lib/appsignal/integrations/sidekiq.rb b/lib/appsignal/integrations/sidekiq.rb index c0441ee9d..124dcaa96 100644 --- a/lib/appsignal/integrations/sidekiq.rb +++ b/lib/appsignal/integrations/sidekiq.rb @@ -46,7 +46,7 @@ def call(exception, sidekiq_context, _sidekiq_config = nil) ) transaction.set_action_if_nil("SidekiqInternal") transaction.set_metadata("sidekiq_error", sidekiq_context[:context]) - transaction.add_params_if_nil(:jobstr => sidekiq_context[:jobstr]) + transaction.add_function_parameters_if_nil(:jobstr => sidekiq_context[:jobstr]) transaction.set_error(exception) end @@ -182,7 +182,7 @@ def call(_worker, item, _queue, &block) raise exception ensure if transaction - transaction.add_params_if_nil { parse_arguments(item) } + transaction.add_function_parameters_if_nil { parse_arguments(item) } enqueued_at = item["enqueued_at"] queue_start = if self.class.sidekiq8? diff --git a/lib/appsignal/integrations/webmachine.rb b/lib/appsignal/integrations/webmachine.rb index fd4cdbd08..6729b0b6c 100644 --- a/lib/appsignal/integrations/webmachine.rb +++ b/lib/appsignal/integrations/webmachine.rb @@ -24,7 +24,7 @@ def run end begin - transaction.add_params_if_nil { request.query } + transaction.add_query_parameters_if_nil { request.query } transaction.add_headers_if_nil { request.headers if request.respond_to?(:headers) } Appsignal.instrument( diff --git a/lib/appsignal/rack.rb b/lib/appsignal/rack.rb index 40b23ca70..20e3c47bc 100644 --- a/lib/appsignal/rack.rb +++ b/lib/appsignal/rack.rb @@ -60,7 +60,7 @@ def apply_to(transaction) transaction.set_metadata("method", request_method) end - transaction.add_params { params_for(request) } + transaction.add_request_payload { params_for(request) } transaction.add_session_data { session_data_for(request) } transaction.add_headers do request.env if request.respond_to?(:env) diff --git a/lib/appsignal/rack/event_handler.rb b/lib/appsignal/rack/event_handler.rb index 13ca8e098..0b792a296 100644 --- a/lib/appsignal/rack/event_handler.rb +++ b/lib/appsignal/rack/event_handler.rb @@ -124,7 +124,7 @@ def on_finish(request, response) self.class.safe_execution("Appsignal::Rack::EventHandler#on_finish") do transaction.finish_event("process_request.rack", "callback: on_finish", "") - transaction.add_params_if_nil { request.params } + transaction.add_request_payload_if_nil { request.params } transaction.add_headers_if_nil { request.env } transaction.add_session_data_if_nil do request.session if request.respond_to?(:session) diff --git a/lib/appsignal/rack/hanami_middleware.rb b/lib/appsignal/rack/hanami_middleware.rb index ff427b87e..6a481a244 100644 --- a/lib/appsignal/rack/hanami_middleware.rb +++ b/lib/appsignal/rack/hanami_middleware.rb @@ -20,7 +20,7 @@ def initialize(app, options = {}) def add_transaction_metadata_after(transaction, request) action_name = fetch_hanami_action(request.env) transaction.set_action_if_nil(action_name) if action_name - transaction.add_params { params_for(request) } + transaction.add_request_payload { params_for(request) } end def params_for(request) diff --git a/lib/appsignal/sample_data.rb b/lib/appsignal/sample_data.rb index c8359dd23..7ec7be276 100644 --- a/lib/appsignal/sample_data.rb +++ b/lib/appsignal/sample_data.rb @@ -61,6 +61,10 @@ def empty? @empty end + # The sample-data category this holds (e.g. `:params`, `:request_payload`). + # Used to name the data in log messages. + attr_reader :key + protected attr_reader :blocks diff --git a/lib/appsignal/transaction.rb b/lib/appsignal/transaction.rb index d59403aa2..4009bb9aa 100644 --- a/lib/appsignal/transaction.rb +++ b/lib/appsignal/transaction.rb @@ -22,6 +22,11 @@ class Transaction ERROR_CAUSES_LIMIT = 10 # @!visibility private ERRORS_LIMIT = 10 + # Guards the process-wide `add_params`/`set_params` deprecation warn-once + # flag, which transactions touch concurrently on threaded servers. A + # constant so it is created once at load time rather than lazily. + # @!visibility private + PARAMS_DEPRECATION_LOCK = Mutex.new class << self # Create a new transaction and set it as the currently active @@ -172,6 +177,26 @@ def last_errors # @!visibility private attr_writer :last_errors + + # Runs the block to emit the collector-mode `add_params`/`set_params` + # deprecation warning the first time it is called in the process, then + # stays quiet. The check-and-set is done under a lock so concurrent + # transactions on threaded runtimes don't race and warn more than once. + # @!visibility private + def warn_params_deprecation_once + should_warn = PARAMS_DEPRECATION_LOCK.synchronize do + next false if @params_deprecation_warned + + @params_deprecation_warned = true + end + yield if should_warn + end + + # @!visibility private + # Resets the warn-once state. Only used to keep test runs isolated. + def reset_params_deprecation_warning! + PARAMS_DEPRECATION_LOCK.synchronize { @params_deprecation_warned = false } + end end # @!visibility private @@ -213,7 +238,6 @@ def initialize( # rubocop:disable Metrics/ParameterLists @is_duplicate = false @error_set = nil - @params = Appsignal::SampleData.new(:params) @session_data = Appsignal::SampleData.new(:session_data, Hash) @headers = Appsignal::SampleData.new(:headers, Hash) @custom_data = Appsignal::SampleData.new(:custom_data) @@ -227,6 +251,20 @@ def initialize( # rubocop:disable Metrics/ParameterLists :opentelemetry_relationship => opentelemetry_relationship ) + # The backend decides how the params channels are stored. Its + # `params_mapping` maps each logical channel to a storage bucket: the + # extension backend maps them all to one `:params` bucket, so agent mode + # keeps a single merged blob, while the OpenTelemetry backend keeps the + # request payload, function parameters and query parameters apart. + # + # Each distinct bucket gets its own `SampleData`, named after the bucket. + # That symbol is also the sample-data key the backend receives, so it can + # route the bucket to the right storage. + @params_mapping = @backend.params_mapping + @params_buckets = @params_mapping.values.uniq.to_h do |bucket| + [bucket, Appsignal::SampleData.new(bucket)] + end + run_after_create_hooks end @@ -389,17 +427,28 @@ def job_enqueue_events_suppressed? # @see https://docs.appsignal.com/guides/custom-data/sample-data.html # Sample data guide def add_params(given_params = nil, &block) - @params.add(given_params, &block) + warn_params_deprecation + params_data(:params).add(given_params, &block) end alias set_params add_params + # Marks every params channel as explicitly empty, so no parameters are + # reported for this transaction whatever their source. + # + # This is a deliberate choice for collector mode, where the request payload + # and the function parameters live in separate buckets: it empties both, not + # only the request payload the legacy `:params` channel maps to. Otherwise + # an integration's `_if_nil` setter could still add function parameters (a + # background job's arguments) after params were emptied, and the behavior + # would differ from agent mode, where all channels share one bucket. + # # @since 4.0.0 # @return [void] # @!visibility private # # @see Helpers::Instrumentation#set_empty_params! def set_empty_params! - @params.set_empty_value! + @params_buckets.each_value(&:set_empty_value!) end # Add parameters to the transaction if not already set. @@ -415,10 +464,119 @@ def set_empty_params! # # @see #add_params def add_params_if_nil(given_params = nil, &block) - add_params(given_params, &block) if !@params.value? && !@params.empty? + add_params(given_params, &block) if params_unset?(:params) end alias set_params_if_nil add_params_if_nil + # Add the request payload to the transaction. + # + # These are the parameters of an incoming request, such as the query string + # and the request body. In collector mode they map to the request payload + # attribute. In agent mode they are the transaction's params. + # + # Behaves like {#add_params}: merges when called multiple times, and a + # block takes precedence over the argument. + # + # @param given_params [Hash, Array] The parameters to add to the + # transaction. + # @yield This block is called when the transaction is sampled. The block's + # return value will become the new parameters. + # @yieldreturn [Hash, Array] + # @return [void] + # + # @see #add_function_parameters + def add_request_payload(given_params = nil, &block) + params_data(:request_payload).add(given_params, &block) + end + + # Add the request payload to the transaction if not already set. + # + # @param given_params [Hash, Array] The parameters to add to the + # transaction if none are already set. + # @yield This block is called when the transaction is sampled. The block's + # return value will become the new parameters. + # @yieldreturn [Hash, Array] + # @return [void] + # @!visibility private + # + # @see #add_request_payload + def add_request_payload_if_nil(given_params = nil, &block) + add_request_payload(given_params, &block) if params_unset?(:request_payload) + end + + # Add the function parameters to the transaction. + # + # These are the arguments a background job or function was called with. In + # collector mode they map to the function parameters attribute. In agent + # mode they are the transaction's params. + # + # Behaves like {#add_params}: merges when called multiple times, and a + # block takes precedence over the argument. + # + # @param given_params [Hash, Array] The parameters to add to the + # transaction. + # @yield This block is called when the transaction is sampled. The block's + # return value will become the new parameters. + # @yieldreturn [Hash, Array] + # @return [void] + # + # @see #add_request_payload + def add_function_parameters(given_params = nil, &block) + params_data(:function_parameters).add(given_params, &block) + end + + # Add the function parameters to the transaction if not already set. + # + # @param given_params [Hash, Array] The parameters to add to the + # transaction if none are already set. + # @yield This block is called when the transaction is sampled. The block's + # return value will become the new parameters. + # @yieldreturn [Hash, Array] + # @return [void] + # @!visibility private + # + # @see #add_function_parameters + def add_function_parameters_if_nil(given_params = nil, &block) + add_function_parameters(given_params, &block) if params_unset?(:function_parameters) + end + + # Add the query parameters to the transaction. + # + # These are the parameters parsed from an incoming request's query string. + # In collector mode they map to their own attribute, separate from the + # request payload and the function parameters. In agent mode they are the + # transaction's params. + # + # Behaves like {#add_params}: merges when called multiple times, and a + # block takes precedence over the argument. + # + # @param given_params [Hash, Array] The parameters to add to the + # transaction. + # @yield This block is called when the transaction is sampled. The block's + # return value will become the new parameters. + # @yieldreturn [Hash, Array] + # @return [void] + # + # @see #add_request_payload + def add_query_parameters(given_params = nil, &block) + params_data(:query_parameters).add(given_params, &block) + end + + # Add the query parameters to the transaction if not already set. + # + # @param given_params [Hash, Array] The parameters to add to the + # transaction if none are already set. + # @yield This block is called when the transaction is sampled. The block's + # return value will become the new parameters. + # @yieldreturn [Hash, Array] + # @return [void] + # @!visibility private + # + # @see #add_query_parameters + def add_query_parameters_if_nil(given_params = nil, &block) + add_query_parameters(given_params, &block) if params_unset?(:query_parameters) + end + # Add tags to the transaction. # # When this method is called multiple times, it will merge the tags. @@ -767,7 +925,7 @@ def to_h protected # @!visibility private - attr_writer :is_duplicate, :tags, :custom_data, :params, + attr_writer :is_duplicate, :tags, :custom_data, :params_buckets, :session_data, :headers # @!visibility private @@ -811,6 +969,39 @@ def internal_set_error(error, &block) private + # The `SampleData` bucket a logical params channel is stored in, per the + # backend's `params_mapping`. In agent mode every channel resolves to the + # same `:params` bucket (so they merge and share an `_if_nil` guard); in + # collector mode the request payload, function parameters and query + # parameters resolve to separate buckets. `fetch` raises if a backend's + # mapping omits a channel. + def params_data(channel) + @params_buckets.fetch(@params_mapping.fetch(channel)) + end + + # Whether a params channel's bucket has had nothing set yet, so the + # `_if_nil` setters do not overwrite params the caller already provided. + def params_unset?(channel) + bucket = params_data(channel) + !bucket.value? && !bucket.empty? + end + + # `add_params`/`set_params` don't say whether the params are a request + # payload or function parameters, so in collector mode they always map to + # the request payload. Warn once per process to nudge callers toward the + # explicit methods. + def warn_params_deprecation + return unless Appsignal.config&.collector_mode? + + Appsignal::Transaction.warn_params_deprecation_once do + Appsignal::Utils::StdoutAndLoggerMessage.warning( + "`add_params`/`set_params` is deprecated in collector mode. Use " \ + "`add_request_payload` or `add_function_parameters` instead, or " \ + "`add_custom_data` for data that is neither." + ) + end + end + # Wrap a block handed to the transaction by user code so that, wherever it # later runs, a failure is logged and swallowed instead of breaking the # transaction lifecycle. A raise would otherwise skip the rest of creation or @@ -973,14 +1164,21 @@ def set_sample_data(key, data) end def sample_data - { - :params => sanitized_params, + data = { :environment => sanitized_request_headers, :session_data => sanitized_session_data, :tags => sanitized_tags, :custom_data => custom_data - }.each do |key, data| - set_sample_data(key, data) + } + # Each params bucket is emitted under its own key. The extension backend + # has a single `:params` bucket; the OpenTelemetry backend has separate + # `:request_payload` and `:function_parameters` buckets. The backend maps + # each key to its storage (C-extension slot or OpenTelemetry attribute). + @params_buckets.each do |bucket, sample| + data[bucket] = sanitized_params(sample) + end + data.each do |key, value| + set_sample_data(key, value) end end @@ -994,24 +1192,33 @@ def duplicate transaction.is_duplicate = true transaction.tags = @tags.dup transaction.custom_data = @custom_data.dup - transaction.params = @params.dup + transaction.params_buckets = @params_buckets.transform_values(&:dup) transaction.session_data = @session_data.dup transaction.headers = @headers.dup end end def params - @params.value - rescue => e - Appsignal.internal_logger.error("Exception while fetching params: #{e.class}: #{e}") - nil + params_value(params_data(:params)) end - def sanitized_params + def sanitized_params(sample = params_data(:params)) return unless Appsignal.config[:send_params] filter_keys = Appsignal.config[:filter_parameters] || [] - Appsignal::Utils::SampleDataSanitizer.sanitize(params, filter_keys) + Appsignal::Utils::SampleDataSanitizer.sanitize(params_value(sample), filter_keys) + end + + # Reads a params bucket's value. Evaluating it runs any block the caller + # passed to `add_params`/`add_request_payload`/`add_function_parameters`, + # which is user code that can raise, so a failure is logged and swallowed. + def params_value(sample) + sample.value + rescue => e + Appsignal.internal_logger.error( + "Exception while fetching params (#{sample.key}): #{e.class}: #{e}" + ) + nil end def session_data diff --git a/lib/appsignal/transaction/base_backend.rb b/lib/appsignal/transaction/base_backend.rb index b5f3d940d..13bafc323 100644 --- a/lib/appsignal/transaction/base_backend.rb +++ b/lib/appsignal/transaction/base_backend.rb @@ -42,6 +42,16 @@ def set_queue_start(_start) raise NotImplementedError end + # Maps each logical params channel (`:params`, `:request_payload`, + # `:function_parameters`) to the storage bucket it lands in. Channels that + # share a bucket merge into one `SampleData` object on the transaction; + # distinct buckets stay separate. The bucket name is also the sample-data + # key `set_sample_data` receives for that bucket, so it must be a key this + # backend's `set_sample_data` knows how to store. + def params_mapping + raise NotImplementedError + end + # Sample data (params, session, tags, ...), breadcrumbs and errors. def set_sample_data(_key, _data) raise NotImplementedError diff --git a/lib/appsignal/transaction/extension_backend.rb b/lib/appsignal/transaction/extension_backend.rb index 9aae1720f..3091aee46 100644 --- a/lib/appsignal/transaction/extension_backend.rb +++ b/lib/appsignal/transaction/extension_backend.rb @@ -75,6 +75,20 @@ def set_metadata(key, value) @handle.set_metadata(key, value) end + # The agent has a single params slot, so every params channel maps to one + # `:params` bucket. The transaction merges the channels into it, and only + # the `:params` key ever reaches `set_sample_data`. + PARAMS_MAPPING = { + :params => :params, + :request_payload => :params, + :function_parameters => :params, + :query_parameters => :params + }.freeze + + def params_mapping + PARAMS_MAPPING + end + # `data` is a raw Ruby Hash/Array; the C extension wants a `Data` object, # so serialize it here (mirrors how `set_error` serializes its backtrace). def set_sample_data(key, data) diff --git a/lib/appsignal/transaction/opentelemetry_backend.rb b/lib/appsignal/transaction/opentelemetry_backend.rb index d194cc47c..f890e3b8f 100644 --- a/lib/appsignal/transaction/opentelemetry_backend.rb +++ b/lib/appsignal/transaction/opentelemetry_backend.rb @@ -47,18 +47,6 @@ def warned_keys end end - # Maps an internal namespace to the params attribute it uses. Message/job - # (CONSUMER-kind) namespaces use the function-parameters channel; - # everything else uses the request-payload channel. 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 when no kind is given (a transaction is almost always an # external-triggered unit of work). @@ -237,16 +225,39 @@ def set_metadata(key, value) @span.set_attribute("appsignal.tag.#{key}", value) end + # The collector keeps the request payload, the function parameters and the + # query parameters as separate attributes, so each gets its own bucket. + # Legacy `params` has no channel of its own, so it maps to the request + # payload (the web/server default), matching how the span kind defaults to + # `:server`. + PARAMS_MAPPING = { + :params => :request_payload, + :request_payload => :request_payload, + :function_parameters => :function_parameters, + :query_parameters => :query_parameters + }.freeze + + def params_mapping + PARAMS_MAPPING + end + # Routes each sample-data category to the attribute the collector reads. - # The 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). + # The params arrive on one of three channels: `request_payload` (web), + # `function_parameters` (jobs) and `query_parameters` (a request's query + # string), each its own attribute. The other JSON-blob + # categories (session, custom data) are serialized as JSON; `environment` + # becomes request-header attributes; tags fan out to `appsignal.tag.*`. + # Unknown keys pass through as `appsignal.` JSON so nothing is lost. + # Breadcrumbs never reach here (the backend emits them as span events); + # causes ride on the exception event (see #set_error). def set_sample_data(key, data) case key - when "params" - @span.set_attribute(params_attribute, JSON.generate(data)) + when "request_payload" + @span.set_attribute("appsignal.request.payload", JSON.generate(data)) + when "function_parameters" + @span.set_attribute("appsignal.function.parameters", JSON.generate(data)) + when "query_parameters" + @span.set_attribute("appsignal.request.query_parameters", JSON.generate(data)) when "session_data" @span.set_attribute("appsignal.request.session_data", JSON.generate(data)) when "custom_data" @@ -637,20 +648,6 @@ 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_*). diff --git a/sig/appsignal.rbi b/sig/appsignal.rbi index 1204c8ad9..7dacd1959 100644 --- a/sig/appsignal.rbi +++ b/sig/appsignal.rbi @@ -796,16 +796,65 @@ module Appsignal sig { params(params: T.nilable(T.any(T::Hash[String, Object], T::Array[Object])), block: T.proc.returns(T.any(T::Hash[String, Object], T::Array[Object]))).void } def self.add_params(params = nil, &block); end + # Add the request payload to the current transaction. + # + # The request payload is the parameters of an incoming request, such as + # the query string and the request body. In collector mode it maps to its + # own attribute, separate from the function parameters. + # + # Behaves like {#add_params}: merges when called multiple times, and a + # block takes precedence over the argument. + # + # _@param_ `params` — The request payload to add to the transaction. + # + # _@see_ `#add_function_parameters` + sig { params(params: T.nilable(T.any(T::Hash[String, Object], T::Array[Object])), block: T.proc.returns(T.any(T::Hash[String, Object], T::Array[Object]))).void } + def self.add_request_payload(params = nil, &block); end + + # Add the function parameters to the current transaction. + # + # The function parameters are the arguments a background job or function + # was called with. In collector mode they map to their own attribute, + # separate from the request payload. + # + # Behaves like {#add_params}: merges when called multiple times, and a + # block takes precedence over the argument. + # + # _@param_ `params` — The function parameters to add to the transaction. + # + # _@see_ `#add_request_payload` + sig { params(params: T.nilable(T.any(T::Hash[String, Object], T::Array[Object])), block: T.proc.returns(T.any(T::Hash[String, Object], T::Array[Object]))).void } + def self.add_function_parameters(params = nil, &block); end + + # Add the query parameters to the current transaction. + # + # The query parameters are the parameters parsed from an incoming + # request's query string. In collector mode they map to their own + # attribute, separate from the request payload and the function + # parameters. + # + # Behaves like {#add_params}: merges when called multiple times, and a + # block takes precedence over the argument. + # + # _@param_ `params` — The query parameters to add to the transaction. + # + # _@see_ `#add_request_payload` + sig { params(params: T.nilable(T.any(T::Hash[String, Object], T::Array[Object])), block: T.proc.returns(T.any(T::Hash[String, Object], T::Array[Object]))).void } + def self.add_query_parameters(params = nil, &block); end + # Mark the parameters sample data to be set as an empty value. # - # Use this helper to unset request parameters / background job arguments - # and not report any for this transaction. + # Use this helper to report no parameters for this transaction, whatever + # their source. # - # If parameters would normally be added by AppSignal instrumentations of - # libraries, these parameters will not be added to the Transaction. + # This suppresses every params channel. In collector mode, where the + # request payload and the function parameters (a background job's + # arguments) are tracked as separate attributes, it suppresses both, not + # only the request payload. Parameters that an AppSignal integration would + # otherwise add are not added. # - # Calling {#add_params} after this helper will add new parameters to the - # transaction. + # Calling {#add_params}, {#add_request_payload} or + # {#add_function_parameters} after this helper adds parameters again. # # _@see_ `Transaction#set_empty_params!` # @@ -1801,6 +1850,52 @@ module Appsignal sig { params(given_params: T.nilable(T.any(T::Hash[String, Object], T::Array[Object])), block: T.proc.returns(T.any(T::Hash[String, Object], T::Array[Object]))).void } def add_params(given_params = nil, &block); end + # Add the request payload to the transaction. + # + # These are the parameters of an incoming request, such as the query string + # and the request body. In collector mode they map to the request payload + # attribute. In agent mode they are the transaction's params. + # + # Behaves like {#add_params}: merges when called multiple times, and a + # block takes precedence over the argument. + # + # _@param_ `given_params` — The parameters to add to the transaction. + # + # _@see_ `#add_function_parameters` + sig { params(given_params: T.nilable(T.any(T::Hash[String, Object], T::Array[Object])), block: T.proc.returns(T.any(T::Hash[String, Object], T::Array[Object]))).void } + def add_request_payload(given_params = nil, &block); end + + # Add the function parameters to the transaction. + # + # These are the arguments a background job or function was called with. In + # collector mode they map to the function parameters attribute. In agent + # mode they are the transaction's params. + # + # Behaves like {#add_params}: merges when called multiple times, and a + # block takes precedence over the argument. + # + # _@param_ `given_params` — The parameters to add to the transaction. + # + # _@see_ `#add_request_payload` + sig { params(given_params: T.nilable(T.any(T::Hash[String, Object], T::Array[Object])), block: T.proc.returns(T.any(T::Hash[String, Object], T::Array[Object]))).void } + def add_function_parameters(given_params = nil, &block); end + + # Add the query parameters to the transaction. + # + # These are the parameters parsed from an incoming request's query string. + # In collector mode they map to their own attribute, separate from the + # request payload and the function parameters. In agent mode they are the + # transaction's params. + # + # Behaves like {#add_params}: merges when called multiple times, and a + # block takes precedence over the argument. + # + # _@param_ `given_params` — The parameters to add to the transaction. + # + # _@see_ `#add_request_payload` + sig { params(given_params: T.nilable(T.any(T::Hash[String, Object], T::Array[Object])), block: T.proc.returns(T.any(T::Hash[String, Object], T::Array[Object]))).void } + def add_query_parameters(given_params = nil, &block); end + # Add tags to the transaction. # # When this method is called multiple times, it will merge the tags. @@ -2554,16 +2649,65 @@ module Appsignal sig { params(params: T.nilable(T.any(T::Hash[String, Object], T::Array[Object])), block: T.proc.returns(T.any(T::Hash[String, Object], T::Array[Object]))).void } def add_params(params = nil, &block); end + # Add the request payload to the current transaction. + # + # The request payload is the parameters of an incoming request, such as + # the query string and the request body. In collector mode it maps to its + # own attribute, separate from the function parameters. + # + # Behaves like {#add_params}: merges when called multiple times, and a + # block takes precedence over the argument. + # + # _@param_ `params` — The request payload to add to the transaction. + # + # _@see_ `#add_function_parameters` + sig { params(params: T.nilable(T.any(T::Hash[String, Object], T::Array[Object])), block: T.proc.returns(T.any(T::Hash[String, Object], T::Array[Object]))).void } + def add_request_payload(params = nil, &block); end + + # Add the function parameters to the current transaction. + # + # The function parameters are the arguments a background job or function + # was called with. In collector mode they map to their own attribute, + # separate from the request payload. + # + # Behaves like {#add_params}: merges when called multiple times, and a + # block takes precedence over the argument. + # + # _@param_ `params` — The function parameters to add to the transaction. + # + # _@see_ `#add_request_payload` + sig { params(params: T.nilable(T.any(T::Hash[String, Object], T::Array[Object])), block: T.proc.returns(T.any(T::Hash[String, Object], T::Array[Object]))).void } + def add_function_parameters(params = nil, &block); end + + # Add the query parameters to the current transaction. + # + # The query parameters are the parameters parsed from an incoming + # request's query string. In collector mode they map to their own + # attribute, separate from the request payload and the function + # parameters. + # + # Behaves like {#add_params}: merges when called multiple times, and a + # block takes precedence over the argument. + # + # _@param_ `params` — The query parameters to add to the transaction. + # + # _@see_ `#add_request_payload` + sig { params(params: T.nilable(T.any(T::Hash[String, Object], T::Array[Object])), block: T.proc.returns(T.any(T::Hash[String, Object], T::Array[Object]))).void } + def add_query_parameters(params = nil, &block); end + # Mark the parameters sample data to be set as an empty value. # - # Use this helper to unset request parameters / background job arguments - # and not report any for this transaction. + # Use this helper to report no parameters for this transaction, whatever + # their source. # - # If parameters would normally be added by AppSignal instrumentations of - # libraries, these parameters will not be added to the Transaction. + # This suppresses every params channel. In collector mode, where the + # request payload and the function parameters (a background job's + # arguments) are tracked as separate attributes, it suppresses both, not + # only the request payload. Parameters that an AppSignal integration would + # otherwise add are not added. # - # Calling {#add_params} after this helper will add new parameters to the - # transaction. + # Calling {#add_params}, {#add_request_payload} or + # {#add_function_parameters} after this helper adds parameters again. # # _@see_ `Transaction#set_empty_params!` # diff --git a/sig/appsignal.rbs b/sig/appsignal.rbs index e95e54a4c..c70e59129 100644 --- a/sig/appsignal.rbs +++ b/sig/appsignal.rbs @@ -728,16 +728,62 @@ module Appsignal # _@see_ `https://docs.appsignal.com/guides/filter-data/filter-parameters.html` — Parameter filtering guide def self.add_params: (?(::Hash[String, Object] | ::Array[Object])? params) ?{ () -> (::Hash[String, Object] | ::Array[Object]) } -> void + # Add the request payload to the current transaction. + # + # The request payload is the parameters of an incoming request, such as + # the query string and the request body. In collector mode it maps to its + # own attribute, separate from the function parameters. + # + # Behaves like {#add_params}: merges when called multiple times, and a + # block takes precedence over the argument. + # + # _@param_ `params` — The request payload to add to the transaction. + # + # _@see_ `#add_function_parameters` + def self.add_request_payload: (?(::Hash[String, Object] | ::Array[Object])? params) ?{ () -> (::Hash[String, Object] | ::Array[Object]) } -> void + + # Add the function parameters to the current transaction. + # + # The function parameters are the arguments a background job or function + # was called with. In collector mode they map to their own attribute, + # separate from the request payload. + # + # Behaves like {#add_params}: merges when called multiple times, and a + # block takes precedence over the argument. + # + # _@param_ `params` — The function parameters to add to the transaction. + # + # _@see_ `#add_request_payload` + def self.add_function_parameters: (?(::Hash[String, Object] | ::Array[Object])? params) ?{ () -> (::Hash[String, Object] | ::Array[Object]) } -> void + + # Add the query parameters to the current transaction. + # + # The query parameters are the parameters parsed from an incoming + # request's query string. In collector mode they map to their own + # attribute, separate from the request payload and the function + # parameters. + # + # Behaves like {#add_params}: merges when called multiple times, and a + # block takes precedence over the argument. + # + # _@param_ `params` — The query parameters to add to the transaction. + # + # _@see_ `#add_request_payload` + def self.add_query_parameters: (?(::Hash[String, Object] | ::Array[Object])? params) ?{ () -> (::Hash[String, Object] | ::Array[Object]) } -> void + # Mark the parameters sample data to be set as an empty value. # - # Use this helper to unset request parameters / background job arguments - # and not report any for this transaction. + # Use this helper to report no parameters for this transaction, whatever + # their source. # - # If parameters would normally be added by AppSignal instrumentations of - # libraries, these parameters will not be added to the Transaction. + # This suppresses every params channel. In collector mode, where the + # request payload and the function parameters (a background job's + # arguments) are tracked as separate attributes, it suppresses both, not + # only the request payload. Parameters that an AppSignal integration would + # otherwise add are not added. # - # Calling {#add_params} after this helper will add new parameters to the - # transaction. + # Calling {#add_params}, {#add_request_payload} or + # {#add_function_parameters} after this helper adds parameters again. # # _@see_ `Transaction#set_empty_params!` # @@ -1644,6 +1690,49 @@ module Appsignal # _@see_ `https://docs.appsignal.com/guides/custom-data/sample-data.html` — Sample data guide def add_params: (?(::Hash[String, Object] | ::Array[Object])? given_params) ?{ () -> (::Hash[String, Object] | ::Array[Object]) } -> void + # Add the request payload to the transaction. + # + # These are the parameters of an incoming request, such as the query string + # and the request body. In collector mode they map to the request payload + # attribute. In agent mode they are the transaction's params. + # + # Behaves like {#add_params}: merges when called multiple times, and a + # block takes precedence over the argument. + # + # _@param_ `given_params` — The parameters to add to the transaction. + # + # _@see_ `#add_function_parameters` + def add_request_payload: (?(::Hash[String, Object] | ::Array[Object])? given_params) ?{ () -> (::Hash[String, Object] | ::Array[Object]) } -> void + + # Add the function parameters to the transaction. + # + # These are the arguments a background job or function was called with. In + # collector mode they map to the function parameters attribute. In agent + # mode they are the transaction's params. + # + # Behaves like {#add_params}: merges when called multiple times, and a + # block takes precedence over the argument. + # + # _@param_ `given_params` — The parameters to add to the transaction. + # + # _@see_ `#add_request_payload` + def add_function_parameters: (?(::Hash[String, Object] | ::Array[Object])? given_params) ?{ () -> (::Hash[String, Object] | ::Array[Object]) } -> void + + # Add the query parameters to the transaction. + # + # These are the parameters parsed from an incoming request's query string. + # In collector mode they map to their own attribute, separate from the + # request payload and the function parameters. In agent mode they are the + # transaction's params. + # + # Behaves like {#add_params}: merges when called multiple times, and a + # block takes precedence over the argument. + # + # _@param_ `given_params` — The parameters to add to the transaction. + # + # _@see_ `#add_request_payload` + def add_query_parameters: (?(::Hash[String, Object] | ::Array[Object])? given_params) ?{ () -> (::Hash[String, Object] | ::Array[Object]) } -> void + # Add tags to the transaction. # # When this method is called multiple times, it will merge the tags. @@ -2354,16 +2443,62 @@ module Appsignal # _@see_ `https://docs.appsignal.com/guides/filter-data/filter-parameters.html` — Parameter filtering guide def add_params: (?(::Hash[String, Object] | ::Array[Object])? params) ?{ () -> (::Hash[String, Object] | ::Array[Object]) } -> void + # Add the request payload to the current transaction. + # + # The request payload is the parameters of an incoming request, such as + # the query string and the request body. In collector mode it maps to its + # own attribute, separate from the function parameters. + # + # Behaves like {#add_params}: merges when called multiple times, and a + # block takes precedence over the argument. + # + # _@param_ `params` — The request payload to add to the transaction. + # + # _@see_ `#add_function_parameters` + def add_request_payload: (?(::Hash[String, Object] | ::Array[Object])? params) ?{ () -> (::Hash[String, Object] | ::Array[Object]) } -> void + + # Add the function parameters to the current transaction. + # + # The function parameters are the arguments a background job or function + # was called with. In collector mode they map to their own attribute, + # separate from the request payload. + # + # Behaves like {#add_params}: merges when called multiple times, and a + # block takes precedence over the argument. + # + # _@param_ `params` — The function parameters to add to the transaction. + # + # _@see_ `#add_request_payload` + def add_function_parameters: (?(::Hash[String, Object] | ::Array[Object])? params) ?{ () -> (::Hash[String, Object] | ::Array[Object]) } -> void + + # Add the query parameters to the current transaction. + # + # The query parameters are the parameters parsed from an incoming + # request's query string. In collector mode they map to their own + # attribute, separate from the request payload and the function + # parameters. + # + # Behaves like {#add_params}: merges when called multiple times, and a + # block takes precedence over the argument. + # + # _@param_ `params` — The query parameters to add to the transaction. + # + # _@see_ `#add_request_payload` + def add_query_parameters: (?(::Hash[String, Object] | ::Array[Object])? params) ?{ () -> (::Hash[String, Object] | ::Array[Object]) } -> void + # Mark the parameters sample data to be set as an empty value. # - # Use this helper to unset request parameters / background job arguments - # and not report any for this transaction. + # Use this helper to report no parameters for this transaction, whatever + # their source. # - # If parameters would normally be added by AppSignal instrumentations of - # libraries, these parameters will not be added to the Transaction. + # This suppresses every params channel. In collector mode, where the + # request payload and the function parameters (a background job's + # arguments) are tracked as separate attributes, it suppresses both, not + # only the request payload. Parameters that an AppSignal integration would + # otherwise add are not added. # - # Calling {#add_params} after this helper will add new parameters to the - # transaction. + # Calling {#add_params}, {#add_request_payload} or + # {#add_function_parameters} after this helper adds parameters again. # # _@see_ `Transaction#set_empty_params!` # diff --git a/spec/lib/appsignal/integrations/webmachine_spec.rb b/spec/lib/appsignal/integrations/webmachine_spec.rb index e7a96858b..4bc932b57 100644 --- a/spec/lib/appsignal/integrations/webmachine_spec.rb +++ b/spec/lib/appsignal/integrations/webmachine_spec.rb @@ -116,7 +116,7 @@ def to_html end end - describe "sets the params" do + describe "sets the query parameters" do it "in agent mode", :agent_mode do start_agent perform @@ -126,7 +126,7 @@ def to_html it "in collector mode", :collector_mode do start_collector_agent perform - params = JSON.parse(root_span.attributes["appsignal.request.payload"]) + params = JSON.parse(root_span.attributes["appsignal.request.query_parameters"]) expect(params).to include("param1" => "value1", "param2" => "value2") end end @@ -210,7 +210,7 @@ def to_html end end - describe "sets the params" do + describe "sets the query parameters" do it "in agent mode", :agent_mode do start_agent set_current_transaction(transaction) @@ -226,7 +226,7 @@ def to_html # 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"]) + params = JSON.parse(root_span.attributes["appsignal.request.query_parameters"]) expect(params).to include("param1" => "value1", "param2" => "value2") end end diff --git a/spec/lib/appsignal/transaction/extension_backend_spec.rb b/spec/lib/appsignal/transaction/extension_backend_spec.rb index b10c6f1d9..4c18370f8 100644 --- a/spec/lib/appsignal/transaction/extension_backend_spec.rb +++ b/spec/lib/appsignal/transaction/extension_backend_spec.rb @@ -47,6 +47,17 @@ end end + describe "#params_mapping" do + it "maps every params channel to the single agent params bucket" do + expect(backend.params_mapping).to eq( + :params => :params, + :request_payload => :params, + :function_parameters => :params, + :query_parameters => :params + ) + end + end + describe "method delegation" do let(:handle) { backend.instance_variable_get(:@handle) } diff --git a/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb b/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb index 741df5084..f4f37e3ef 100644 --- a/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb +++ b/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb @@ -372,7 +372,48 @@ def link_span_context(root) end it "accepts #set_sample_data without raising" do - expect { create_backend.set_sample_data("params", "anything") }.not_to raise_error + expect { create_backend.set_sample_data("request_payload", "anything") }.not_to raise_error + end + end + + describe "#params_mapping" do + it "keeps the request payload and function parameters in separate buckets" do + expect(create_backend.params_mapping).to eq( + :params => :request_payload, + :request_payload => :request_payload, + :function_parameters => :function_parameters, + :query_parameters => :query_parameters + ) + end + end + + describe "#set_sample_data params channels" do + def attribute_for(backend, key, data) + backend.set_sample_data(key, data) + backend.complete + span_exporter.finished_spans.first.attributes + end + + it "routes the request_payload channel to appsignal.request.payload" do + attributes = attribute_for(create_backend, "request_payload", "id" => 1) + + expect(attributes["appsignal.request.payload"]).to eq(JSON.generate("id" => 1)) + expect(attributes).to_not have_key("appsignal.function.parameters") + end + + it "routes the function_parameters channel to appsignal.function.parameters" do + attributes = attribute_for(create_backend, "function_parameters", "id" => 1) + + expect(attributes["appsignal.function.parameters"]).to eq(JSON.generate("id" => 1)) + expect(attributes).to_not have_key("appsignal.request.payload") + end + + it "routes the query_parameters channel to appsignal.request.query_parameters" do + attributes = attribute_for(create_backend, "query_parameters", "id" => 1) + + expect(attributes["appsignal.request.query_parameters"]).to eq(JSON.generate("id" => 1)) + expect(attributes).to_not have_key("appsignal.request.payload") + expect(attributes).to_not have_key("appsignal.function.parameters") end end diff --git a/spec/lib/appsignal/transaction_spec.rb b/spec/lib/appsignal/transaction_spec.rb index 188e85d06..12c639ec8 100644 --- a/spec/lib/appsignal/transaction_spec.rb +++ b/spec/lib/appsignal/transaction_spec.rb @@ -48,7 +48,18 @@ end context "when an explicit backend is passed in the initialiser" do - let(:backend) { "some_backend" } + let(:backend) do + # The transaction reads `params_mapping` from its backend on creation, + # so the stand-in has to answer it. + double( + "backend", + :params_mapping => { + :params => :params, + :request_payload => :params, + :function_parameters => :params + } + ) + end it "assigns the backend to the transaction" do expect(described_class.new("web", :backend => backend).backend).to be(backend) @@ -1197,7 +1208,7 @@ def perform logs = capture_logs { transaction._sample } expect(logs).to contains_log( :error, - "Exception while fetching params: RuntimeError: uh oh" + "Exception while fetching params (params): RuntimeError: uh oh" ) end @@ -1208,7 +1219,7 @@ def perform logs = capture_logs { transaction.complete } expect(logs).to contains_log( :error, - "Exception while fetching params: RuntimeError: uh oh" + "Exception while fetching params (request_payload): RuntimeError: uh oh" ) expect(root_span.attributes).to_not have_key("appsignal.request.payload") end @@ -1445,6 +1456,267 @@ def perform end end + describe "#add_request_payload" do + let(:transaction) { new_transaction } + + describe "setting the request payload on the transaction" do + def perform + transaction.add_request_payload("key" => "value") + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + transaction._sample + + expect(transaction).to include_params("key" => "value") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + expect(JSON.parse(root_span.attributes["appsignal.request.payload"])) + .to eq("key" => "value") + expect(root_span.attributes).to_not have_key("appsignal.function.parameters") + end + end + + describe "merging and giving the block precedence" do + def perform + transaction.add_request_payload("abc" => "value") + transaction.add_request_payload("def" => "value") + transaction.add_request_payload { { "xyz" => "value" } } + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + transaction._sample + + expect(transaction).to include_params( + "abc" => "value", "def" => "value", "xyz" => "value" + ) + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + expect(JSON.parse(root_span.attributes["appsignal.request.payload"])).to eq( + "abc" => "value", "def" => "value", "xyz" => "value" + ) + end + end + + describe "#add_request_payload_if_nil does not override existing params" do + def perform + transaction.add_request_payload("original" => "value") + transaction.add_request_payload_if_nil("other" => "value") + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + transaction._sample + + expect(transaction).to include_params("original" => "value") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + expect(JSON.parse(root_span.attributes["appsignal.request.payload"])) + .to eq("original" => "value") + end + end + end + + describe "#add_function_parameters" do + let(:transaction) { new_transaction } + + describe "setting the function parameters on the transaction" do + def perform + transaction.add_function_parameters("key" => "value") + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + transaction._sample + + expect(transaction).to include_params("key" => "value") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + expect(JSON.parse(root_span.attributes["appsignal.function.parameters"])) + .to eq("key" => "value") + expect(root_span.attributes).to_not have_key("appsignal.request.payload") + end + end + + describe "#add_function_parameters_if_nil does not override existing params" do + def perform + transaction.add_function_parameters("original" => "value") + transaction.add_function_parameters_if_nil("other" => "value") + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + transaction._sample + + expect(transaction).to include_params("original" => "value") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + expect(JSON.parse(root_span.attributes["appsignal.function.parameters"])) + .to eq("original" => "value") + end + end + + describe "#set_empty_params! also suppresses function parameters set later" do + def perform + transaction.set_empty_params! + transaction.add_function_parameters_if_nil("key" => "value") + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + transaction._sample + + expect(transaction).to_not include_params + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + expect(root_span.attributes).to_not have_key("appsignal.function.parameters") + end + end + end + + describe "#add_query_parameters" do + let(:transaction) { new_transaction } + + describe "setting the query parameters on the transaction" do + def perform + transaction.add_query_parameters("key" => "value") + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + transaction._sample + + expect(transaction).to include_params("key" => "value") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + expect(JSON.parse(root_span.attributes["appsignal.request.query_parameters"])) + .to eq("key" => "value") + expect(root_span.attributes).to_not have_key("appsignal.request.payload") + expect(root_span.attributes).to_not have_key("appsignal.function.parameters") + end + end + + describe "merging and giving the block precedence" do + def perform + transaction.add_query_parameters("abc" => "value") + transaction.add_query_parameters("def" => "value") + transaction.add_query_parameters { { "xyz" => "value" } } + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + transaction._sample + + expect(transaction).to include_params( + "abc" => "value", "def" => "value", "xyz" => "value" + ) + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + expect(JSON.parse(root_span.attributes["appsignal.request.query_parameters"])).to eq( + "abc" => "value", "def" => "value", "xyz" => "value" + ) + end + end + + describe "#add_query_parameters_if_nil does not override existing params" do + def perform + transaction.add_query_parameters("original" => "value") + transaction.add_query_parameters_if_nil("other" => "value") + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + transaction._sample + + expect(transaction).to include_params("original" => "value") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + expect(JSON.parse(root_span.attributes["appsignal.request.query_parameters"])) + .to eq("original" => "value") + end + end + end + + describe "#add_params deprecation" do + let(:transaction) { new_transaction } + + before { Appsignal::Transaction.reset_params_deprecation_warning! } + + it "does not warn in agent mode", :agent_mode do + start_agent(**start_agent_args) + + expect(Appsignal::Utils::StdoutAndLoggerMessage).to_not receive(:warning) + + transaction.add_params("key" => "value") + end + + it "warns once in collector mode", :collector_mode do + start_collector_agent + + expect(Appsignal::Utils::StdoutAndLoggerMessage).to receive(:warning) + .with(a_string_including("add_request_payload", "add_function_parameters")) + .once + + transaction.add_params("key" => "value") + transaction.add_params("key2" => "value") + end + end + describe "#add_session_data" do let(:transaction) { new_transaction } @@ -3091,22 +3363,25 @@ def perform transaction.set_params(set) end - def expect_unsupported_type_logs(logs) + # The log names the bucket the params were stored in. Legacy `set_params` + # goes to the single `params` bucket in agent mode and to the + # `request_payload` bucket in collector mode, so the key differs per mode. + def expect_unsupported_type_logs(logs, key) expect(logs).to contains_log( :error, - %(Sample data 'params': Unsupported data type 'String' received: "some string") + %(Sample data '#{key}': Unsupported data type 'String' received: "some string") ) expect(logs).to contains_log( :error, - %(Sample data 'params': Unsupported data type 'Integer' received: 123) + %(Sample data '#{key}': Unsupported data type 'Integer' received: 123) ) expect(logs).to contains_log( :error, - %(Sample data 'params': Unsupported data type 'Class' received: #|\])/ + /Sample data '#{key}': Unsupported data type 'Set' received: (#|\])/ ) end @@ -3118,7 +3393,7 @@ def expect_unsupported_type_logs(logs) end expect(transaction).to_not include_params - expect_unsupported_type_logs(logs) + expect_unsupported_type_logs(logs, "params") end it "in collector mode", :collector_mode do @@ -3129,7 +3404,7 @@ def expect_unsupported_type_logs(logs) end expect(root_span.attributes).to_not have_key("appsignal.request.payload") - expect_unsupported_type_logs(logs) + expect_unsupported_type_logs(logs, "request_payload") end end @@ -3206,7 +3481,10 @@ def perform perform transaction.complete - expect(JSON.parse(root_span.attributes["appsignal.request.payload"])).to eq(expected) + # A raw `params` key is not one of the collector's known channels, so it + # passes through as `appsignal.params`. The request payload and function + # parameters channels are covered by their own specs above. + expect(JSON.parse(root_span.attributes["appsignal.params"])).to eq(expected) end end diff --git a/spec/lib/appsignal_spec.rb b/spec/lib/appsignal_spec.rb index a49e3a5db..024cc4bd8 100644 --- a/spec/lib/appsignal_spec.rb +++ b/spec/lib/appsignal_spec.rb @@ -1692,6 +1692,126 @@ def perform end end + describe ".add_request_payload" do + before do |example| + start_agent unless example.metadata[:agent_mode] || example.metadata[:collector_mode] + end + + describe "setting the request payload through the public API" do + let(:transaction) { http_request_transaction } + + def perform + set_current_transaction(transaction) + Appsignal.add_request_payload("param1" => "value1") + end + + it "in agent mode", :agent_mode do + start_agent + perform + + transaction._sample + expect(transaction).to 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 "without transaction" do + it "does not set the request payload on any transaction" do + expect_any_instance_of(Appsignal::Transaction).to_not receive(:add_request_payload) + + Appsignal.add_request_payload("a" => "b") + end + end + end + + describe ".add_function_parameters" do + before do |example| + start_agent unless example.metadata[:agent_mode] || example.metadata[:collector_mode] + end + + describe "setting the function parameters through the public API" do + let(:transaction) { background_job_transaction } + + def perform + set_current_transaction(transaction) + Appsignal.add_function_parameters("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.function.parameters"])) + .to eq("param1" => "value1") + end + end + + context "without transaction" do + it "does not set the function parameters on any transaction" do + expect_any_instance_of(Appsignal::Transaction).to_not receive(:add_function_parameters) + + Appsignal.add_function_parameters("a" => "b") + end + end + end + + describe ".add_query_parameters" do + before do |example| + start_agent unless example.metadata[:agent_mode] || example.metadata[:collector_mode] + end + + describe "setting the query parameters through the public API" do + let(:transaction) { http_request_transaction } + + def perform + set_current_transaction(transaction) + Appsignal.add_query_parameters("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.query_parameters"])) + .to eq("param1" => "value1") + end + end + + context "without transaction" do + it "does not set the query parameters on any transaction" do + expect_any_instance_of(Appsignal::Transaction).to_not receive(:add_query_parameters) + + Appsignal.add_query_parameters("a" => "b") + end + end + end + describe ".set_empty_params!" do describe "marking parameters to be sent as an empty value" do let(:transaction) { http_request_transaction } From 0ef05280be2adb86583b6a9b48d2c2c2e1552345 Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Thu, 23 Jul 2026 12:29:41 +0200 Subject: [PATCH 33/69] Parent background job spans to the enqueuer In collector mode a background job started its own trace and linked back to the span that enqueued it. The relationship is `:both` now, so the job continues the enqueuer's trace as a child span and keeps the link back to the enqueuing span. Delayed Job has no carrier for trace context across the enqueue and perform boundary, so its jobs stay their own trace and the change is a no-op for it. --- lib/appsignal/hooks/active_job.rb | 2 +- lib/appsignal/integrations/delayed_job_plugin.rb | 2 +- lib/appsignal/integrations/que.rb | 2 +- lib/appsignal/integrations/resque.rb | 2 +- lib/appsignal/integrations/shoryuken.rb | 2 +- lib/appsignal/integrations/sidekiq.rb | 4 ++-- spec/lib/appsignal/hooks/activejob_spec.rb | 8 +++++--- spec/lib/appsignal/integrations/que_spec.rb | 5 +++-- spec/lib/appsignal/integrations/resque_spec.rb | 5 +++-- spec/lib/appsignal/integrations/shoryuken_spec.rb | 5 +++-- spec/lib/appsignal/integrations/sidekiq_spec.rb | 12 +++++++----- 11 files changed, 28 insertions(+), 21 deletions(-) diff --git a/lib/appsignal/hooks/active_job.rb b/lib/appsignal/hooks/active_job.rb index d4088ad76..77b0b2403 100644 --- a/lib/appsignal/hooks/active_job.rb +++ b/lib/appsignal/hooks/active_job.rb @@ -80,7 +80,7 @@ def execute(job) :opentelemetry_context => Appsignal::OpenTelemetry.extract_job_context(job), :opentelemetry_scope => ["appsignal-ruby/active_job", Appsignal::VERSION], :opentelemetry_kind => :consumer, - :opentelemetry_relationship => :link + :opentelemetry_relationship => :both ) end diff --git a/lib/appsignal/integrations/delayed_job_plugin.rb b/lib/appsignal/integrations/delayed_job_plugin.rb index 5854bc17f..2adcaa4fe 100644 --- a/lib/appsignal/integrations/delayed_job_plugin.rb +++ b/lib/appsignal/integrations/delayed_job_plugin.rb @@ -62,7 +62,7 @@ def self.invoke_with_instrumentation(job, block) Appsignal::Transaction::BACKGROUND_JOB, :opentelemetry_scope => ["appsignal-ruby/delayed_job", Appsignal::VERSION], :opentelemetry_kind => :consumer, - :opentelemetry_relationship => :link + :opentelemetry_relationship => :both ) begin diff --git a/lib/appsignal/integrations/que.rb b/lib/appsignal/integrations/que.rb index 9a4b36d81..cd336bb2a 100644 --- a/lib/appsignal/integrations/que.rb +++ b/lib/appsignal/integrations/que.rb @@ -86,7 +86,7 @@ def _run(*args) :opentelemetry_context => QueTraceContext.extract(local_attrs.dig(:data, :tags)), :opentelemetry_scope => ["appsignal-ruby/que", Appsignal::VERSION], :opentelemetry_kind => :consumer, - :opentelemetry_relationship => :link + :opentelemetry_relationship => :both ) begin diff --git a/lib/appsignal/integrations/resque.rb b/lib/appsignal/integrations/resque.rb index 54ae846db..2033470f4 100644 --- a/lib/appsignal/integrations/resque.rb +++ b/lib/appsignal/integrations/resque.rb @@ -12,7 +12,7 @@ def perform :opentelemetry_context => Appsignal::OpenTelemetry.extract_job_context(payload), :opentelemetry_scope => ["appsignal-ruby/resque", Appsignal::VERSION], :opentelemetry_kind => :consumer, - :opentelemetry_relationship => :link + :opentelemetry_relationship => :both ) Appsignal.instrument( diff --git a/lib/appsignal/integrations/shoryuken.rb b/lib/appsignal/integrations/shoryuken.rb index 1a7fb7db9..ee5c30868 100644 --- a/lib/appsignal/integrations/shoryuken.rb +++ b/lib/appsignal/integrations/shoryuken.rb @@ -82,7 +82,7 @@ def call(worker_instance, queue, sqs_msg, body, &block) :opentelemetry_context => context, :opentelemetry_scope => ["appsignal-ruby/shoryuken", Appsignal::VERSION], :opentelemetry_kind => :consumer, - :opentelemetry_relationship => :link + :opentelemetry_relationship => :both ) Appsignal.instrument( diff --git a/lib/appsignal/integrations/sidekiq.rb b/lib/appsignal/integrations/sidekiq.rb index 124dcaa96..86eec825b 100644 --- a/lib/appsignal/integrations/sidekiq.rb +++ b/lib/appsignal/integrations/sidekiq.rb @@ -42,7 +42,7 @@ def call(exception, sidekiq_context, _sidekiq_config = nil) Appsignal::Transaction::BACKGROUND_JOB, :opentelemetry_scope => ["appsignal-ruby/sidekiq", Appsignal::VERSION], :opentelemetry_kind => :consumer, - :opentelemetry_relationship => :link + :opentelemetry_relationship => :both ) transaction.set_action_if_nil("SidekiqInternal") transaction.set_metadata("sidekiq_error", sidekiq_context[:context]) @@ -163,7 +163,7 @@ def call(_worker, item, _queue, &block) :opentelemetry_context => Appsignal::OpenTelemetry.extract_job_context(item), :opentelemetry_scope => ["appsignal-ruby/sidekiq", Appsignal::VERSION], :opentelemetry_kind => :consumer, - :opentelemetry_relationship => :link + :opentelemetry_relationship => :both ) transaction.set_action_if_nil(action_name) diff --git a/spec/lib/appsignal/hooks/activejob_spec.rb b/spec/lib/appsignal/hooks/activejob_spec.rb index beb8ae003..f742994f0 100644 --- a/spec/lib/appsignal/hooks/activejob_spec.rb +++ b/spec/lib/appsignal/hooks/activejob_spec.rb @@ -671,13 +671,15 @@ def perform_with_incoming_context perform_active_job { ActiveJob::Base.execute(job_data) } end - it "starts a linked trace in collector mode", :collector_mode do + it "parents and links the job trace to the enqueuer 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. + # The job continues the enqueuer's trace as a child and links back to it. expect(root_span.kind).to eq(:consumer) - expect(root_span.hex_trace_id).to_not eq(trace_id_hex) + expect(root_span.hex_trace_id).to eq(trace_id_hex) + expect(root_span.parent_span_id.unpack1("H*")).to eq(span_id_hex) expect(root_span.links.size).to eq(1) link = root_span.links.first.span_context expect(link.hex_trace_id).to eq(trace_id_hex) diff --git a/spec/lib/appsignal/integrations/que_spec.rb b/spec/lib/appsignal/integrations/que_spec.rb index d0d34d226..e9cc34a07 100644 --- a/spec/lib/appsignal/integrations/que_spec.rb +++ b/spec/lib/appsignal/integrations/que_spec.rb @@ -345,9 +345,10 @@ def perform start_collector_agent perform - # The job runs as its own trace, linked back to the enqueuer. + # The job continues the enqueuer's trace as a child and links back to it. expect(root_span.kind).to eq(:consumer) - expect(root_span.hex_trace_id).to_not eq(trace_id_hex) + expect(root_span.hex_trace_id).to eq(trace_id_hex) + expect(root_span.parent_span_id.unpack1("H*")).to eq(span_id_hex) expect(root_span.links.size).to eq(1) link_context = root_span.links.first.span_context expect(link_context.hex_trace_id).to eq(trace_id_hex) diff --git a/spec/lib/appsignal/integrations/resque_spec.rb b/spec/lib/appsignal/integrations/resque_spec.rb index 0bb883345..e53dd71eb 100644 --- a/spec/lib/appsignal/integrations/resque_spec.rb +++ b/spec/lib/appsignal/integrations/resque_spec.rb @@ -92,9 +92,10 @@ def perform expect(Appsignal).to receive(:stop) perform - # The job runs as its own trace, linked back to the span that enqueued it. + # The job continues the enqueuer's trace as a child and links back to it. expect(root_span.kind).to eq(:consumer) - expect(root_span.hex_trace_id).to_not eq(trace_id_hex) + expect(root_span.hex_trace_id).to eq(trace_id_hex) + expect(root_span.parent_span_id.unpack1("H*")).to eq(span_id_hex) expect(root_span.links.size).to eq(1) link_context = root_span.links.first.span_context expect(link_context.hex_trace_id).to eq(trace_id_hex) diff --git a/spec/lib/appsignal/integrations/shoryuken_spec.rb b/spec/lib/appsignal/integrations/shoryuken_spec.rb index c029f84a2..848e27a9a 100644 --- a/spec/lib/appsignal/integrations/shoryuken_spec.rb +++ b/spec/lib/appsignal/integrations/shoryuken_spec.rb @@ -211,9 +211,10 @@ def perform start_collector_agent perform - # The job runs as its own trace, linked back to the span that enqueued it. + # The job continues the enqueuer's trace as a child and links back to it. expect(root_span.kind).to eq(:consumer) - expect(root_span.hex_trace_id).to_not eq(trace_id_hex) + expect(root_span.hex_trace_id).to eq(trace_id_hex) + expect(root_span.parent_span_id.unpack1("H*")).to eq(span_id_hex) expect(root_span.links.size).to eq(1) link_context = root_span.links.first.span_context expect(link_context.hex_trace_id).to eq(trace_id_hex) diff --git a/spec/lib/appsignal/integrations/sidekiq_spec.rb b/spec/lib/appsignal/integrations/sidekiq_spec.rb index 5a3123857..31758309a 100644 --- a/spec/lib/appsignal/integrations/sidekiq_spec.rb +++ b/spec/lib/appsignal/integrations/sidekiq_spec.rb @@ -571,10 +571,12 @@ def expect_no_yaml_parse_error(logs) 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 + # A job continues the enqueuer's trace as a child span and also links back + # to the enqueuing span. + def expect_parented_and_linked_to_remote expect(root_span.kind).to eq(:consumer) - expect(root_span.hex_trace_id).to_not eq(trace_id_hex) + expect(root_span.hex_trace_id).to eq(trace_id_hex) + expect(root_span.parent_span_id.unpack1("H*")).to eq(span_id_hex) expect(root_span.links.size).to eq(1) link_context = root_span.links.first.span_context expect(link_context.hex_trace_id).to eq(trace_id_hex) @@ -596,7 +598,7 @@ def expect_linked_back_to_remote start_collector_agent perform_sidekiq_job - expect_linked_back_to_remote + expect_parented_and_linked_to_remote end end @@ -617,7 +619,7 @@ def expect_linked_back_to_remote start_collector_agent perform_sidekiq_job - expect_linked_back_to_remote + expect_parented_and_linked_to_remote end end end From 4d693e287354a900933fdf050c197327e465faf0 Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Wed, 29 Jul 2026 15:00:00 +0200 Subject: [PATCH 34/69] Document the OpenTelemetry keyword arguments `opentelemetry_kind`, `opentelemetry_relationship` and `opentelemetry_context` carried documentation, but `opentelemetry_scope` did not, and neither did `opentelemetry_kind` on `Appsignal.instrument`. The type signatures are generated from these comments, so the undocumented arguments were generated as `untyped`. They are documented everywhere they are accepted now, so `opentelemetry_scope` generates as a `[String, String]` pair. The signatures are regenerated to match. --- lib/appsignal/helpers/instrumentation.rb | 25 ++++++++++ lib/appsignal/transaction.rb | 4 ++ sig/appsignal.rbi | 60 ++++++++++++++++++------ sig/appsignal.rbs | 60 ++++++++++++++++++------ 4 files changed, 119 insertions(+), 30 deletions(-) diff --git a/lib/appsignal/helpers/instrumentation.rb b/lib/appsignal/helpers/instrumentation.rb index 3f0bd0a51..937c23b7f 100644 --- a/lib/appsignal/helpers/instrumentation.rb +++ b/lib/appsignal/helpers/instrumentation.rb @@ -110,6 +110,10 @@ module Instrumentation # one of `:parent`, `:link`, `:both` or `:none`. Defaults to `:parent`. # @param opentelemetry_context In collector mode, an incoming OpenTelemetry # trace context to relate this transaction's span to. + # @param opentelemetry_scope [Array(String, String)] In collector mode, the + # OpenTelemetry instrumentation scope to record this transaction's spans + # under, given as a `[name, version]` pair. Defaults to the AppSignal + # scope. # @yield [] The block to monitor. # @yieldreturn [Object] The return value of the block # @raise [Exception] Any exception that occurs within the given block is @@ -189,6 +193,10 @@ def monitor( # rubocop:disable Metrics/ParameterLists # within the block with {#set_action}. # This will not update the active transaction's action if # {.monitor} is called when another transaction is already active. + # @param opentelemetry_scope [Array(String, String)] In collector mode, the + # OpenTelemetry instrumentation scope to record this transaction's spans + # under, given as a `[name, version]` pair. Defaults to the AppSignal + # scope. # @yield [] The block to monitor. # @yieldreturn [Object] The return value of the block # @raise [Exception] Any exception that occurs within the given block is @@ -252,6 +260,10 @@ def monitor_and_stop(action:, namespace: nil, opentelemetry_scope: nil, &block) # one of `:parent`, `:link`, `:both` or `:none`. Defaults to `:parent`. # @param opentelemetry_context In collector mode, an incoming OpenTelemetry # trace context to relate this transaction's span to. + # @param opentelemetry_scope [Array(String, String)] In collector mode, the + # OpenTelemetry instrumentation scope to record this transaction's spans + # under, given as a `[name, version]` pair. Defaults to the AppSignal + # scope. # @yield [transaction] yields block to allow modification of the # transaction before it's send. # @yieldparam transaction [Transaction] yields the AppSignal transaction @@ -405,6 +417,10 @@ def set_error(exception) # @param opentelemetry_context In collector mode, an incoming OpenTelemetry # trace context to relate this transaction's span to. Only used when a # new transaction is created. + # @param opentelemetry_scope [Array(String, String)] In collector mode, the + # OpenTelemetry instrumentation scope to record this transaction's spans + # under, given as a `[name, version]` pair. Defaults to the AppSignal + # scope. # @yield [transaction] yields block to allow modification of the # transaction. # @yieldparam transaction [Transaction] yields the AppSignal transaction @@ -935,6 +951,12 @@ def add_breadcrumb(category, action, message = "", metadata = {}, time = Time.no # instrumented. Accepted values are {EventFormatter::DEFAULT} and # {EventFormatter::SQL_BODY_FORMAT}, but we recommend you use # {.instrument_sql} instead of {EventFormatter::SQL_BODY_FORMAT}. + # @param opentelemetry_kind [Symbol] In collector mode, the OpenTelemetry + # span kind for the event's span, such as `:client` for an outgoing HTTP + # request. Defaults to the OpenTelemetry default of `:internal`. + # @param opentelemetry_scope [Array(String, String)] In collector mode, the + # OpenTelemetry instrumentation scope to record the event's span under, + # given as a `[name, version]` pair. Defaults to the AppSignal scope. # @yield [] yields the given block of code instrumented in an AppSignal # event. # @return [Object] Returns the block's return value. @@ -986,6 +1008,9 @@ def instrument( # rubocop:disable Metrics/ParameterLists # naming guide listed under "See also". # @param title [String, nil] Human readable name of the event. # @param body [String, nil] SQL query that's being executed. + # @param opentelemetry_scope [Array(String, String)] In collector mode, the + # OpenTelemetry instrumentation scope to record the event's span under, + # given as a `[name, version]` pair. Defaults to the AppSignal scope. # @yield [] yields the given block of code instrumented in an AppSignal # event. # @return [Object] Returns the block's return value. diff --git a/lib/appsignal/transaction.rb b/lib/appsignal/transaction.rb index 4009bb9aa..328593215 100644 --- a/lib/appsignal/transaction.rb +++ b/lib/appsignal/transaction.rb @@ -41,6 +41,10 @@ class << self # one of `:parent`, `:link`, `:both` or `:none`. Defaults to `:parent`. # @param opentelemetry_context In collector mode, an incoming OpenTelemetry # trace context to relate this transaction's span to. + # @param opentelemetry_scope [Array(String, String)] In collector mode, the + # OpenTelemetry instrumentation scope to record this transaction's spans + # under, given as a `[name, version]` pair. Defaults to the AppSignal + # scope. # @return [Transaction] def create( namespace, diff --git a/sig/appsignal.rbi b/sig/appsignal.rbi index 7dacd1959..5b53f6a17 100644 --- a/sig/appsignal.rbi +++ b/sig/appsignal.rbi @@ -307,6 +307,8 @@ module Appsignal # # _@param_ `opentelemetry_context` — In collector mode, an incoming OpenTelemetry trace context to relate this transaction's span to. # + # _@param_ `opentelemetry_scope` — In collector mode, the OpenTelemetry instrumentation scope to record this transaction's spans under, given as a `[name, version]` pair. Defaults to the AppSignal scope. + # # _@return_ — The value of the given block is returned. # Returns `nil` if there already is a transaction active and no block # was given. @@ -402,7 +404,7 @@ module Appsignal action: T.any(String, Symbol, NilClass), namespace: T.nilable(T.any(String, Symbol)), opentelemetry_context: T.untyped, - opentelemetry_scope: T.untyped, + opentelemetry_scope: T.nilable([String, String]), opentelemetry_kind: T.nilable(Symbol), opentelemetry_relationship: T.nilable(Symbol), blk: T.proc.returns(Object) @@ -422,6 +424,8 @@ module Appsignal # # _@param_ `action` — The action name for the transaction. The action name is required to be set for the transaction to be reported. The argument can be set to `nil` or `:set_later` if the action is set within the block with {#set_action}. This will not update the active transaction's action if {.monitor} is called when another transaction is already active. # + # _@param_ `opentelemetry_scope` — In collector mode, the OpenTelemetry instrumentation scope to record this transaction's spans under, given as a `[name, version]` pair. Defaults to the AppSignal scope. + # # _@return_ — The value of the given block is returned. # # _@see_ `monitor` @@ -429,7 +433,7 @@ module Appsignal params( action: T.any(String, Symbol, NilClass), namespace: T.nilable(T.any(String, Symbol)), - opentelemetry_scope: T.untyped, + opentelemetry_scope: T.nilable([String, String]), block: T.proc.returns(Object) ).returns(T.nilable(Object)) end @@ -458,6 +462,8 @@ module Appsignal # # _@param_ `opentelemetry_context` — In collector mode, an incoming OpenTelemetry trace context to relate this transaction's span to. # + # _@param_ `opentelemetry_scope` — In collector mode, the OpenTelemetry instrumentation scope to record this transaction's spans under, given as a `[name, version]` pair. Defaults to the AppSignal scope. + # # Send an exception # ```ruby # begin @@ -482,7 +488,7 @@ module Appsignal params( error: Exception, opentelemetry_context: T.untyped, - opentelemetry_scope: T.untyped, + opentelemetry_scope: T.nilable([String, String]), opentelemetry_kind: T.nilable(Symbol), opentelemetry_relationship: T.nilable(Symbol), block: T.proc.params(transaction: Transaction).void @@ -567,6 +573,8 @@ module Appsignal # # _@param_ `opentelemetry_context` — In collector mode, an incoming OpenTelemetry trace context to relate this transaction's span to. Only used when a new transaction is created. # + # _@param_ `opentelemetry_scope` — In collector mode, the OpenTelemetry instrumentation scope to record this transaction's spans under, given as a `[name, version]` pair. Defaults to the AppSignal scope. + # # ```ruby # class SomeController < ApplicationController # def create @@ -592,7 +600,7 @@ module Appsignal params( exception: Exception, opentelemetry_context: T.untyped, - opentelemetry_scope: T.untyped, + opentelemetry_scope: T.nilable([String, String]), opentelemetry_kind: T.nilable(Symbol), opentelemetry_relationship: T.nilable(Symbol), block: T.proc.params(transaction: Transaction).void @@ -993,6 +1001,10 @@ module Appsignal # # _@param_ `body_format` — Enum for the type of event that is instrumented. Accepted values are {EventFormatter::DEFAULT} and {EventFormatter::SQL_BODY_FORMAT}, but we recommend you use {.instrument_sql} instead of {EventFormatter::SQL_BODY_FORMAT}. # + # _@param_ `opentelemetry_kind` — In collector mode, the OpenTelemetry span kind for the event's span, such as `:client` for an outgoing HTTP request. Defaults to the OpenTelemetry default of `:internal`. + # + # _@param_ `opentelemetry_scope` — In collector mode, the OpenTelemetry instrumentation scope to record the event's span under, given as a `[name, version]` pair. Defaults to the AppSignal scope. + # # _@return_ — Returns the block's return value. # # Simple instrumentation @@ -1024,8 +1036,8 @@ module Appsignal title: T.nilable(String), body: T.nilable(String), body_format: Integer, - opentelemetry_kind: T.untyped, - opentelemetry_scope: T.untyped, + opentelemetry_kind: T.nilable(Symbol), + opentelemetry_scope: T.nilable([String, String]), block: T.untyped ).returns(Object) end @@ -1041,6 +1053,8 @@ module Appsignal # # _@param_ `body` — SQL query that's being executed. # + # _@param_ `opentelemetry_scope` — In collector mode, the OpenTelemetry instrumentation scope to record the event's span under, given as a `[name, version]` pair. Defaults to the AppSignal scope. + # # _@return_ — Returns the block's return value. # # SQL query instrumentation @@ -1069,7 +1083,7 @@ module Appsignal name: String, title: T.nilable(String), body: T.nilable(String), - opentelemetry_scope: T.untyped, + opentelemetry_scope: T.nilable([String, String]), block: T.untyped ).returns(Object) end @@ -1805,11 +1819,13 @@ module Appsignal # _@param_ `opentelemetry_relationship` — In collector mode, how an incoming `opentelemetry_context` relates to this transaction's span: one of `:parent`, `:link`, `:both` or `:none`. Defaults to `:parent`. # # _@param_ `opentelemetry_context` — In collector mode, an incoming OpenTelemetry trace context to relate this transaction's span to. + # + # _@param_ `opentelemetry_scope` — In collector mode, the OpenTelemetry instrumentation scope to record this transaction's spans under, given as a `[name, version]` pair. Defaults to the AppSignal scope. sig do params( namespace: String, opentelemetry_context: T.untyped, - opentelemetry_scope: T.untyped, + opentelemetry_scope: T.nilable([String, String]), opentelemetry_kind: T.nilable(Symbol), opentelemetry_relationship: T.nilable(Symbol) ).returns(Transaction) @@ -2160,6 +2176,8 @@ module Appsignal # # _@param_ `opentelemetry_context` — In collector mode, an incoming OpenTelemetry trace context to relate this transaction's span to. # + # _@param_ `opentelemetry_scope` — In collector mode, the OpenTelemetry instrumentation scope to record this transaction's spans under, given as a `[name, version]` pair. Defaults to the AppSignal scope. + # # _@return_ — The value of the given block is returned. # Returns `nil` if there already is a transaction active and no block # was given. @@ -2255,7 +2273,7 @@ module Appsignal action: T.any(String, Symbol, NilClass), namespace: T.nilable(T.any(String, Symbol)), opentelemetry_context: T.untyped, - opentelemetry_scope: T.untyped, + opentelemetry_scope: T.nilable([String, String]), opentelemetry_kind: T.nilable(Symbol), opentelemetry_relationship: T.nilable(Symbol), blk: T.proc.returns(Object) @@ -2275,6 +2293,8 @@ module Appsignal # # _@param_ `action` — The action name for the transaction. The action name is required to be set for the transaction to be reported. The argument can be set to `nil` or `:set_later` if the action is set within the block with {#set_action}. This will not update the active transaction's action if {.monitor} is called when another transaction is already active. # + # _@param_ `opentelemetry_scope` — In collector mode, the OpenTelemetry instrumentation scope to record this transaction's spans under, given as a `[name, version]` pair. Defaults to the AppSignal scope. + # # _@return_ — The value of the given block is returned. # # _@see_ `monitor` @@ -2282,7 +2302,7 @@ module Appsignal params( action: T.any(String, Symbol, NilClass), namespace: T.nilable(T.any(String, Symbol)), - opentelemetry_scope: T.untyped, + opentelemetry_scope: T.nilable([String, String]), block: T.proc.returns(Object) ).returns(T.nilable(Object)) end @@ -2311,6 +2331,8 @@ module Appsignal # # _@param_ `opentelemetry_context` — In collector mode, an incoming OpenTelemetry trace context to relate this transaction's span to. # + # _@param_ `opentelemetry_scope` — In collector mode, the OpenTelemetry instrumentation scope to record this transaction's spans under, given as a `[name, version]` pair. Defaults to the AppSignal scope. + # # Send an exception # ```ruby # begin @@ -2335,7 +2357,7 @@ module Appsignal params( error: Exception, opentelemetry_context: T.untyped, - opentelemetry_scope: T.untyped, + opentelemetry_scope: T.nilable([String, String]), opentelemetry_kind: T.nilable(Symbol), opentelemetry_relationship: T.nilable(Symbol), block: T.proc.params(transaction: Transaction).void @@ -2420,6 +2442,8 @@ module Appsignal # # _@param_ `opentelemetry_context` — In collector mode, an incoming OpenTelemetry trace context to relate this transaction's span to. Only used when a new transaction is created. # + # _@param_ `opentelemetry_scope` — In collector mode, the OpenTelemetry instrumentation scope to record this transaction's spans under, given as a `[name, version]` pair. Defaults to the AppSignal scope. + # # ```ruby # class SomeController < ApplicationController # def create @@ -2445,7 +2469,7 @@ module Appsignal params( exception: Exception, opentelemetry_context: T.untyped, - opentelemetry_scope: T.untyped, + opentelemetry_scope: T.nilable([String, String]), opentelemetry_kind: T.nilable(Symbol), opentelemetry_relationship: T.nilable(Symbol), block: T.proc.params(transaction: Transaction).void @@ -2846,6 +2870,10 @@ module Appsignal # # _@param_ `body_format` — Enum for the type of event that is instrumented. Accepted values are {EventFormatter::DEFAULT} and {EventFormatter::SQL_BODY_FORMAT}, but we recommend you use {.instrument_sql} instead of {EventFormatter::SQL_BODY_FORMAT}. # + # _@param_ `opentelemetry_kind` — In collector mode, the OpenTelemetry span kind for the event's span, such as `:client` for an outgoing HTTP request. Defaults to the OpenTelemetry default of `:internal`. + # + # _@param_ `opentelemetry_scope` — In collector mode, the OpenTelemetry instrumentation scope to record the event's span under, given as a `[name, version]` pair. Defaults to the AppSignal scope. + # # _@return_ — Returns the block's return value. # # Simple instrumentation @@ -2877,8 +2905,8 @@ module Appsignal title: T.nilable(String), body: T.nilable(String), body_format: Integer, - opentelemetry_kind: T.untyped, - opentelemetry_scope: T.untyped, + opentelemetry_kind: T.nilable(Symbol), + opentelemetry_scope: T.nilable([String, String]), block: T.untyped ).returns(Object) end @@ -2894,6 +2922,8 @@ module Appsignal # # _@param_ `body` — SQL query that's being executed. # + # _@param_ `opentelemetry_scope` — In collector mode, the OpenTelemetry instrumentation scope to record the event's span under, given as a `[name, version]` pair. Defaults to the AppSignal scope. + # # _@return_ — Returns the block's return value. # # SQL query instrumentation @@ -2922,7 +2952,7 @@ module Appsignal name: String, title: T.nilable(String), body: T.nilable(String), - opentelemetry_scope: T.untyped, + opentelemetry_scope: T.nilable([String, String]), block: T.untyped ).returns(Object) end diff --git a/sig/appsignal.rbs b/sig/appsignal.rbs index c70e59129..b07b247ed 100644 --- a/sig/appsignal.rbs +++ b/sig/appsignal.rbs @@ -265,6 +265,8 @@ module Appsignal # # _@param_ `opentelemetry_context` — In collector mode, an incoming OpenTelemetry trace context to relate this transaction's span to. # + # _@param_ `opentelemetry_scope` — In collector mode, the OpenTelemetry instrumentation scope to record this transaction's spans under, given as a `[name, version]` pair. Defaults to the AppSignal scope. + # # _@return_ — The value of the given block is returned. # Returns `nil` if there already is a transaction active and no block # was given. @@ -359,7 +361,7 @@ module Appsignal action: (String | Symbol | NilClass), ?namespace: (String | Symbol)?, ?opentelemetry_context: untyped, - ?opentelemetry_scope: untyped, + ?opentelemetry_scope: [String, String]?, ?opentelemetry_kind: Symbol?, ?opentelemetry_relationship: Symbol? ) ?{ () -> Object } -> Object? @@ -376,10 +378,12 @@ module Appsignal # # _@param_ `action` — The action name for the transaction. The action name is required to be set for the transaction to be reported. The argument can be set to `nil` or `:set_later` if the action is set within the block with {#set_action}. This will not update the active transaction's action if {.monitor} is called when another transaction is already active. # + # _@param_ `opentelemetry_scope` — In collector mode, the OpenTelemetry instrumentation scope to record this transaction's spans under, given as a `[name, version]` pair. Defaults to the AppSignal scope. + # # _@return_ — The value of the given block is returned. # # _@see_ `monitor` - def self.monitor_and_stop: (action: (String | Symbol | NilClass), ?namespace: (String | Symbol)?, ?opentelemetry_scope: untyped) ?{ () -> Object } -> Object? + def self.monitor_and_stop: (action: (String | Symbol | NilClass), ?namespace: (String | Symbol)?, ?opentelemetry_scope: [String, String]?) ?{ () -> Object } -> Object? # Send an error to AppSignal regardless of the context. # @@ -404,6 +408,8 @@ module Appsignal # # _@param_ `opentelemetry_context` — In collector mode, an incoming OpenTelemetry trace context to relate this transaction's span to. # + # _@param_ `opentelemetry_scope` — In collector mode, the OpenTelemetry instrumentation scope to record this transaction's spans under, given as a `[name, version]` pair. Defaults to the AppSignal scope. + # # Send an exception # ```ruby # begin @@ -427,7 +433,7 @@ module Appsignal def self.send_error: ( Exception error, ?opentelemetry_context: untyped, - ?opentelemetry_scope: untyped, + ?opentelemetry_scope: [String, String]?, ?opentelemetry_kind: Symbol?, ?opentelemetry_relationship: Symbol? ) ?{ (Transaction transaction) -> void } -> void @@ -508,6 +514,8 @@ module Appsignal # # _@param_ `opentelemetry_context` — In collector mode, an incoming OpenTelemetry trace context to relate this transaction's span to. Only used when a new transaction is created. # + # _@param_ `opentelemetry_scope` — In collector mode, the OpenTelemetry instrumentation scope to record this transaction's spans under, given as a `[name, version]` pair. Defaults to the AppSignal scope. + # # ```ruby # class SomeController < ApplicationController # def create @@ -532,7 +540,7 @@ module Appsignal def self.report_error: ( Exception exception, ?opentelemetry_context: untyped, - ?opentelemetry_scope: untyped, + ?opentelemetry_scope: [String, String]?, ?opentelemetry_kind: Symbol?, ?opentelemetry_relationship: Symbol? ) ?{ (Transaction transaction) -> void } -> void @@ -916,6 +924,10 @@ module Appsignal # # _@param_ `body_format` — Enum for the type of event that is instrumented. Accepted values are {EventFormatter::DEFAULT} and {EventFormatter::SQL_BODY_FORMAT}, but we recommend you use {.instrument_sql} instead of {EventFormatter::SQL_BODY_FORMAT}. # + # _@param_ `opentelemetry_kind` — In collector mode, the OpenTelemetry span kind for the event's span, such as `:client` for an outgoing HTTP request. Defaults to the OpenTelemetry default of `:internal`. + # + # _@param_ `opentelemetry_scope` — In collector mode, the OpenTelemetry instrumentation scope to record the event's span under, given as a `[name, version]` pair. Defaults to the AppSignal scope. + # # _@return_ — Returns the block's return value. # # Simple instrumentation @@ -946,8 +958,8 @@ module Appsignal ?String? title, ?String? body, ?Integer body_format, - ?opentelemetry_kind: untyped, - ?opentelemetry_scope: untyped + ?opentelemetry_kind: Symbol?, + ?opentelemetry_scope: [String, String]? ) -> Object # Instrumentation helper for SQL queries. @@ -960,6 +972,8 @@ module Appsignal # # _@param_ `body` — SQL query that's being executed. # + # _@param_ `opentelemetry_scope` — In collector mode, the OpenTelemetry instrumentation scope to record the event's span under, given as a `[name, version]` pair. Defaults to the AppSignal scope. + # # _@return_ — Returns the block's return value. # # SQL query instrumentation @@ -987,7 +1001,7 @@ module Appsignal String name, ?String? title, ?String? body, - ?opentelemetry_scope: untyped + ?opentelemetry_scope: [String, String]? ) -> Object # Convenience method for ignoring instrumentation events in a block of @@ -1652,10 +1666,12 @@ module Appsignal # _@param_ `opentelemetry_relationship` — In collector mode, how an incoming `opentelemetry_context` relates to this transaction's span: one of `:parent`, `:link`, `:both` or `:none`. Defaults to `:parent`. # # _@param_ `opentelemetry_context` — In collector mode, an incoming OpenTelemetry trace context to relate this transaction's span to. + # + # _@param_ `opentelemetry_scope` — In collector mode, the OpenTelemetry instrumentation scope to record this transaction's spans under, given as a `[name, version]` pair. Defaults to the AppSignal scope. def self.create: ( String namespace, ?opentelemetry_context: untyped, - ?opentelemetry_scope: untyped, + ?opentelemetry_scope: [String, String]?, ?opentelemetry_kind: Symbol?, ?opentelemetry_relationship: Symbol? ) -> Transaction @@ -1980,6 +1996,8 @@ module Appsignal # # _@param_ `opentelemetry_context` — In collector mode, an incoming OpenTelemetry trace context to relate this transaction's span to. # + # _@param_ `opentelemetry_scope` — In collector mode, the OpenTelemetry instrumentation scope to record this transaction's spans under, given as a `[name, version]` pair. Defaults to the AppSignal scope. + # # _@return_ — The value of the given block is returned. # Returns `nil` if there already is a transaction active and no block # was given. @@ -2074,7 +2092,7 @@ module Appsignal action: (String | Symbol | NilClass), ?namespace: (String | Symbol)?, ?opentelemetry_context: untyped, - ?opentelemetry_scope: untyped, + ?opentelemetry_scope: [String, String]?, ?opentelemetry_kind: Symbol?, ?opentelemetry_relationship: Symbol? ) ?{ () -> Object } -> Object? @@ -2091,10 +2109,12 @@ module Appsignal # # _@param_ `action` — The action name for the transaction. The action name is required to be set for the transaction to be reported. The argument can be set to `nil` or `:set_later` if the action is set within the block with {#set_action}. This will not update the active transaction's action if {.monitor} is called when another transaction is already active. # + # _@param_ `opentelemetry_scope` — In collector mode, the OpenTelemetry instrumentation scope to record this transaction's spans under, given as a `[name, version]` pair. Defaults to the AppSignal scope. + # # _@return_ — The value of the given block is returned. # # _@see_ `monitor` - def monitor_and_stop: (action: (String | Symbol | NilClass), ?namespace: (String | Symbol)?, ?opentelemetry_scope: untyped) ?{ () -> Object } -> Object? + def monitor_and_stop: (action: (String | Symbol | NilClass), ?namespace: (String | Symbol)?, ?opentelemetry_scope: [String, String]?) ?{ () -> Object } -> Object? # Send an error to AppSignal regardless of the context. # @@ -2119,6 +2139,8 @@ module Appsignal # # _@param_ `opentelemetry_context` — In collector mode, an incoming OpenTelemetry trace context to relate this transaction's span to. # + # _@param_ `opentelemetry_scope` — In collector mode, the OpenTelemetry instrumentation scope to record this transaction's spans under, given as a `[name, version]` pair. Defaults to the AppSignal scope. + # # Send an exception # ```ruby # begin @@ -2142,7 +2164,7 @@ module Appsignal def send_error: ( Exception error, ?opentelemetry_context: untyped, - ?opentelemetry_scope: untyped, + ?opentelemetry_scope: [String, String]?, ?opentelemetry_kind: Symbol?, ?opentelemetry_relationship: Symbol? ) ?{ (Transaction transaction) -> void } -> void @@ -2223,6 +2245,8 @@ module Appsignal # # _@param_ `opentelemetry_context` — In collector mode, an incoming OpenTelemetry trace context to relate this transaction's span to. Only used when a new transaction is created. # + # _@param_ `opentelemetry_scope` — In collector mode, the OpenTelemetry instrumentation scope to record this transaction's spans under, given as a `[name, version]` pair. Defaults to the AppSignal scope. + # # ```ruby # class SomeController < ApplicationController # def create @@ -2247,7 +2271,7 @@ module Appsignal def report_error: ( Exception exception, ?opentelemetry_context: untyped, - ?opentelemetry_scope: untyped, + ?opentelemetry_scope: [String, String]?, ?opentelemetry_kind: Symbol?, ?opentelemetry_relationship: Symbol? ) ?{ (Transaction transaction) -> void } -> void @@ -2631,6 +2655,10 @@ module Appsignal # # _@param_ `body_format` — Enum for the type of event that is instrumented. Accepted values are {EventFormatter::DEFAULT} and {EventFormatter::SQL_BODY_FORMAT}, but we recommend you use {.instrument_sql} instead of {EventFormatter::SQL_BODY_FORMAT}. # + # _@param_ `opentelemetry_kind` — In collector mode, the OpenTelemetry span kind for the event's span, such as `:client` for an outgoing HTTP request. Defaults to the OpenTelemetry default of `:internal`. + # + # _@param_ `opentelemetry_scope` — In collector mode, the OpenTelemetry instrumentation scope to record the event's span under, given as a `[name, version]` pair. Defaults to the AppSignal scope. + # # _@return_ — Returns the block's return value. # # Simple instrumentation @@ -2661,8 +2689,8 @@ module Appsignal ?String? title, ?String? body, ?Integer body_format, - ?opentelemetry_kind: untyped, - ?opentelemetry_scope: untyped + ?opentelemetry_kind: Symbol?, + ?opentelemetry_scope: [String, String]? ) -> Object # Instrumentation helper for SQL queries. @@ -2675,6 +2703,8 @@ module Appsignal # # _@param_ `body` — SQL query that's being executed. # + # _@param_ `opentelemetry_scope` — In collector mode, the OpenTelemetry instrumentation scope to record the event's span under, given as a `[name, version]` pair. Defaults to the AppSignal scope. + # # _@return_ — Returns the block's return value. # # SQL query instrumentation @@ -2702,7 +2732,7 @@ module Appsignal String name, ?String? title, ?String? body, - ?opentelemetry_scope: untyped + ?opentelemetry_scope: [String, String]? ) -> Object # Convenience method for ignoring instrumentation events in a block of From 4e9d5dc9e085d724b102348c353340f911d05f2b Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Thu, 23 Jul 2026 13:08:41 +0200 Subject: [PATCH 35/69] Put event category in collector span name In collector mode an event span's name leads with the event category instead of carrying it in a separate `appsignal.category` attribute. A titled event is named `category (title)`, such as `sql.active_record (User Load)`. A title-less event keeps just its category. When the title matches the category, the category is not repeated. The `appsignal.category` attribute is no longer emitted. It is a legacy concept that is not supported going forward. --- .../transaction/opentelemetry_backend.rb | 16 ++--- .../integration/collector_mode_traces_spec.rb | 11 ++- .../instrument_shared_examples.rb | 10 +-- spec/lib/appsignal/hooks/activejob_spec.rb | 7 +- spec/lib/appsignal/hooks/dry_monitor_spec.rb | 4 +- spec/lib/appsignal/hooks/redis_client_spec.rb | 16 ++--- spec/lib/appsignal/hooks/redis_spec.rb | 8 +-- spec/lib/appsignal/hooks/sequel_spec.rb | 2 +- .../integrations/data_mapper_spec.rb | 8 +-- .../integrations/delayed_job_plugin_spec.rb | 10 +-- .../appsignal/integrations/faraday_spec.rb | 5 +- spec/lib/appsignal/integrations/http_spec.rb | 40 +++++------ .../integrations/mongo_ruby_driver_spec.rb | 12 ++-- .../appsignal/integrations/net_http_spec.rb | 8 +-- spec/lib/appsignal/integrations/que_spec.rb | 16 ++--- .../lib/appsignal/integrations/resque_spec.rb | 8 +-- .../integrations/shoryuken_client_spec.rb | 6 +- .../appsignal/integrations/shoryuken_spec.rb | 16 ++--- .../appsignal/integrations/sidekiq_spec.rb | 12 ++-- .../rack/abstract_middleware_spec.rb | 4 +- spec/lib/appsignal/rack/body_wrapper_spec.rb | 19 +++-- spec/lib/appsignal/rack/event_handler_spec.rb | 12 ++-- .../transaction/opentelemetry_backend_spec.rb | 72 +++++++++++-------- spec/lib/appsignal/transaction_spec.rb | 30 ++++---- spec/lib/appsignal_spec.rb | 16 ++--- .../support/shared_contexts/collector_mode.rb | 19 +++++ 26 files changed, 209 insertions(+), 178 deletions(-) diff --git a/lib/appsignal/transaction/opentelemetry_backend.rb b/lib/appsignal/transaction/opentelemetry_backend.rb index f890e3b8f..3729c97aa 100644 --- a/lib/appsignal/transaction/opentelemetry_backend.rb +++ b/lib/appsignal/transaction/opentelemetry_backend.rb @@ -682,15 +682,15 @@ def write_tags(tags) 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. + # label in the trace UI. The AS::N `name` (e.g. "sql.active_record") + # always leads the span name so it stays visible. When a formatter + # supplied a human-readable `title` (e.g. "User Load", "GET + # https://example.com"), it follows in parentheses, giving + # "sql.active_record (User Load)". Some integrations pass the event + # name as the title as well; in that case the name is not repeated. def write_event_name_attributes(span, name, title) - span.name = title && !title.empty? ? title : name - span.set_attribute("appsignal.category", name) + has_title = title && !title.empty? && title != name + span.name = has_title ? "#{name} (#{title})" : name end def write_event_body_attributes(span, body, body_format) diff --git a/spec/integration/collector_mode_traces_spec.rb b/spec/integration/collector_mode_traces_spec.rb index f7ed90e02..ec5650a86 100644 --- a/spec/integration/collector_mode_traces_spec.rb +++ b/spec/integration/collector_mode_traces_spec.rb @@ -24,14 +24,13 @@ # 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. + # Event spans for each instrumented block are present. Every event span + # name leads with the event name. The title-less events keep just that + # name. The SQL event has a human-readable title ("Find user"), so its + # name adds the title in parentheses after the event name. expect(by_name.keys).to include("template.render", "partial.render") - sql = spans.find { |s| attribute_value(s, "appsignal.category") == "active_record.sql" } + sql = by_name["active_record.sql (Find user)"] 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) 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 bbec2c6c3..446f56269 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 @@ -37,7 +37,7 @@ def perform 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(event_category(span)).to eq("sql.active_record") # The scope is derived from the event group (the part after the last dot). expect(scope_of(span)).to eq(["appsignal-ruby/active_record", Appsignal::VERSION]) expect(span.attributes).not_to have_key("appsignal.body") @@ -79,14 +79,14 @@ def perform Appsignal::Transaction.complete_current! expect(event_spans.size).to eq(1) - span = event_spans.find { |s| s.name == "Sequel::Postgres::Database" } + span = event_span_for("sql.sequel") expect(span).not_to be_nil expect(span.parent_span_id).to eq(root_span.span_id) # A database query is an outgoing call, so it carries CLIENT kind. expect(span.kind).to eq(:client) expect(span.attributes["db.query.text"]).to eq("SQL") expect(span.attributes["db.system.name"]).to eq("other_sql") - expect(span.attributes["appsignal.category"]).to eq("sql.sequel") + expect(event_category(span)).to eq("sql.sequel") expect(scope_of(span)).to eq(["appsignal-ruby/sequel", Appsignal::VERSION]) expect(span.attributes).not_to have_key("appsignal.body") end @@ -129,7 +129,7 @@ def perform # 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(event_category(span)).to eq("no-registered.formatter") expect(scope_of(span)).to eq(["appsignal-ruby/formatter", Appsignal::VERSION]) expect(span.attributes).not_to have_key("db.query.text") expect(span.attributes).not_to have_key("db.system.name") @@ -170,7 +170,7 @@ def perform 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") + expect(event_category(span)).to eq("not_a_string") # No group (no dot) in the name, so it falls back to the default scope. expect(scope_of(span)).to eq(["appsignal-ruby", Appsignal::VERSION]) end diff --git a/spec/lib/appsignal/hooks/activejob_spec.rb b/spec/lib/appsignal/hooks/activejob_spec.rb index f742994f0..c49fa6861 100644 --- a/spec/lib/appsignal/hooks/activejob_spec.rb +++ b/spec/lib/appsignal/hooks/activejob_spec.rb @@ -597,9 +597,10 @@ def 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") + # transaction. Its name leads with the category, followed by the + # title naming the job being enqueued. + producer = event_span_for("enqueue.active_job") + expect(producer.name).to eq("enqueue.active_job (enqueue ActiveJobTestJob job)") expect(scope_of(producer)).to eq(["appsignal-ruby/active_job", Appsignal::VERSION]) expect(producer.kind).to eq(:producer) expect(producer.parent_span_id).to eq(root_span.span_id) diff --git a/spec/lib/appsignal/hooks/dry_monitor_spec.rb b/spec/lib/appsignal/hooks/dry_monitor_spec.rb index 26890d10b..0b5f4977c 100644 --- a/spec/lib/appsignal/hooks/dry_monitor_spec.rb +++ b/spec/lib/appsignal/hooks/dry_monitor_spec.rb @@ -78,7 +78,7 @@ def perform 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.rom") + expect(event_category(span)).to eq("query.rom") expect(scope_of(span)).to eq(["appsignal-ruby/dry_monitor", Appsignal::VERSION]) expect(attrs).not_to have_key("appsignal.body") end @@ -120,7 +120,7 @@ def perform # 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.dry") + expect(event_category(span)).to eq("foo.dry") expect(attrs).not_to have_key("appsignal.body") expect(attrs).not_to have_key("db.query.text") expect(attrs).not_to have_key("db.system.name") diff --git a/spec/lib/appsignal/hooks/redis_client_spec.rb b/spec/lib/appsignal/hooks/redis_client_spec.rb index ec9d10549..7f336fbad 100644 --- a/spec/lib/appsignal/hooks/redis_client_spec.rb +++ b/spec/lib/appsignal/hooks/redis_client_spec.rb @@ -106,11 +106,11 @@ def perform expect(event_spans.size).to eq(1) span = event_spans.first - expect(span.name).to eq("stub_id") + expect(span.name).to eq("query.redis (stub_id)") expect(span.kind).to eq(:client) expect(span.parent_span_id).to eq(root_span.span_id) expect(span.attributes["appsignal.body"]).to eq("get ?") - expect(span.attributes["appsignal.category"]).to eq("query.redis") + expect(event_category(span)).to eq("query.redis") expect(scope_of(span)).to eq(["appsignal-ruby/redis_client", Appsignal::VERSION]) expect(span.attributes).not_to have_key("db.query.text") end @@ -148,11 +148,11 @@ def perform expect(event_spans.size).to eq(1) span = event_spans.first - expect(span.name).to eq("stub_id") + expect(span.name).to eq("query.redis (stub_id)") expect(span.kind).to eq(:client) expect(span.parent_span_id).to eq(root_span.span_id) expect(span.attributes["appsignal.body"]).to eq("#{script} ? ?") - expect(span.attributes["appsignal.category"]).to eq("query.redis") + expect(event_category(span)).to eq("query.redis") expect(span.attributes).not_to have_key("db.query.text") end end @@ -240,10 +240,10 @@ def perform expect(event_spans.size).to eq(1) span = event_spans.first - expect(span.name).to eq("stub_id") + expect(span.name).to eq("query.redis (stub_id)") expect(span.parent_span_id).to eq(root_span.span_id) expect(span.attributes["appsignal.body"]).to eq("get ?") - expect(span.attributes["appsignal.category"]).to eq("query.redis") + expect(event_category(span)).to eq("query.redis") expect(span.attributes).not_to have_key("db.query.text") end end @@ -280,10 +280,10 @@ def perform expect(event_spans.size).to eq(1) span = event_spans.first - expect(span.name).to eq("stub_id") + expect(span.name).to eq("query.redis (stub_id)") expect(span.parent_span_id).to eq(root_span.span_id) expect(span.attributes["appsignal.body"]).to eq("#{script} ? ?") - expect(span.attributes["appsignal.category"]).to eq("query.redis") + expect(event_category(span)).to eq("query.redis") expect(span.attributes).not_to have_key("db.query.text") end end diff --git a/spec/lib/appsignal/hooks/redis_spec.rb b/spec/lib/appsignal/hooks/redis_spec.rb index a5ea2ef59..9368dd543 100644 --- a/spec/lib/appsignal/hooks/redis_spec.rb +++ b/spec/lib/appsignal/hooks/redis_spec.rb @@ -100,11 +100,11 @@ def perform expect(event_spans.size).to eq(1) span = event_spans.first - expect(span.name).to eq("stub_id") + expect(span.name).to eq("query.redis (stub_id)") expect(span.kind).to eq(:client) expect(span.parent_span_id).to eq(root_span.span_id) expect(span.attributes["appsignal.body"]).to eq("get ?") - expect(span.attributes["appsignal.category"]).to eq("query.redis") + expect(event_category(span)).to eq("query.redis") expect(scope_of(span)).to eq(["appsignal-ruby/redis", Appsignal::VERSION]) expect(span.attributes).not_to have_key("db.query.text") end @@ -141,11 +141,11 @@ def perform expect(event_spans.size).to eq(1) span = event_spans.first - expect(span.name).to eq("stub_id") + expect(span.name).to eq("query.redis (stub_id)") expect(span.kind).to eq(:client) expect(span.parent_span_id).to eq(root_span.span_id) expect(span.attributes["appsignal.body"]).to eq("#{script} ? ?") - expect(span.attributes["appsignal.category"]).to eq("query.redis") + expect(event_category(span)).to eq("query.redis") expect(span.attributes).not_to have_key("db.query.text") end end diff --git a/spec/lib/appsignal/hooks/sequel_spec.rb b/spec/lib/appsignal/hooks/sequel_spec.rb index 8d26d0181..08db49d13 100644 --- a/spec/lib/appsignal/hooks/sequel_spec.rb +++ b/spec/lib/appsignal/hooks/sequel_spec.rb @@ -49,7 +49,7 @@ def perform 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") + expect(event_category(span)).to eq("sql.sequel") expect(scope_of(span)).to eq(["appsignal-ruby/sequel", Appsignal::VERSION]) end end diff --git a/spec/lib/appsignal/integrations/data_mapper_spec.rb b/spec/lib/appsignal/integrations/data_mapper_spec.rb index ec92b5810..80fa423c5 100644 --- a/spec/lib/appsignal/integrations/data_mapper_spec.rb +++ b/spec/lib/appsignal/integrations/data_mapper_spec.rb @@ -55,13 +55,13 @@ def perform expect(event_spans.size).to eq(1) span = event_spans.first - expect(span.name).to eq("DataMapper Query") + expect(span.name).to eq("query.data_mapper (DataMapper Query)") expect(span.kind).to eq(:client) expect(span.parent_span_id).to eq(root_span.span_id) attrs = span.attributes expect(attrs["db.query.text"]).to eq("SELECT * from users") expect(attrs["db.system.name"]).to eq("other_sql") - expect(attrs["appsignal.category"]).to eq("query.data_mapper") + expect(event_category(span)).to eq("query.data_mapper") expect(scope_of(span)).to eq(["appsignal-ruby/data_mapper", Appsignal::VERSION]) expect(attrs).not_to have_key("appsignal.body") observed = span.end_timestamp - span.start_timestamp @@ -104,11 +104,11 @@ def perform expect(event_spans.size).to eq(1) span = event_spans.first - expect(span.name).to eq("DataMapper Query") + expect(span.name).to eq("query.data_mapper (DataMapper Query)") expect(span.kind).to eq(:client) expect(span.parent_span_id).to eq(root_span.span_id) attrs = span.attributes - expect(attrs["appsignal.category"]).to eq("query.data_mapper") + expect(event_category(span)).to eq("query.data_mapper") expect(scope_of(span)).to eq(["appsignal-ruby/data_mapper", Appsignal::VERSION]) expect(attrs).not_to have_key("appsignal.body") expect(attrs).not_to have_key("db.query.text") diff --git a/spec/lib/appsignal/integrations/delayed_job_plugin_spec.rb b/spec/lib/appsignal/integrations/delayed_job_plugin_spec.rb index fff70cffa..5de4b170f 100644 --- a/spec/lib/appsignal/integrations/delayed_job_plugin_spec.rb +++ b/spec/lib/appsignal/integrations/delayed_job_plugin_spec.rb @@ -58,8 +58,8 @@ def perform_job(job) # Delayed Job has no envelope to carry trace context, so -- like # OpenTelemetry's own instrumentation -- nothing is injected; the # producer span is not linked to the later perform. - producer = event_spans.find { |s| s.name == "enqueue DelayedTestJob job" } - expect(producer.attributes["appsignal.category"]).to eq("enqueue.delayed_job") + producer = event_span_for("enqueue.delayed_job") + expect(producer.name).to eq("enqueue.delayed_job (enqueue DelayedTestJob job)") expect(scope_of(producer)).to eq(["appsignal-ruby/delayed_job", Appsignal::VERSION]) expect(producer.kind).to eq(:producer) expect(producer.parent_span_id).to eq(root_span.span_id) @@ -79,9 +79,9 @@ def perform_job(job) Delayed::Job.enqueue(DelayedTestJob.new) - # Event spans are named after the title; the event name lives in the - # `appsignal.category` attribute, so match on that. - categories = span_exporter.finished_spans.map { |s| s.attributes["appsignal.category"] } + # Event span names lead with the event category, so derive the + # categories from the event spans and check none is the enqueue event. + categories = event_spans.map { |s| event_category(s) } expect(categories).to_not include("enqueue.delayed_job") end end diff --git a/spec/lib/appsignal/integrations/faraday_spec.rb b/spec/lib/appsignal/integrations/faraday_spec.rb index c20d5bff3..bbe789fc9 100644 --- a/spec/lib/appsignal/integrations/faraday_spec.rb +++ b/spec/lib/appsignal/integrations/faraday_spec.rb @@ -85,9 +85,10 @@ def perform .to eq("00-#{faraday_span.hex_trace_id}-#{faraday_span.hex_span_id}-01") end - # Finds the recorded event span for an `appsignal.category` (AS::N name). + # Finds the recorded event span for a category (AS::N name), which now leads + # the event span's name. def event_span(category) - event_spans.find { |span| span.attributes["appsignal.category"] == category } + event_span_for(category) end # Reads the `traceparent` header off the recorded outgoing request to `url`. diff --git a/spec/lib/appsignal/integrations/http_spec.rb b/spec/lib/appsignal/integrations/http_spec.rb index 6a6e627e5..b23cdca1f 100644 --- a/spec/lib/appsignal/integrations/http_spec.rb +++ b/spec/lib/appsignal/integrations/http_spec.rb @@ -37,10 +37,10 @@ def perform .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.name).to eq("request.http_rb (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(event_category(span)).to eq("request.http_rb") expect(scope_of(span)).to eq(["appsignal-ruby/http_rb", Appsignal::VERSION]) expect(span.attributes).not_to have_key("appsignal.body") @@ -82,10 +82,10 @@ def perform .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.name).to eq("request.http_rb (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(event_category(span)).to eq("request.http_rb") expect(scope_of(span)).to eq(["appsignal-ruby/http_rb", Appsignal::VERSION]) expect(span.attributes).not_to have_key("appsignal.body") @@ -121,8 +121,8 @@ def perform 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.name).to eq("request.http_rb (GET https://www.google.com)") + expect(event_category(span)).to eq("request.http_rb") expect(scope_of(span)).to eq(["appsignal-ruby/http_rb", Appsignal::VERSION]) expect(span.attributes).not_to have_key("appsignal.body") end @@ -155,8 +155,8 @@ def perform 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.name).to eq("request.http_rb (POST https://www.google.com)") + expect(event_category(span)).to eq("request.http_rb") expect(scope_of(span)).to eq(["appsignal-ruby/http_rb", Appsignal::VERSION]) expect(span.attributes).not_to have_key("appsignal.body") end @@ -197,9 +197,9 @@ def perform expect(event_spans.size).to eq(1) span = event_spans.first - expect(span.name).to eq("GET http://www.google.com") + expect(span.name).to eq("request.http_rb (GET http://www.google.com)") expect(span.kind).to eq(:client) - expect(span.attributes["appsignal.category"]).to eq("request.http_rb") + expect(event_category(span)).to eq("request.http_rb") expect(scope_of(span)).to eq(["appsignal-ruby/http_rb", Appsignal::VERSION]) end end @@ -237,8 +237,8 @@ def to_s 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") + expect(span.name).to eq("request.http_rb (GET http://www.google.com)") + expect(event_category(span)).to eq("request.http_rb") expect(scope_of(span)).to eq(["appsignal-ruby/http_rb", Appsignal::VERSION]) end end @@ -269,8 +269,8 @@ def perform 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") + expect(span.name).to eq("request.http_rb (GET http://www.google.com)") + expect(event_category(span)).to eq("request.http_rb") expect(scope_of(span)).to eq(["appsignal-ruby/http_rb", Appsignal::VERSION]) end end @@ -301,8 +301,8 @@ def perform 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") + expect(span.name).to eq("request.http_rb (GET http://www.google.com)") + expect(event_category(span)).to eq("request.http_rb") expect(scope_of(span)).to eq(["appsignal-ruby/http_rb", Appsignal::VERSION]) end end @@ -333,8 +333,8 @@ def perform 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") + expect(span.name).to eq("request.http_rb (GET http://www.google.com)") + expect(event_category(span)).to eq("request.http_rb") expect(scope_of(span)).to eq(["appsignal-ruby/http_rb", Appsignal::VERSION]) end end @@ -365,8 +365,8 @@ def perform 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") + expect(span.name).to eq("request.http_rb (GET http://www.example.com)") + expect(event_category(span)).to eq("request.http_rb") expect(scope_of(span)).to eq(["appsignal-ruby/http_rb", Appsignal::VERSION]) end end diff --git a/spec/lib/appsignal/integrations/mongo_ruby_driver_spec.rb b/spec/lib/appsignal/integrations/mongo_ruby_driver_spec.rb index d59397091..6d8ca168a 100644 --- a/spec/lib/appsignal/integrations/mongo_ruby_driver_spec.rb +++ b/spec/lib/appsignal/integrations/mongo_ruby_driver_spec.rb @@ -85,12 +85,12 @@ def perform perform Appsignal::Transaction.complete_current! - span = event_spans.find { |s| s.attributes["appsignal.category"] == "query.mongodb" } + span = event_span_for("query.mongodb") expect(span).not_to be_nil - expect(span.name).to eq("find | test | SUCCEEDED") + expect(span.name).to eq("query.mongodb (find | test | SUCCEEDED)") expect(span.kind).to eq(:client) expect(span.parent_span_id).to eq(root_span.span_id) - expect(span.attributes["appsignal.category"]).to eq("query.mongodb") + expect(event_category(span)).to eq("query.mongodb") expect(scope_of(span)).to eq(["appsignal-ruby/mongo", Appsignal::VERSION]) expect(span.attributes["appsignal.body"]).to eq("{\"foo\":\"?\"}") @@ -131,11 +131,11 @@ def perform perform Appsignal::Transaction.complete_current! - span = event_spans.find { |s| s.attributes["appsignal.category"] == "query.mongodb" } + span = event_span_for("query.mongodb") expect(span).not_to be_nil - expect(span.name).to eq("find | test | FAILED") + expect(span.name).to eq("query.mongodb (find | test | FAILED)") expect(span.kind).to eq(:client) - expect(span.attributes["appsignal.category"]).to eq("query.mongodb") + expect(event_category(span)).to eq("query.mongodb") expect(scope_of(span)).to eq(["appsignal-ruby/mongo", Appsignal::VERSION]) expect(span.attributes["appsignal.body"]).to eq("{\"foo\":\"?\"}") end diff --git a/spec/lib/appsignal/integrations/net_http_spec.rb b/spec/lib/appsignal/integrations/net_http_spec.rb index 4c1a873d5..53957ebc5 100644 --- a/spec/lib/appsignal/integrations/net_http_spec.rb +++ b/spec/lib/appsignal/integrations/net_http_spec.rb @@ -30,10 +30,10 @@ def perform expect(event_spans.size).to eq(1) span = event_spans.first - expect(span.name).to eq("GET http://www.google.com") + expect(span.name).to eq("request.net_http (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(event_category(span)).to eq("request.net_http") expect(scope_of(span)).to eq(["appsignal-ruby/net_http", Appsignal::VERSION]) expect(span.attributes).not_to have_key("appsignal.body") @@ -76,10 +76,10 @@ def perform expect(event_spans.size).to eq(1) span = event_spans.first - expect(span.name).to eq("GET https://www.google.com") + expect(span.name).to eq("request.net_http (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(event_category(span)).to eq("request.net_http") expect(scope_of(span)).to eq(["appsignal-ruby/net_http", Appsignal::VERSION]) expect(span.attributes).not_to have_key("appsignal.body") diff --git a/spec/lib/appsignal/integrations/que_spec.rb b/spec/lib/appsignal/integrations/que_spec.rb index e9cc34a07..af74fb419 100644 --- a/spec/lib/appsignal/integrations/que_spec.rb +++ b/spec/lib/appsignal/integrations/que_spec.rb @@ -88,7 +88,7 @@ def perform 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") + expect(event_category(span)).to eq("perform_job.que") expect(scope_of(root_span)).to eq(["appsignal-ruby/que", Appsignal::VERSION]) expect(scope_of(span)).to eq(["appsignal-ruby/que", Appsignal::VERSION]) expected_params = { "arguments" => %w[post_id_123 user_id_123] } @@ -451,8 +451,8 @@ def expect_job_arguments_untouched # 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") + producer = event_span_for("enqueue.que") + expect(producer.name).to eq("enqueue.que (enqueue MyQueJob job)") expect(scope_of(producer)).to eq(["appsignal-ruby/que", Appsignal::VERSION]) expect(producer.kind).to eq(:producer) expect(producer.parent_span_id).to eq(root_span.span_id) @@ -498,7 +498,7 @@ def expect_job_arguments_untouched enqueue # No transaction to attach to: nothing recorded, nothing injected. - expect(span_exporter.finished_spans.map(&:name)).to_not include("enqueue.que") + expect(event_spans_for("enqueue.que")).to be_empty expect(enqueued_tags).to eq(["user:42"]) if DependencyHelper.que1_present? expect_job_arguments_untouched end @@ -532,7 +532,7 @@ def 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") + expect(event_spans_for("enqueue.que")).to be_empty # ...but the trace context is still injected so the job links back. if DependencyHelper.que1_present? expect(enqueued_tags).to include(a_string_starting_with("traceparent:")) @@ -589,10 +589,10 @@ def bulk_enqueue(tags: ["user:42"]) bulk_enqueue Appsignal::Transaction.complete_current! - producers = event_spans.select { |s| s.name == "bulk enqueue MyQueJob jobs" } + producers = event_spans_for("bulk_enqueue.que") expect(producers.size).to eq(1) producer = producers.first - expect(producer.attributes["appsignal.category"]).to eq("bulk_enqueue.que") + expect(producer.name).to eq("bulk_enqueue.que (bulk enqueue MyQueJob jobs)") expect(producer.kind).to eq(:producer) expect(producer.parent_span_id).to eq(root_span.span_id) @@ -642,7 +642,7 @@ def 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") + expect(event_spans_for("bulk_enqueue.que")).to be_empty # ...but the trace context is still injected so the jobs link back. expect(enqueued_tags).to include(a_string_starting_with("traceparent:")) end diff --git a/spec/lib/appsignal/integrations/resque_spec.rb b/spec/lib/appsignal/integrations/resque_spec.rb index e53dd71eb..77320c08e 100644 --- a/spec/lib/appsignal/integrations/resque_spec.rb +++ b/spec/lib/appsignal/integrations/resque_spec.rb @@ -267,8 +267,8 @@ def enqueue # 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") + producer = event_span_for("enqueue.resque") + expect(producer.name).to eq("enqueue.resque (enqueue ResqueTestJob job)") expect(scope_of(producer)).to eq(["appsignal-ruby/resque", Appsignal::VERSION]) expect(producer.kind).to eq(:producer) expect(producer.parent_span_id).to eq(root_span.span_id) @@ -295,7 +295,7 @@ def enqueue # 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(event_spans_for("enqueue.resque")).to be_empty expect(item).to_not have_key("traceparent") end end @@ -327,7 +327,7 @@ def 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.resque") + expect(event_spans_for("enqueue.resque")).to be_empty # ...but the trace context is still injected so the job links back. expect(item).to have_key("traceparent") end diff --git a/spec/lib/appsignal/integrations/shoryuken_client_spec.rb b/spec/lib/appsignal/integrations/shoryuken_client_spec.rb index 185aa865b..58ddc362c 100644 --- a/spec/lib/appsignal/integrations/shoryuken_client_spec.rb +++ b/spec/lib/appsignal/integrations/shoryuken_client_spec.rb @@ -57,9 +57,9 @@ def send_message 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") + # A raw `send_message` has no worker class, so the title names the queue. + producer = event_span_for("enqueue.shoryuken") + expect(producer.name).to eq("enqueue.shoryuken (enqueue on test-queue)") expect(producer.kind).to eq(:producer) # The middleware the hook registered injected the producer span's trace diff --git a/spec/lib/appsignal/integrations/shoryuken_spec.rb b/spec/lib/appsignal/integrations/shoryuken_spec.rb index 848e27a9a..baaca5b46 100644 --- a/spec/lib/appsignal/integrations/shoryuken_spec.rb +++ b/spec/lib/appsignal/integrations/shoryuken_spec.rb @@ -89,7 +89,7 @@ def perform 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(event_category(span)).to eq("perform_job.shoryuken") expect(scope_of(root_span)).to eq(["appsignal-ruby/shoryuken", Appsignal::VERSION]) expect(scope_of(span)).to eq(["appsignal-ruby/shoryuken", Appsignal::VERSION]) expect(JSON.parse(root_span.attributes["appsignal.function.parameters"])) @@ -341,7 +341,7 @@ def perform 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(event_category(span)).to eq("perform_job.shoryuken") expect(JSON.parse(root_span.attributes["appsignal.function.parameters"])) .to eq( "msg2" => "foo bar", @@ -410,8 +410,8 @@ def perform # 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") + producer = event_span_for("enqueue.shoryuken") + expect(producer.name).to eq("enqueue.shoryuken (enqueue MyShoryukenWorker job)") expect(scope_of(producer)).to eq(["appsignal-ruby/shoryuken", Appsignal::VERSION]) expect(producer.kind).to eq(:producer) expect(producer.parent_span_id).to eq(root_span.span_id) @@ -446,9 +446,9 @@ def perform perform Appsignal::Transaction.complete_current! - producer = event_spans.find { |s| s.name == "enqueue on my-queue" } + producer = event_span_for("enqueue.shoryuken") expect(producer).to_not be_nil - expect(producer.attributes["appsignal.category"]).to eq("enqueue.shoryuken") + expect(producer.name).to eq("enqueue.shoryuken (enqueue on my-queue)") end end end @@ -468,7 +468,7 @@ def perform # 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(event_spans_for("enqueue.shoryuken")).to be_empty expect(options).to_not have_key(:message_attributes) end end @@ -501,7 +501,7 @@ def 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") + expect(event_spans_for("enqueue.shoryuken")).to be_empty # ...but the trace context is still injected so the job links back. expect(options[:message_attributes]).to have_key("traceparent") end diff --git a/spec/lib/appsignal/integrations/sidekiq_spec.rb b/spec/lib/appsignal/integrations/sidekiq_spec.rb index 31758309a..48f7bbdcc 100644 --- a/spec/lib/appsignal/integrations/sidekiq_spec.rb +++ b/spec/lib/appsignal/integrations/sidekiq_spec.rb @@ -359,8 +359,8 @@ def enqueue # 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") + producer = event_span_for("enqueue.sidekiq") + expect(producer.name).to eq("enqueue.sidekiq (enqueue TestClass job)") expect(producer.kind).to eq(:producer) expect(producer.parent_span_id).to eq(root_span.span_id) expect(scope_of(producer)).to eq(["appsignal-ruby/sidekiq", Appsignal::VERSION]) @@ -449,7 +449,7 @@ def enqueue # 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(event_spans_for("enqueue.sidekiq")).to be_empty expect(job).to_not have_key("traceparent") end end @@ -481,7 +481,7 @@ def 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.sidekiq") + expect(event_spans_for("enqueue.sidekiq")).to be_empty # ...but the trace context is still injected so the job links back. expect(job).to have_key("traceparent") end @@ -904,7 +904,7 @@ def perform 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") + expect(event_category(span)).to eq("perform_job.sidekiq") # Both the job's root span and its perform event carry the Sidekiq # instrumentation scope. @@ -1020,7 +1020,7 @@ def perform 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") + expect(event_category(span)).to eq("perform_job.sidekiq") end end end diff --git a/spec/lib/appsignal/rack/abstract_middleware_spec.rb b/spec/lib/appsignal/rack/abstract_middleware_spec.rb index c5d1cdcb4..76a63858f 100644 --- a/spec/lib/appsignal/rack/abstract_middleware_spec.rb +++ b/spec/lib/appsignal/rack/abstract_middleware_spec.rb @@ -625,7 +625,7 @@ def setup_parent_transaction body.to_ary response_events = event_spans.count do |span| - span.attributes["appsignal.category"] == "process_response_body.rack" + event_category(span) == "process_response_body.rack" end expect(response_events).to eq(1) end @@ -661,7 +661,7 @@ def perform response_events = event_spans.count do |span| - span.attributes["appsignal.category"] == "process_response_body.rack" + event_category(span) == "process_response_body.rack" end expect(response_events).to eq(1) end diff --git a/spec/lib/appsignal/rack/body_wrapper_spec.rb b/spec/lib/appsignal/rack/body_wrapper_spec.rb index 637c1d9dc..5cbbbd02c 100644 --- a/spec/lib/appsignal/rack/body_wrapper_spec.rb +++ b/spec/lib/appsignal/rack/body_wrapper_spec.rb @@ -28,17 +28,17 @@ def expect_collector_no_error 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 } + # The event name leads the span name. When there is a human-readable + # title it follows in parentheses, otherwise the name stands alone. + span = event_span_for(name) expect(span).not_to be_nil expect(span.parent_span_id).to eq(root_span.span_id) - expect(span.name).to eq(title) if title + expect(span.name).to eq("#{name} (#{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) + expect(event_spans_for(name)).to be_empty end it_in_both_modes "forwards method calls to the body if the method doesn't exist" do @@ -138,11 +138,10 @@ def perform # 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"] + # A title-less event names the span after its category alone, without + # a parenthesized title; the "#each" one never does. + titleless_event = event_spans_for("process_response_body.rack").find do |span| + span.name == "process_response_body.rack" end expect(titleless_event).to be_nil end diff --git a/spec/lib/appsignal/rack/event_handler_spec.rb b/spec/lib/appsignal/rack/event_handler_spec.rb index b475f1419..0b6984ce0 100644 --- a/spec/lib/appsignal/rack/event_handler_spec.rb +++ b/spec/lib/appsignal/rack/event_handler_spec.rb @@ -192,12 +192,10 @@ def 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 + event = event_span_for("process_request.rack") expect(event).not_to be_nil expect(event.parent_span_id).to eq(root_span.span_id) - expect(event.name).to eq("callback: after_reply") + expect(event.name).to eq("process_request.rack (callback: after_reply)") end end @@ -929,12 +927,10 @@ def perform use_test_logger perform - event = event_spans.find do |span| - span.attributes["appsignal.category"] == "process_request.rack" - end + event = event_span_for("process_request.rack") expect(event).not_to be_nil expect(event.parent_span_id).to eq(root_span.span_id) - expect(event.name).to eq("callback: on_finish") + expect(event.name).to eq("process_request.rack (callback: on_finish)") end end diff --git a/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb b/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb index f4f37e3ef..1952768a5 100644 --- a/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb +++ b/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb @@ -418,8 +418,8 @@ def attribute_for(backend, key, data) end describe "#start_event with opentelemetry_kind" do - def event_span_for(category) - span_exporter.finished_spans.find { |s| s.attributes["appsignal.category"] == category } + def event_span_named(name) + span_exporter.finished_spans.find { |s| s.name == name } end it "creates the event span with the given span kind" do @@ -428,7 +428,7 @@ def event_span_for(category) backend.finish_event("request.net_http", "GET", "", Appsignal::EventFormatter::DEFAULT) backend.complete - expect(event_span_for("request.net_http").kind).to eq(:client) + expect(event_span_named("request.net_http (GET)").kind).to eq(:client) end it "defaults to an internal span when no kind is given" do @@ -437,7 +437,7 @@ def event_span_for(category) backend.finish_event("sql.query", "title", "", Appsignal::EventFormatter::DEFAULT) backend.complete - expect(event_span_for("sql.query").kind).to eq(:internal) + expect(event_span_named("sql.query (title)").kind).to eq(:internal) end end @@ -507,8 +507,10 @@ def event_span_for(category) allow(Appsignal::Extension).to receive(:allocation_count) { @allocations } end - def event_span(category) - span_exporter.finished_spans.find { |s| s.attributes["appsignal.category"] == category } + # An event span's name leads with the event's category, optionally followed + # by the title in parentheses, so match on the part before the title. + def event_span_for(category) + span_exporter.finished_spans.find { |s| s.name.sub(/ \(.*\)\z/, "") == category } end it "sets the transaction total on the root span from the delta since start" do @@ -551,7 +553,7 @@ def event_span(category) @allocations = 175 backend.finish_event("sql.query", "SQL", "SELECT 1", 0) - attributes = event_span("sql.query").attributes + attributes = event_span_for("sql.query").attributes expect(attributes["appsignal.allocation_count"]).to eq(45) expect(attributes["appsignal.self_allocation_count"]).to eq(45) end @@ -568,8 +570,8 @@ def event_span(category) @allocations = 200 backend.finish_event("template.render", "Render", "", 0) - inner = event_span("sql.query").attributes - outer = event_span("template.render").attributes + inner = event_span_for("sql.query").attributes + outer = event_span_for("template.render").attributes # Inner: full == self == 45 (no children). expect(inner["appsignal.allocation_count"]).to eq(45) expect(inner["appsignal.self_allocation_count"]).to eq(45) @@ -582,7 +584,7 @@ def event_span(category) backend = create_backend backend.record_event("sql.query", "SQL", "SELECT 1", 0, 1_000_000) - attributes = event_span("sql.query").attributes + attributes = event_span_for("sql.query").attributes expect(attributes).to_not have_key("appsignal.allocation_count") expect(attributes).to_not have_key("appsignal.self_allocation_count") end @@ -632,7 +634,7 @@ def event_span(category) # [full, self] for an event span, by category name. counts = lambda do |category| - attributes = event_span(category).attributes + attributes = event_span_for(category).attributes [attributes["appsignal.allocation_count"], attributes["appsignal.self_allocation_count"]] end @@ -643,7 +645,7 @@ def event_span(category) # both e2 (27, which itself includes e3) and e4 (12). expect(counts.call("e1")).to eq([70, 31]) - expect(event_span("r").attributes).to_not have_key("appsignal.allocation_count") + expect(event_span_for("r").attributes).to_not have_key("appsignal.allocation_count") root = finished_span(span).attributes # Transaction total spans everything, including the 10 allocations before e1. expect(root["appsignal.transaction_allocation_count"]).to eq(80) @@ -707,7 +709,7 @@ def event_span(category) @allocations = 100 # finished on another thread: counter went backwards logs = capture_logs { backend.finish_event("sql.query", "SQL", "SELECT 1", 0) } - attributes = event_span("sql.query").attributes + attributes = event_span_for("sql.query").attributes expect(attributes).to_not have_key("appsignal.allocation_count") expect(attributes).to_not have_key("appsignal.self_allocation_count") expect(logs).to include("allocation counter decreased") @@ -904,7 +906,7 @@ def exception_event(backend) backend.complete event_span = span_exporter.finished_spans - .find { |s| s.attributes["appsignal.category"] == "sql.query" } + .find { |s| s.name == "sql.query (title)" } backend_span_id = backend.instance_variable_get(:@span).context.span_id root = span_exporter.finished_spans.find { |s| s.span_id == backend_span_id } @@ -1133,7 +1135,7 @@ def new_transaction_with_otel_backend(namespace = Appsignal::Transaction::HTTP_R end describe "#finish_event" do - it "pops the stack, names the span after the title, finishes it, and detaches the context" do + it "pops the stack, names the span, finishes it, and detaches the context" do backend = create_backend root_span = backend.instance_variable_get(:@span) @@ -1145,12 +1147,13 @@ def new_transaction_with_otel_backend(namespace = Appsignal::Transaction::HTTP_R expect(::OpenTelemetry::Trace.current_span).to eq(root_span) event_span = span_exporter.finished_spans - .find { |s| s.attributes["appsignal.category"] == "custom.event" } + .find { |s| s.name == "custom.event (Title)" } 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") + # The event name leads the span name and the human-readable title + # follows in parentheses. There is no separate appsignal.category + # attribute. + expect(event_span.name).to eq("custom.event (Title)") + expect(event_span.attributes).not_to have_key("appsignal.category") expect(event_span.attributes["appsignal.body"]).to eq("Body") expect(event_span.attributes).not_to have_key("appsignal.title") end @@ -1172,9 +1175,9 @@ def new_transaction_with_otel_backend(namespace = Appsignal::Transaction::HTTP_R Appsignal::EventFormatter::DEFAULT, duration_ns) span = span_exporter.finished_spans - .find { |s| s.attributes["appsignal.category"] == "custom.event" } + .find { |s| s.name == "custom.event (T)" } expect(span).not_to be_nil - expect(span.name).to eq("T") + expect(span.name).to eq("custom.event (T)") observed = span.end_timestamp - span.start_timestamp # Allow a small slack for clock jitter and the time elapsed # between computing start_time and calling finish. @@ -1222,7 +1225,7 @@ def new_transaction_with_otel_backend(namespace = Appsignal::Transaction::HTTP_R Appsignal::EventFormatter::SQL_BODY_FORMAT) attrs = span_exporter.finished_spans - .find { |s| s.attributes["appsignal.category"] == "sql.query" }.attributes + .find { |s| s.name == "sql.query (Q)" }.attributes expect(attrs["db.query.text"]).to eq("SELECT 1") expect(attrs["db.system.name"]).to eq("other_sql") expect(attrs).not_to have_key("appsignal.body") @@ -1235,7 +1238,7 @@ def new_transaction_with_otel_backend(namespace = Appsignal::Transaction::HTTP_R Appsignal::EventFormatter::DEFAULT) attrs = span_exporter.finished_spans - .find { |s| s.attributes["appsignal.category"] == "custom" }.attributes + .find { |s| s.name == "custom (T)" }.attributes expect(attrs["appsignal.body"]).to eq("Body") expect(attrs).not_to have_key("db.query.text") expect(attrs).not_to have_key("db.system.name") @@ -1251,9 +1254,9 @@ def new_transaction_with_otel_backend(namespace = Appsignal::Transaction::HTTP_R Appsignal::EventFormatter::DEFAULT) no_body = span_exporter.finished_spans - .find { |s| s.attributes["appsignal.category"] == "no.body" } + .find { |s| s.name == "no.body (T)" } empty_body = span_exporter.finished_spans - .find { |s| s.attributes["appsignal.category"] == "empty.body" } + .find { |s| s.name == "empty.body (T)" } expect(no_body.attributes).not_to have_key("appsignal.body") expect(no_body.attributes).not_to have_key("db.query.text") expect(empty_body.attributes).not_to have_key("appsignal.body") @@ -1270,15 +1273,28 @@ def new_transaction_with_otel_backend(namespace = Appsignal::Transaction::HTTP_R Appsignal::EventFormatter::DEFAULT) no_title = span_exporter.finished_spans - .find { |s| s.attributes["appsignal.category"] == "no.title" } + .find { |s| s.name == "no.title" } empty_title = span_exporter.finished_spans - .find { |s| s.attributes["appsignal.category"] == "empty.title" } + .find { |s| s.name == "empty.title" } # With no usable title, the span name is the event name itself. expect(no_title.name).to eq("no.title") expect(empty_title.name).to eq("empty.title") expect(no_title.attributes).not_to have_key("appsignal.title") expect(empty_title.attributes).not_to have_key("appsignal.title") end + + it "does not repeat the event name when the title is the same" do + backend = create_backend + backend.start_event + backend.finish_event("query.postgres", "query.postgres", "Body", + Appsignal::EventFormatter::DEFAULT) + + span = span_exporter.finished_spans + .find { |s| s.name == "query.postgres" } + # Some integrations pass the event name as the title too. The span + # name is then just the name, not "query.postgres (query.postgres)". + expect(span).not_to be_nil + end end describe "#complete with unfinished event spans" do diff --git a/spec/lib/appsignal/transaction_spec.rb b/spec/lib/appsignal/transaction_spec.rb index 12c639ec8..0e45298f1 100644 --- a/spec/lib/appsignal/transaction_spec.rb +++ b/spec/lib/appsignal/transaction_spec.rb @@ -227,7 +227,7 @@ Appsignal::EventFormatter::DEFAULT) Appsignal::Transaction.complete_current! - event_span = event_spans.find { |s| s.attributes["appsignal.category"] == "sql.query" } + event_span = event_spans.find { |s| s.name == "sql.query (Query)" } foreign_span = span_exporter.finished_spans.find { |s| s.name == "foreign-call" } expect(event_span.events.map(&:name)).to include("exception", "appsignal.breadcrumb") @@ -249,7 +249,7 @@ Appsignal::EventFormatter::DEFAULT) Appsignal::Transaction.complete_current! - event_span = event_spans.find { |s| s.attributes["appsignal.category"] == "sql.query" } + event_span = event_spans.find { |s| s.name == "sql.query (Query)" } expect(event_span.events.map(&:name)).to include("exception", "appsignal.breadcrumb") expect(Array(root_span.events).map(&:name)).to_not include("appsignal.breadcrumb") end @@ -2834,7 +2834,7 @@ def perform transaction.complete event_span = event_spans.find do |span| - span.attributes["appsignal.category"] == "sql.active_record" + span.name == "sql.active_record (User Load)" end expect(event_span.events.map(&:name)).to include("appsignal.breadcrumb") expect(Array(root_span.events).map(&:name)).to_not include("appsignal.breadcrumb") @@ -3792,7 +3792,7 @@ def perform transaction.finish_event("query", "title", "body", Appsignal::EventFormatter::DEFAULT) transaction.complete - event_span = event_spans.find { |span| span.attributes["appsignal.category"] == "query" } + event_span = event_spans.find { |span| span.name == "query (title)" } expect(event_span.events.map(&:name)).to include("exception") expect(Array(root_span.events).map(&:name)).not_to include("exception") end @@ -4955,8 +4955,8 @@ def 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.name).to eq("custom.event (T)") + expect(span.attributes).not_to have_key("appsignal.category") expect(span.parent_span_id).to eq(root_span.span_id) observed = span.end_timestamp - span.start_timestamp expect(observed).to be_within(50_000_000).of(duration_ns) @@ -5019,13 +5019,13 @@ def perform(transaction) Appsignal::Transaction.complete_current! span = event_spans.first - expect(span.name).to eq("Query") + expect(span.name).to eq("sql.active_record (Query)") expect(span.parent_span_id).to eq(root_span.span_id) expect(span.attributes).to include( "db.query.text" => "SELECT 1", - "db.system.name" => "other_sql", - "appsignal.category" => "sql.active_record" + "db.system.name" => "other_sql" ) + expect(span.attributes).not_to have_key("appsignal.category") expect(span.attributes).not_to have_key("appsignal.body") end end @@ -5057,11 +5057,11 @@ def perform(transaction) Appsignal::Transaction.complete_current! span = event_spans.first - expect(span.name).to eq("Title") + expect(span.name).to eq("custom.event (Title)") expect(span.attributes).to include( - "appsignal.body" => "Body", - "appsignal.category" => "custom.event" + "appsignal.body" => "Body" ) + expect(span.attributes).not_to have_key("appsignal.category") expect(span.attributes).not_to have_key("db.query.text") expect(span.attributes).not_to have_key("db.system.name") end @@ -5096,8 +5096,8 @@ def perform(transaction) 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" } + outer = event_spans.find { |s| s.name == "outer.event (Outer)" } + inner = event_spans.find { |s| s.name == "inner.event (Inner)" } expect(inner.parent_span_id).to eq(outer.span_id) expect(outer.parent_span_id).to eq(root_span.span_id) @@ -5114,7 +5114,7 @@ def perform(transaction) 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.category") expect(span.attributes).not_to have_key("appsignal.title") end end diff --git a/spec/lib/appsignal_spec.rb b/spec/lib/appsignal_spec.rb index 024cc4bd8..2c9e10962 100644 --- a/spec/lib/appsignal_spec.rb +++ b/spec/lib/appsignal_spec.rb @@ -3259,9 +3259,9 @@ def perform expect(event_spans.size).to eq(1) span = event_spans.first - expect(span.name).to eq("title") + expect(span.name).to eq("name (title)") expect(span.parent_span_id).to eq(root_span.span_id) - expect(span.attributes["appsignal.category"]).to eq("name") + expect(span.attributes).not_to have_key("appsignal.category") expect(span.attributes["appsignal.body"]).to eq("body") expect(span.attributes).not_to have_key("db.query.text") expect(span.attributes).not_to have_key("db.system.name") @@ -3292,8 +3292,8 @@ def perform 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.name).to eq("name (title)") + expect(span.attributes).not_to have_key("appsignal.category") expect(span.attributes["appsignal.body"]).to eq("body") end end @@ -3322,8 +3322,8 @@ def perform 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.name).to eq("name (title)") + expect(span.attributes).not_to have_key("appsignal.category") expect(span.attributes["appsignal.body"]).to eq("body") end end @@ -3357,9 +3357,9 @@ def perform expect(event_spans.size).to eq(1) span = event_spans.first - expect(span.name).to eq("title") + expect(span.name).to eq("name (title)") expect(span.parent_span_id).to eq(root_span.span_id) - expect(span.attributes["appsignal.category"]).to eq("name") + expect(span.attributes).not_to have_key("appsignal.category") expect(span.attributes["db.query.text"]).to eq("body") expect(span.attributes["db.system.name"]).to eq("other_sql") expect(span.attributes).not_to have_key("appsignal.body") diff --git a/spec/support/shared_contexts/collector_mode.rb b/spec/support/shared_contexts/collector_mode.rb index fea592b30..23079dd72 100644 --- a/spec/support/shared_contexts/collector_mode.rb +++ b/spec/support/shared_contexts/collector_mode.rb @@ -109,6 +109,25 @@ def scope_of(span) [span.instrumentation_scope.name, span.instrumentation_scope.version] end + # In collector mode the event's category (its AppSignal event name, such as + # "sql.active_record") is no longer emitted as an attribute. It leads the + # span name instead, either on its own or followed by the human-readable + # title as "category (title)". This returns the category back out of an + # event span's name, ignoring any title, so specs can match on it. + def event_category(span) + span.name.sub(/ \(.*\)\z/, "") + end + + # The event spans whose category matches, regardless of their title. + def event_spans_for(category) + event_spans.select { |span| event_category(span) == category } + end + + # The first event span whose category matches, regardless of its title. + def event_span_for(category) + event_spans_for(category).first + end + # The OpenTelemetry `exception` events recorded across all finished spans # (errors attach to the span that was current when they were set, which may # be the root span or an event span). From 5c7d3245e6cc03b675fb87546908983d1526947d Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Wed, 29 Jul 2026 14:42:16 +0200 Subject: [PATCH 36/69] Rename the event span name writer `write_event_name_attributes` used to set the `appsignal.category` attribute alongside the span name. It sets no attribute now, so the name described something it does not do. It is `write_event_span_name`, which stays parallel to `write_event_body_attributes`, which does still write attributes. --- lib/appsignal/transaction/opentelemetry_backend.rb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/appsignal/transaction/opentelemetry_backend.rb b/lib/appsignal/transaction/opentelemetry_backend.rb index 3729c97aa..a455ae104 100644 --- a/lib/appsignal/transaction/opentelemetry_backend.rb +++ b/lib/appsignal/transaction/opentelemetry_backend.rb @@ -155,7 +155,7 @@ def finish_event(name, title, body, body_format) return if @event_stack.empty? frame = @event_stack.pop - write_event_name_attributes(frame.span, name, title) + write_event_span_name(frame.span, name, title) write_event_body_attributes(frame.span, body, body_format) write_event_allocation_count(frame) ::OpenTelemetry::Context.detach(frame.token) @@ -174,7 +174,7 @@ def record_event( # rubocop:disable Metrics/ParameterLists :start_timestamp => start_time, :kind => opentelemetry_kind ) - write_event_name_attributes(span, name, title) + write_event_span_name(span, name, title) write_event_body_attributes(span, body, body_format) # A recorded event has no start hook, so we never measured its # allocations. We deliberately set no allocation attribute rather than a @@ -688,7 +688,7 @@ def write_tags(tags) # https://example.com"), it follows in parentheses, giving # "sql.active_record (User Load)". Some integrations pass the event # name as the title as well; in that case the name is not repeated. - def write_event_name_attributes(span, name, title) + def write_event_span_name(span, name, title) has_title = title && !title.empty? && title != name span.name = has_title ? "#{name} (#{title})" : name end From 10a7f307fce63ad709a0ca91fe5328f9256833a9 Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Thu, 30 Jul 2026 13:20:43 +0200 Subject: [PATCH 37/69] Link bulk enqueued Que jobs instead of parenting Every job enqueued by `Que.bulk_enqueue` shares one producer span, because the whole batch is recorded as a single `bulk_enqueue.que` event. A span can have only one parent, so parenting hangs the entire batch off that one span. The OpenTelemetry messaging conventions ask for links by default, and allow the producer to be the parent only when it produced a single message. Que records nothing that tells its two enqueue paths apart. Both insert paths write the same columns, and a job's `data` column holds only its tags. The enqueue side marks a batch with a tag, and the worker links instead of parenting when it finds that tag. The tag contains no colon, so a reader that splits tags on the colon to rebuild the trace context carrier ignores it. Que allows five tags per job and the trace context already takes one. On the batch path the marker and the trace context are kept or dropped together, because propagating without the marker would make the worker parent the whole batch. When there is no room for both, the batch is enqueued without trace context. --- lib/appsignal/integrations/que.rb | 58 +++++++++++--- spec/lib/appsignal/integrations/que_spec.rb | 85 ++++++++++++++++++++- 2 files changed, 132 insertions(+), 11 deletions(-) diff --git a/lib/appsignal/integrations/que.rb b/lib/appsignal/integrations/que.rb index cd336bb2a..f575992e3 100644 --- a/lib/appsignal/integrations/que.rb +++ b/lib/appsignal/integrations/que.rb @@ -26,6 +26,17 @@ def self.set(carrier, key, value) MAX_TAG_LENGTH = defined?(::Que::Job::MAXIMUM_TAG_LENGTH) ? ::Que::Job::MAXIMUM_TAG_LENGTH : 100 + # Marks a job as one of a batch enqueued by `bulk_enqueue`, so the worker + # can tell the two enqueue paths apart. Que itself records nothing that + # distinguishes them: both insert paths write the same columns, and the + # job's `data` only ever holds its tags. So the enqueue side has to say so. + # + # This deliberately contains no colon. The trace context rides in the same + # tags array as `"key:value"` strings, so a reader that splits tags on the + # colon to rebuild the carrier drops this tag on its own. That keeps it out + # of the carrier for us and for OpenTelemetry's own Que instrumentation. + BULK_TAG = "appsignal.bulk_enqueue" + # Que only has tags from version 1.0 on, and they are the only carrier its # enqueue API exposes. On Que 0.x there is nowhere to put the trace context # that survives to the worker, so propagation is skipped there. Writing the @@ -54,13 +65,21 @@ def extract(tags) # Que's limits: propagation is skipped rather than break the enqueue. # Outside collector mode, and on Que versions without tags, the tags are # returned unchanged. - def inject(tags) + # + # Pass `bulk` for a `bulk_enqueue` batch, which adds the bulk marker too. + # Both are kept or dropped together, because a batch missing its marker + # would parent every job in it to the one producer span. + def inject(tags, bulk: false) original = Array(tags) return original unless TAGS_SUPPORTED injected = Appsignal::OpenTelemetry.if_started do copy = original.dup ::OpenTelemetry.propagation.inject(copy, :setter => TagSetter) + # The marker has nothing to link back to without a trace context, so + # only add it when the context was actually injected. The propagator + # writes nothing when there is no valid span to propagate. + copy << BULK_TAG if bulk && copy.length > original.length copy end return original if injected.nil? || !within_limits?(injected) @@ -68,6 +87,12 @@ def inject(tags) injected end + # Whether the job being performed was enqueued as part of a batch, which + # the enqueue side records by adding `BULK_TAG` to the job's tags. + def bulk?(tags) + Array(tags).include?(BULK_TAG) + end + def within_limits?(tags) tags.length <= MAX_TAGS_COUNT && tags.all? { |tag| tag.length <= MAX_TAG_LENGTH } end @@ -77,16 +102,26 @@ def within_limits?(tags) module QuePlugin def _run(*args) local_attrs = respond_to?(:que_attrs) ? que_attrs : attrs + tags = local_attrs.dig(:data, :tags) + + # A job enqueued on its own is the only job its producer span produced, so + # it can be a child of that span as well as link to it. Every job in a + # batch shares one producer span, and a span can only have one parent, so + # parenting a batch would hang the whole batch off that single span. Only + # link those, which is what the OpenTelemetry messaging conventions ask + # for: they use links as the default, and allow the producer to be the + # parent only when it produced a single message. + relationship = QueTraceContext.bulk?(tags) ? :link : :both # Read the incoming trace context off the job's tags so the transaction # links back to the enqueuer. No-op outside collector mode. transaction = Appsignal::Transaction.create( Appsignal::Transaction::BACKGROUND_JOB, - :opentelemetry_context => QueTraceContext.extract(local_attrs.dig(:data, :tags)), + :opentelemetry_context => QueTraceContext.extract(tags), :opentelemetry_scope => ["appsignal-ruby/que", Appsignal::VERSION], :opentelemetry_kind => :consumer, - :opentelemetry_relationship => :both + :opentelemetry_relationship => relationship ) begin @@ -166,13 +201,13 @@ def forward_job_options(kwargs, job_options) # 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) + def record_enqueue(job_options, event_name, title, bulk: false) # 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) + return yield job_options_with_context(job_options, :bulk => bulk) end Appsignal.instrument( @@ -181,15 +216,15 @@ def record_enqueue(job_options, event_name, title) :opentelemetry_kind => :producer, :opentelemetry_scope => ["appsignal-ruby/que", Appsignal::VERSION] ) do - yield job_options_with_context(job_options) + yield job_options_with_context(job_options, :bulk => bulk) end end # In collector mode, injects the current trace context into a copy of the # job's tags and returns the tag-augmented `job_options`; a no-op that # returns `job_options` unchanged outside collector mode. - def job_options_with_context(job_options) - tags = QueTraceContext.inject(job_options[:tags]) + def job_options_with_context(job_options, bulk: false) + tags = QueTraceContext.inject(job_options[:tags], :bulk => bulk) tags.empty? ? job_options : job_options.merge(:tags => tags) end end @@ -203,7 +238,12 @@ def job_options_with_context(job_options) # the inner enqueues are pass-throughs. module QueBulkClientPlugin def bulk_enqueue(job_options: {}, **rest, &block) - record_enqueue(job_options, "bulk_enqueue.que", bulk_enqueue_title(job_options)) do |merged| + record_enqueue( + job_options, + "bulk_enqueue.que", + bulk_enqueue_title(job_options), + :bulk => true + ) do |merged| # Flag the batch so the enqueues this block triggers pass through # without recording, without reading Que's internal bulk state. was_bulk = Thread.current[:appsignal_que_bulk_enqueue] diff --git a/spec/lib/appsignal/integrations/que_spec.rb b/spec/lib/appsignal/integrations/que_spec.rb index af74fb419..b9284a69b 100644 --- a/spec/lib/appsignal/integrations/que_spec.rb +++ b/spec/lib/appsignal/integrations/que_spec.rb @@ -355,6 +355,51 @@ def perform expect(link_context.hex_span_id).to eq(span_id_hex) end end + + # Only Que 2's `bulk_enqueue` writes the bulk tag, but reading it back is + # the same on every version that has tags, so this runs on Que 1 too. + context "with incoming trace context from a bulk enqueue", + :if => DependencyHelper.que1_present? do + let(:trace_id_hex) { "0af7651916cd43dd8448eb211c80319c" } + let(:span_id_hex) { "b7ad6b7169203331" } + let(:job_attrs) do + super().merge( + :data => { + :tags => [ + "traceparent:00-#{trace_id_hex}-#{span_id_hex}-01", + Appsignal::Integrations::QueTraceContext::BULK_TAG + ] + } + ) + end + + def perform + perform_que_job(instance) + end + + it "in agent mode", :agent_mode do + start_agent + expect { perform }.to change { created_transactions.length }.by(1) + expect(last_transaction).to be_completed + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + + # Every job in the batch shares the one producer span, so the job only + # links back to it. It gets its own trace instead of hanging the whole + # batch off that span. + expect(root_span.kind).to eq(:consumer) + expect(root_span.parent_span_id).to eq(::OpenTelemetry::Trace::INVALID_SPAN_ID) + expect(root_span.hex_trace_id).to_not eq(trace_id_hex) + + expect(root_span.links.size).to eq(1) + link_context = root_span.links.first.span_context + expect(link_context.hex_trace_id).to eq(trace_id_hex) + expect(link_context.hex_span_id).to eq(span_id_hex) + end + end end end @@ -480,6 +525,21 @@ def expect_job_arguments_untouched expect(enqueued_tags).to eq(full) end + + it "propagates on the last free tag and adds no bulk tag", + :collector_mode, :if => DependencyHelper.que1_present? do + start_collector_agent + set_current_transaction(http_request_transaction) + + # A job enqueued on its own needs no bulk tag, so one free tag is enough + # to propagate. The batch path needs two and would skip propagation here. + enqueue(:tags => %w[t1 t2 t3 t4]) + Appsignal::Transaction.complete_current! + + expect(enqueued_tags).to include(a_string_starting_with("traceparent:")) + expect(enqueued_tags) + .to_not include(Appsignal::Integrations::QueTraceContext::BULK_TAG) + end end context "without an active transaction" do @@ -596,10 +656,13 @@ def bulk_enqueue(tags: ["user:42"]) 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. + # Every job in the batch carries the one producer span's context, plus + # the tag marking it as one of a batch so the worker only links back. expect(enqueued_tags).to include("user:42") expect(enqueued_tags) .to include("traceparent:00-#{producer.hex_trace_id}-#{producer.hex_span_id}-01") + expect(enqueued_tags) + .to include(Appsignal::Integrations::QueTraceContext::BULK_TAG) end it "skips propagation rather than break the enqueue when tags are full", @@ -613,6 +676,21 @@ def bulk_enqueue(tags: ["user:42"]) expect(enqueued_tags).to eq(full) end + + it "skips propagation when only the trace context would fit", + :collector_mode do + start_collector_agent + set_current_transaction(http_request_transaction) + + # There is room for the trace context but not for the bulk tag as well. + # Propagating without that tag would make the worker parent every job in + # the batch to the one producer span, so nothing is propagated at all. + tags = %w[t1 t2 t3 t4] + bulk_enqueue(:tags => tags) + Appsignal::Transaction.complete_current! + + expect(enqueued_tags).to eq(tags) + end end context "when job enqueue events are suppressed" do @@ -643,8 +721,11 @@ def bulk_enqueue_suppressed(transaction) # No producer span for the suppressed batch... expect(event_spans_for("bulk_enqueue.que")).to be_empty - # ...but the trace context is still injected so the jobs link back. + # ...but the trace context is still injected so the jobs link back, and + # the batch is still marked so they link instead of parenting. expect(enqueued_tags).to include(a_string_starting_with("traceparent:")) + expect(enqueued_tags) + .to include(Appsignal::Integrations::QueTraceContext::BULK_TAG) end end end From 1872a71e3bbcb1d249acfbdaca4f4f6ae753fb26 Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Tue, 28 Jul 2026 16:38:12 +0200 Subject: [PATCH 38/69] Trim shared frames from error cause backtraces In collector mode an error's causes are sent as one JSON span-event attribute, `appsignal.error_causes`, and each cause carried its full backtrace. A cause is raised inside the frames that led to the reported error, so its backtrace ends with the same lines as the reported error's own. For a web request that shared tail is the whole framework and web server stack, repeated for every cause. In a measured three-deep cause chain from a Rails controller action, 88 of each cause's 92 lines were a verbatim repeat and the attribute came to 21,080 characters. The AppSignal Collector caps a span-event attribute at 20,000 characters, and its truncation leaves the JSON invalid, so the collector could not read the causes and dropped the whole chain. Nothing appeared under the error's causes in the trace. Each cause's backtrace now drops the trailing lines it shares with the reported error's. Those lines are already sent once in `exception.stacktrace`, and what is left is where the cause was raised. On the measured payload the attribute comes to about 904 characters. A cause that shares every line keeps its first, because a cause with no lines leaves nothing to show. --- .../transaction/opentelemetry_backend.rb | 36 +++++++- .../transaction/opentelemetry_backend_spec.rb | 88 +++++++++++++++++++ 2 files changed, 121 insertions(+), 3 deletions(-) diff --git a/lib/appsignal/transaction/opentelemetry_backend.rb b/lib/appsignal/transaction/opentelemetry_backend.rb index a455ae104..35706cb6e 100644 --- a/lib/appsignal/transaction/opentelemetry_backend.rb +++ b/lib/appsignal/transaction/opentelemetry_backend.rb @@ -278,14 +278,17 @@ def set_sample_data(key, data) # 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. + # each become their own incident. Each cause only carries the part of its + # backtrace that the reported error's backtrace does not already end with + # (see `lines_without_shared_tail`). def set_error(class_name, message, backtrace, causes, _root_cause_missing) span = current_span + error_lines = Array(backtrace) attributes = { "exception.type" => class_name, "exception.message" => message.to_s, - "exception.stacktrace" => Array(backtrace).join("\n"), + "exception.stacktrace" => error_lines.join("\n"), "appsignal.alert_this_error" => true } @@ -295,7 +298,7 @@ def set_error(class_name, message, backtrace, causes, _root_cause_missing) { "name" => cause[:name], "message" => cause[:message], - "lines" => cause[:backtrace] || [] + "lines" => lines_without_shared_tail(Array(cause[:backtrace]), error_lines) } end ) @@ -681,6 +684,33 @@ def write_tags(tags) end end + # Returns the leading lines of a cause's backtrace that the reported + # error's own backtrace does not already end with. + # + # A cause is raised somewhere inside the frames that led to the reported + # error, so both backtraces share the same trailing frames -- everything + # from the raise point outwards, which for a web request is the whole + # framework and web server stack. Those lines are already sent once, in + # `exception.stacktrace`. Repeating them for every cause pushed the + # `appsignal.error_causes` attribute past the length the collector + # accepts, and the collector truncates an over-long attribute into + # invalid JSON, so it could not read the causes at all and dropped them. + # + # A cause shares its trailing frames with the error it led to, and those + # are already sent in `exception.stacktrace`. Repeating them for every + # cause makes `appsignal.error_causes` too long for the collector to read. + def lines_without_shared_tail(cause_lines, error_lines) + shared = 0 + while shared < cause_lines.length && shared < error_lines.length && + cause_lines[-1 - shared] == error_lines[-1 - shared] + shared += 1 + end + + return cause_lines if shared.zero? + + cause_lines.first([cause_lines.length - shared, 1].max) + end + # The OTel span name is what the collector surfaces as the event's # label in the trace UI. The AS::N `name` (e.g. "sql.active_record") # always leads the span name so it stays visible. When a formatter diff --git a/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb b/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb index 1952768a5..22191f9b2 100644 --- a/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb +++ b/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb @@ -880,6 +880,94 @@ def exception_event(backend) expect(parsed).to eq([{ "name" => "ArgumentError", "message" => "bad arg", "lines" => [] }]) end + describe "a cause backtrace that shares its last lines with the error's" do + def cause_lines(backend) + JSON.parse(exception_event(backend).attributes["appsignal.error_causes"]) + .map { |cause| cause["lines"] } + end + + it "drops the trailing lines the cause shares with the error" do + backend = create_backend + causes = [ + { + :name => "ArgumentError", :message => "bad arg", + :backtrace => ["cause 1", "cause 2", "shared 1", "shared 2"] + }, + { + :name => "KeyError", :message => "missing", + :backtrace => ["cause 3", "shared 1", "shared 2"] + } + ] + backend.set_error( + "RuntimeError", "boom", ["line 1", "shared 1", "shared 2"], causes, false + ) + + expect(cause_lines(backend)).to eq([["cause 1", "cause 2"], ["cause 3"]]) + end + + it "keeps every line when the last lines differ" do + backend = create_backend + causes = [ + { + :name => "ArgumentError", :message => "bad arg", + :backtrace => ["shared 1", "shared 2", "cause 1"] + } + ] + backend.set_error( + "RuntimeError", "boom", ["shared 1", "shared 2", "line 1"], causes, false + ) + + expect(cause_lines(backend)).to eq([["shared 1", "shared 2", "cause 1"]]) + end + + it "keeps the first line when every line is shared" do + backend = create_backend + causes = [ + { + :name => "ArgumentError", :message => "bad arg", + :backtrace => ["shared 1", "shared 2"] + } + ] + backend.set_error( + "RuntimeError", "boom", ["line 1", "shared 1", "shared 2"], causes, false + ) + + expect(cause_lines(backend)).to eq([["shared 1"]]) + end + + it "keeps the first line when the cause's backtrace is the error's backtrace" do + backend = create_backend + lines = ["shared 1", "shared 2"] + causes = [{ :name => "ArgumentError", :message => "bad arg", :backtrace => lines }] + backend.set_error("RuntimeError", "boom", lines, causes, false) + + expect(cause_lines(backend)).to eq([["shared 1"]]) + end + + it "keeps every line when the error has no backtrace" do + causes = [{ :name => "ArgumentError", :message => "bad arg", :backtrace => ["cause 1"] }] + + nil_backtrace = create_backend + nil_backtrace.set_error("RuntimeError", "boom", nil, causes, false) + expect(cause_lines(nil_backtrace)).to eq([["cause 1"]]) + + empty_backtrace = create_backend + empty_backtrace.set_error("RuntimeError", "boom", [], causes, false) + expect(cause_lines(empty_backtrace)).to eq([["cause 1"]]) + end + + it "sends no lines for a cause without a backtrace" do + backend = create_backend + causes = [ + { :name => "ArgumentError", :message => "bad arg", :backtrace => nil }, + { :name => "KeyError", :message => "missing", :backtrace => [] } + ] + backend.set_error("RuntimeError", "boom", ["shared 1"], causes, false) + + expect(cause_lines(backend)).to eq([[], []]) + end + 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) From 95a237b63e2ac45c0bbd2e60dba287f8db3dc7ad Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Tue, 28 Jul 2026 17:34:13 +0200 Subject: [PATCH 39/69] Report how many cause lines were omitted Each error cause in `appsignal.error_causes` no longer carries the trailing backtrace lines it shares with the reported error's own backtrace, and a consumer could not tell that lines were left out or how many. A `lines_omitted` key holds the number of trailing lines dropped for that cause. The AppSignal Processor uses it to add a line reading "[88 repeated lines omitted]" to the end of the cause's backtrace. The count is the number of lines actually removed, so the line kept for a cause that shares every line does not count. The key is left out when nothing was dropped, because a consumer reads a missing key as zero. --- .../transaction/opentelemetry_backend.rb | 45 +++++------ .../transaction/opentelemetry_backend_spec.rb | 81 ++++++++++++++++++- 2 files changed, 101 insertions(+), 25 deletions(-) diff --git a/lib/appsignal/transaction/opentelemetry_backend.rb b/lib/appsignal/transaction/opentelemetry_backend.rb index 35706cb6e..4efde54f6 100644 --- a/lib/appsignal/transaction/opentelemetry_backend.rb +++ b/lib/appsignal/transaction/opentelemetry_backend.rb @@ -273,14 +273,13 @@ def set_sample_data(key, data) # 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. Each cause only carries the part of its - # backtrace that the reported error's backtrace does not already end with - # (see `lines_without_shared_tail`). + # raised it. `appsignal.alert_this_error` tells the collector to report it + # even on a child span. + # + # Causes ride on one `appsignal.error_causes` JSON attribute, whose keys + # match the processor's `ErrorSubCause`. Separate cause events would each + # become their own incident. Each cause carries only the part of its + # backtrace that is not shared (see `trim_shared_tail`). def set_error(class_name, message, backtrace, causes, _root_cause_missing) span = current_span error_lines = Array(backtrace) @@ -295,11 +294,15 @@ def set_error(class_name, message, backtrace, causes, _root_cause_missing) unless causes.empty? attributes["appsignal.error_causes"] = JSON.generate( causes.map do |cause| - { + lines, lines_omitted = trim_shared_tail(Array(cause[:backtrace]), error_lines) + + cause_attributes = { "name" => cause[:name], "message" => cause[:message], - "lines" => lines_without_shared_tail(Array(cause[:backtrace]), error_lines) + "lines" => lines } + cause_attributes["lines_omitted"] = lines_omitted if lines_omitted.positive? + cause_attributes end ) end @@ -685,30 +688,26 @@ def write_tags(tags) end # Returns the leading lines of a cause's backtrace that the reported - # error's own backtrace does not already end with. - # - # A cause is raised somewhere inside the frames that led to the reported - # error, so both backtraces share the same trailing frames -- everything - # from the raise point outwards, which for a web request is the whole - # framework and web server stack. Those lines are already sent once, in - # `exception.stacktrace`. Repeating them for every cause pushed the - # `appsignal.error_causes` attribute past the length the collector - # accepts, and the collector truncates an over-long attribute into - # invalid JSON, so it could not read the causes at all and dropped them. + # error's backtrace does not already end with, and how many trailing lines + # were dropped to get there. # # A cause shares its trailing frames with the error it led to, and those # are already sent in `exception.stacktrace`. Repeating them for every # cause makes `appsignal.error_causes` too long for the collector to read. - def lines_without_shared_tail(cause_lines, error_lines) + # + # If every line is shared, one is kept, because a cause with no lines + # leaves the UI nothing to show. That kept line does not count as dropped. + def trim_shared_tail(cause_lines, error_lines) shared = 0 while shared < cause_lines.length && shared < error_lines.length && cause_lines[-1 - shared] == error_lines[-1 - shared] shared += 1 end - return cause_lines if shared.zero? + return [cause_lines, 0] if shared.zero? - cause_lines.first([cause_lines.length - shared, 1].max) + kept = [cause_lines.length - shared, 1].max + [cause_lines.first(kept), cause_lines.length - kept] end # The OTel span name is what the collector surfaces as the event's diff --git a/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb b/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb index 22191f9b2..0dee2aba9 100644 --- a/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb +++ b/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb @@ -881,9 +881,12 @@ def exception_event(backend) end describe "a cause backtrace that shares its last lines with the error's" do - def cause_lines(backend) + def parsed_causes(backend) JSON.parse(exception_event(backend).attributes["appsignal.error_causes"]) - .map { |cause| cause["lines"] } + end + + def cause_lines(backend) + parsed_causes(backend).map { |cause| cause["lines"] } end it "drops the trailing lines the cause shares with the error" do @@ -966,6 +969,80 @@ def cause_lines(backend) expect(cause_lines(backend)).to eq([[], []]) end + + it "reports how many lines were dropped from each cause" do + backend = create_backend + causes = [ + { + :name => "ArgumentError", :message => "bad arg", + :backtrace => ["cause 1", "cause 2", "shared 1", "shared 2"] + }, + { + :name => "KeyError", :message => "missing", + :backtrace => ["cause 3", "shared 2"] + } + ] + backend.set_error( + "RuntimeError", "boom", ["line 1", "shared 1", "shared 2"], causes, false + ) + + expect(parsed_causes(backend)).to eq( + [ + { + "name" => "ArgumentError", "message" => "bad arg", + "lines" => ["cause 1", "cause 2"], "lines_omitted" => 2 + }, + { + "name" => "KeyError", "message" => "missing", + "lines" => ["cause 3"], "lines_omitted" => 1 + } + ] + ) + end + + it "reports no dropped lines for a cause that shares no lines" do + backend = create_backend + causes = [ + { + :name => "ArgumentError", :message => "bad arg", + :backtrace => ["shared 1", "shared 2", "cause 1"] + } + ] + backend.set_error( + "RuntimeError", "boom", ["shared 1", "shared 2", "line 1"], causes, false + ) + + expect(parsed_causes(backend).first).not_to have_key("lines_omitted") + end + + # Every line is shared, but the first one is kept, so it was not dropped + # and is not counted. + it "does not count the shared line it keeps when every line is shared" do + backend = create_backend + causes = [ + { + :name => "ArgumentError", :message => "bad arg", + :backtrace => ["shared 1", "shared 2", "shared 3"] + } + ] + backend.set_error( + "RuntimeError", "boom", ["line 1", "shared 1", "shared 2", "shared 3"], causes, false + ) + + cause = parsed_causes(backend).first + expect(cause["lines"]).to eq(["shared 1"]) + expect(cause["lines_omitted"]).to eq(2) + end + + it "reports no dropped lines when the only line a cause has is shared" do + backend = create_backend + causes = [{ :name => "ArgumentError", :message => "bad arg", :backtrace => ["shared 1"] }] + backend.set_error("RuntimeError", "boom", ["line 1", "shared 1"], causes, false) + + cause = parsed_causes(backend).first + expect(cause["lines"]).to eq(["shared 1"]) + expect(cause).not_to have_key("lines_omitted") + end end it "does not set appsignal.error_causes when there are no causes" do From 6faf76f28971ad6d2ed9a823bf89e34fa0fe02b1 Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Thu, 30 Jul 2026 18:50:45 +0200 Subject: [PATCH 40/69] Skip trace context when enqueue events are off With `enable_job_enqueue_instrumentation` off, the job enqueue integrations recorded no enqueue event but still wrote trace context onto the outgoing job. There was no producer span to point at, so what they wrote was the context of whatever span happened to be current, such as the surrounding web request. The performing job then linked back to a span that is not a producer, which the OpenTelemetry messaging conventions do not allow. Each enqueue integration checks the option itself and skips the enqueue entirely, so it writes no trace context either. A job enqueued while the option is off starts its own trace. `Transaction#job_enqueue_events_suppressed?` no longer folds the option in, so it answers only whether an outer integration such as Active Job is already recording this enqueue. A nested integration must keep propagating, because the outer integration's producer span is what the performing job links back to, and only the nested integration owns the carrier the job travels on. --- lib/appsignal/hooks/active_job.rb | 13 ++++--- .../integrations/delayed_job_plugin.rb | 5 +++ lib/appsignal/integrations/que.rb | 10 ++++++ lib/appsignal/integrations/resque.rb | 7 ++++ lib/appsignal/integrations/shoryuken.rb | 7 ++++ lib/appsignal/integrations/sidekiq.rb | 7 ++++ lib/appsignal/transaction.rb | 11 +++--- spec/lib/appsignal/hooks/activejob_spec.rb | 32 ++++++++++++++++- .../integrations/delayed_job_plugin_spec.rb | 30 ++++++++++++++++ spec/lib/appsignal/integrations/que_spec.rb | 34 +++++++++++++++++++ .../lib/appsignal/integrations/resque_spec.rb | 32 +++++++++++++++++ .../appsignal/integrations/shoryuken_spec.rb | 32 +++++++++++++++++ .../appsignal/integrations/sidekiq_spec.rb | 32 +++++++++++++++++ spec/lib/appsignal/transaction_spec.rb | 8 +++-- 14 files changed, 248 insertions(+), 12 deletions(-) diff --git a/lib/appsignal/hooks/active_job.rb b/lib/appsignal/hooks/active_job.rb index 77b0b2403..f32109879 100644 --- a/lib/appsignal/hooks/active_job.rb +++ b/lib/appsignal/hooks/active_job.rb @@ -159,10 +159,15 @@ module ActiveJobTraceContext # transparent pass-through when there's no active transaction, and # `inject_context` no-ops outside collector mode. def enqueue(*, **) - # Skip recording the event when enqueue events are suppressed. That is - # the case when enqueue instrumentation is disabled, and it keeps this - # integration consistent with the standalone adapters (Sidekiq, ...), - # which already gate their own enqueue event on this check. + # When enqueue instrumentation is disabled, drop the trace context + # along with the event. Without an enqueue event there is no producer + # span, so the context we would write is that of whatever span is + # current, such as the surrounding web request. The job that performs + # later would then link back to a span that is not a producer. + return super if Appsignal.config && !Appsignal.config[:enable_job_enqueue_instrumentation] + + # Another enqueue integration is already recording this enqueue, so + # don't record it a second time. if Appsignal::Transaction.current? && Appsignal::Transaction.current.job_enqueue_events_suppressed? return super diff --git a/lib/appsignal/integrations/delayed_job_plugin.rb b/lib/appsignal/integrations/delayed_job_plugin.rb index 2adcaa4fe..91a9be38d 100644 --- a/lib/appsignal/integrations/delayed_job_plugin.rb +++ b/lib/appsignal/integrations/delayed_job_plugin.rb @@ -25,6 +25,11 @@ class DelayedJobPlugin < ::Delayed::Plugin # another job). An enqueue with no active transaction is a transparent # pass-through. def self.enqueue_with_instrumentation(job, block) + # Skip the enqueue event when enqueue instrumentation is disabled. + if Appsignal.config && !Appsignal.config[:enable_job_enqueue_instrumentation] + return block.call(job) + end + # Under Active Job the enqueue is already recorded as an # `enqueue.active_job` event, so skip recording it again here. if Appsignal::Transaction.current? && diff --git a/lib/appsignal/integrations/que.rb b/lib/appsignal/integrations/que.rb index f575992e3..e80555b6c 100644 --- a/lib/appsignal/integrations/que.rb +++ b/lib/appsignal/integrations/que.rb @@ -202,6 +202,16 @@ def forward_job_options(kwargs, job_options) # performs links back. Yields the (possibly tag-augmented) `job_options` to # do the actual enqueue. def record_enqueue(job_options, event_name, title, bulk: false) + # When enqueue instrumentation is disabled, drop the trace context along + # with the event, so yield the job options untouched. Without an enqueue + # event there is no producer span, so the context we would write is that + # of whatever span is current, such as the surrounding web request. The + # job that performs later would then link back to a span that is not a + # producer. + if Appsignal.config && !Appsignal.config[:enable_job_enqueue_instrumentation] + return yield job_options + end + # Under Active Job the enqueue is already recorded as an # `enqueue.active_job` event, so skip recording it again here. The trace # context is still injected so the performed job links back. diff --git a/lib/appsignal/integrations/resque.rb b/lib/appsignal/integrations/resque.rb index 2033470f4..7914de425 100644 --- a/lib/appsignal/integrations/resque.rb +++ b/lib/appsignal/integrations/resque.rb @@ -48,6 +48,13 @@ def perform # @!visibility private module ResquePushIntegration def push(queue, item) + # When enqueue instrumentation is disabled, drop the trace context along + # with the event. Without an enqueue event there is no producer span, so + # the context we would write is that of whatever span is current, such as + # the surrounding web request. The job that performs later would then + # link back to a span that is not a producer. + return super if Appsignal.config && !Appsignal.config[:enable_job_enqueue_instrumentation] + # Under Active Job the enqueue is already recorded as an # `enqueue.active_job` event, so skip recording it again here. The trace # context is still injected so the performed job links back. diff --git a/lib/appsignal/integrations/shoryuken.rb b/lib/appsignal/integrations/shoryuken.rb index ee5c30868..3f67423ae 100644 --- a/lib/appsignal/integrations/shoryuken.rb +++ b/lib/appsignal/integrations/shoryuken.rb @@ -165,6 +165,13 @@ def fetch_args(batch, sqs_msg, body) # @!visibility private class ShoryukenClientMiddleware def call(options) + # When enqueue instrumentation is disabled, drop the trace context along + # with the event. Without an enqueue event there is no producer span, so + # the context we would write is that of whatever span is current, such as + # the surrounding web request. The job that performs later would then + # link back to a span that is not a producer. + return yield if Appsignal.config && !Appsignal.config[:enable_job_enqueue_instrumentation] + # Under Active Job the enqueue is already recorded as an # `enqueue.active_job` event, so skip recording it again here. The trace # context is still injected so the performed job links back. diff --git a/lib/appsignal/integrations/sidekiq.rb b/lib/appsignal/integrations/sidekiq.rb index 86eec825b..9a1a58d93 100644 --- a/lib/appsignal/integrations/sidekiq.rb +++ b/lib/appsignal/integrations/sidekiq.rb @@ -115,6 +115,13 @@ def safe_load(content, default) # @!visibility private class SidekiqClientMiddleware def call(_worker_class, job, _queue, _redis_pool) + # When enqueue instrumentation is disabled, drop the trace context along + # with the event. Without an enqueue event there is no producer span, so + # the context we would write is that of whatever span is current, such as + # the surrounding web request. The job that performs later would then + # link back to a span that is not a producer. + return yield if Appsignal.config && !Appsignal.config[:enable_job_enqueue_instrumentation] + # Under Active Job the enqueue is already recorded as an # `enqueue.active_job` event, so skip recording it again here. The trace # context is still injected so the performed job links back. diff --git a/lib/appsignal/transaction.rb b/lib/appsignal/transaction.rb index 328593215..a821ad812 100644 --- a/lib/appsignal/transaction.rb +++ b/lib/appsignal/transaction.rb @@ -403,12 +403,13 @@ def suppress_job_enqueue_events end # @!visibility private + # + # True when an outer integration (Active Job) is already recording this + # enqueue. Nested integrations use it to skip their own enqueue event, but + # they must still propagate trace context: the outer integration's producer + # span is what the performing job links back to, and only the nested + # integration owns the carrier that job travels on. def job_enqueue_events_suppressed? - # When enqueue instrumentation is disabled, every enqueue integration - # treats its event as suppressed. That is how the config option turns the - # enqueue events off across all integrations at once. - return true if Appsignal.config && !Appsignal.config[:enable_job_enqueue_instrumentation] - store("job_enqueue")[:suppressed] == true end diff --git a/spec/lib/appsignal/hooks/activejob_spec.rb b/spec/lib/appsignal/hooks/activejob_spec.rb index c49fa6861..8eb008761 100644 --- a/spec/lib/appsignal/hooks/activejob_spec.rb +++ b/spec/lib/appsignal/hooks/activejob_spec.rb @@ -699,7 +699,23 @@ def perform_with_incoming_context let(:options) { { :enable_job_enqueue_instrumentation => false } } before { ActiveJob::Base.queue_adapter = :test } - it "does not record an enqueue event but still enqueues the job" do + # Captures the job as the adapter receives it, so the test can check what + # trace context the enqueue wrote onto it. + def enqueue_and_capture_job + captured = nil + adapter = ActiveJob::Base.queue_adapter + allow(adapter).to receive(:enqueue).and_wrap_original do |method, job| + captured = job + method.call(job) + end + + set_current_transaction(http_request_transaction) + ActiveJobTestJob.perform_later + + captured + end + + it "does not record an enqueue event but still enqueues the job", :agent_mode do start_agent(**start_agent_args) transaction = http_request_transaction set_current_transaction(transaction) @@ -711,6 +727,20 @@ def perform_with_incoming_context expect(enqueue_events).to be_empty expect(ActiveJob::Base.queue_adapter.enqueued_jobs.count).to eq(1) end + + it "emits no enqueue span and writes no trace context", :collector_mode do + start_collector_agent + + job = enqueue_and_capture_job + Appsignal::Transaction.complete_current! + + # No enqueue event means no producer span, so there is nothing for the + # performing job to link back to and no trace context is written. The + # job starts its own trace instead of linking to the web request. + expect(event_spans_for("enqueue.active_job")).to be_empty + expect(job.serialize).to_not have_key("__otel_headers") + expect(ActiveJob::Base.queue_adapter.enqueued_jobs.count).to eq(1) + end end end diff --git a/spec/lib/appsignal/integrations/delayed_job_plugin_spec.rb b/spec/lib/appsignal/integrations/delayed_job_plugin_spec.rb index 5de4b170f..7b0474335 100644 --- a/spec/lib/appsignal/integrations/delayed_job_plugin_spec.rb +++ b/spec/lib/appsignal/integrations/delayed_job_plugin_spec.rb @@ -66,6 +66,36 @@ def perform_job(job) end end + context "when enqueue instrumentation is disabled" do + let(:start_agent_args) do + { :options => { :enable_job_enqueue_instrumentation => false } } + end + + it "records no enqueue event but still enqueues the job", :agent_mode do + start_agent(**start_agent_args) + transaction = http_request_transaction + set_current_transaction(transaction) + + expect { Delayed::Job.enqueue(DelayedTestJob.new) } + .to change { Delayed::Backend::Test::Job.count }.by(1) + + event_names = transaction.to_h["events"].map { |event| event["name"] } + expect(event_names).to_not include("enqueue.delayed_job") + end + + it "emits no enqueue span but still enqueues the job", :collector_mode do + start_collector_agent + transaction = http_request_transaction + set_current_transaction(transaction) + + expect { Delayed::Job.enqueue(DelayedTestJob.new) } + .to change { Delayed::Backend::Test::Job.count }.by(1) + Appsignal::Transaction.complete_current! + + expect(event_spans_for("enqueue.delayed_job")).to be_empty + end + end + context "without an active transaction" do it "is a transparent pass-through", :agent_mode do start_agent diff --git a/spec/lib/appsignal/integrations/que_spec.rb b/spec/lib/appsignal/integrations/que_spec.rb index b9284a69b..94f86d8ed 100644 --- a/spec/lib/appsignal/integrations/que_spec.rb +++ b/spec/lib/appsignal/integrations/que_spec.rb @@ -601,6 +601,40 @@ def enqueue_suppressed(transaction) end end + context "when enqueue instrumentation is disabled" do + let(:start_agent_args) do + { :options => { :enable_job_enqueue_instrumentation => false } } + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + transaction = http_request_transaction + set_current_transaction(transaction) + + enqueue + + event_names = transaction.to_h["events"].map { |event| event["name"] } + expect(event_names).to_not include("enqueue.que") + expect_job_arguments_untouched + end + + it "in collector mode", :collector_mode do + start_collector_agent + transaction = http_request_transaction + set_current_transaction(transaction) + + enqueue + Appsignal::Transaction.complete_current! + + # No enqueue event means no producer span, so there is nothing for the + # performing job to link back to and no trace context is written. The + # job starts its own trace instead of linking to the web request. + expect(event_spans_for("enqueue.que")).to be_empty + expect(enqueued_tags).to eq(["user:42"]) if DependencyHelper.que1_present? + expect_job_arguments_untouched + end + end + # `bulk_enqueue` is Que 2 only. The whole batch 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 diff --git a/spec/lib/appsignal/integrations/resque_spec.rb b/spec/lib/appsignal/integrations/resque_spec.rb index 77320c08e..270c31097 100644 --- a/spec/lib/appsignal/integrations/resque_spec.rb +++ b/spec/lib/appsignal/integrations/resque_spec.rb @@ -332,6 +332,38 @@ def enqueue_suppressed(transaction) expect(item).to have_key("traceparent") end end + + context "when enqueue instrumentation is disabled" do + let(:start_agent_args) do + { :options => { :enable_job_enqueue_instrumentation => false } } + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + transaction = http_request_transaction + set_current_transaction(transaction) + + expect(enqueue).to eq(:pushed) + + event_names = transaction.to_h["events"].map { |event| event["name"] } + expect(event_names).to_not include("enqueue.resque") + end + + it "in collector mode", :collector_mode do + start_collector_agent + transaction = http_request_transaction + set_current_transaction(transaction) + + expect(enqueue).to eq(:pushed) + Appsignal::Transaction.complete_current! + + # No enqueue event means no producer span, so there is nothing for the + # performing job to link back to and no trace context is written. The + # job starts its own trace instead of linking to the web request. + expect(event_spans_for("enqueue.resque")).to be_empty + expect(item).to_not have_key("traceparent") + end + end end describe "does not set arguments for ActiveJob" do diff --git a/spec/lib/appsignal/integrations/shoryuken_spec.rb b/spec/lib/appsignal/integrations/shoryuken_spec.rb index baaca5b46..7560f94e5 100644 --- a/spec/lib/appsignal/integrations/shoryuken_spec.rb +++ b/spec/lib/appsignal/integrations/shoryuken_spec.rb @@ -506,4 +506,36 @@ def enqueue_suppressed(transaction) expect(options[:message_attributes]).to have_key("traceparent") end end + + context "when enqueue instrumentation is disabled" do + let(:start_agent_args) do + { :options => { :enable_job_enqueue_instrumentation => false } } + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + transaction = http_request_transaction + set_current_transaction(transaction) + + enqueue + + event_names = transaction.to_h["events"].map { |event| event["name"] } + expect(event_names).to_not include("enqueue.shoryuken") + end + + it "in collector mode", :collector_mode do + start_collector_agent + transaction = http_request_transaction + set_current_transaction(transaction) + + enqueue + Appsignal::Transaction.complete_current! + + # No enqueue event means no producer span, so there is nothing for the + # performing job to link back to and no trace context is written. The job + # starts its own trace instead of linking to the web request. + expect(event_spans_for("enqueue.shoryuken")).to be_empty + expect(options).to_not have_key(:message_attributes) + end + end end diff --git a/spec/lib/appsignal/integrations/sidekiq_spec.rb b/spec/lib/appsignal/integrations/sidekiq_spec.rb index 48f7bbdcc..28948b189 100644 --- a/spec/lib/appsignal/integrations/sidekiq_spec.rb +++ b/spec/lib/appsignal/integrations/sidekiq_spec.rb @@ -486,6 +486,38 @@ def enqueue_suppressed(transaction) expect(job).to have_key("traceparent") end end + + context "when enqueue instrumentation is disabled" do + let(:start_agent_args) do + { :options => { :enable_job_enqueue_instrumentation => false } } + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + transaction = http_request_transaction + set_current_transaction(transaction) + + expect(enqueue).to eq(:enqueued) + + event_names = transaction.to_h["events"].map { |event| event["name"] } + expect(event_names).to_not include("enqueue.sidekiq") + end + + it "in collector mode", :collector_mode do + start_collector_agent + transaction = http_request_transaction + set_current_transaction(transaction) + + expect(enqueue).to eq(:enqueued) + Appsignal::Transaction.complete_current! + + # No enqueue event means no producer span, so there is nothing for the + # performing job to link back to and no trace context is written. The + # job starts its own trace instead of linking to the web request. + expect(event_spans_for("enqueue.sidekiq")).to be_empty + expect(job).to_not have_key("traceparent") + end + end end describe Appsignal::Integrations::SidekiqMiddleware do diff --git a/spec/lib/appsignal/transaction_spec.rb b/spec/lib/appsignal/transaction_spec.rb index 0e45298f1..478c4efc3 100644 --- a/spec/lib/appsignal/transaction_spec.rb +++ b/spec/lib/appsignal/transaction_spec.rb @@ -1082,8 +1082,12 @@ def perform context "when enqueue instrumentation is disabled" do let(:options) { { :enable_job_enqueue_instrumentation => false } } - it "reports enqueue events as suppressed" do - expect(transaction.job_enqueue_events_suppressed?).to be(true) + # The config option is not part of this question. Each enqueue + # integration checks it separately, because disabling enqueue + # instrumentation also disables trace context propagation, while + # suppression by an outer integration keeps it. + it "does not report enqueue events as suppressed" do + expect(transaction.job_enqueue_events_suppressed?).to be(false) end end end From d2c3ccca594cd4ec0ac9bcf95cfec2252a1b9d88 Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Thu, 30 Jul 2026 18:12:37 +0200 Subject: [PATCH 41/69] Add an OpenTelemetry span attributes method Integrations need to describe what they instrument in OpenTelemetry's own terms. A database query span should carry `db.system.name`, and an outgoing request span should carry `http.request.method`. Transaction events have nowhere to put that, because they have a name, a title and a body and nothing else. `Appsignal::Transaction#add_opentelemetry_attributes` sets attributes on the span AppSignal is currently recording. That is the innermost open event span, or the transaction's own span when no event is open. Agent mode has no spans, so the extension backend drops the attributes rather than storing them as some other kind of data. The attributes go on AppSignal's own span rather than on OpenTelemetry's current span, which may belong to another instrumentation. Reading the span from AppSignal's own event stack makes that hold by construction, so no caller has to remember it. --- lib/appsignal/transaction.rb | 35 ++++++ lib/appsignal/transaction/base_backend.rb | 7 ++ .../transaction/extension_backend.rb | 7 ++ .../transaction/opentelemetry_backend.rb | 13 ++ sig/appsignal.rbi | 30 +++++ sig/appsignal.rbs | 29 +++++ .../transaction/extension_backend_spec.rb | 9 ++ .../transaction/opentelemetry_backend_spec.rb | 115 ++++++++++++++++++ spec/lib/appsignal/transaction_spec.rb | 91 ++++++++++++++ 9 files changed, 336 insertions(+) diff --git a/lib/appsignal/transaction.rb b/lib/appsignal/transaction.rb index a821ad812..cb0fb6f15 100644 --- a/lib/appsignal/transaction.rb +++ b/lib/appsignal/transaction.rb @@ -821,6 +821,41 @@ def set_metadata(key, value) @backend.set_metadata(key, value) end + # Add OpenTelemetry attributes to the span AppSignal is currently + # recording. + # + # In collector mode, AppSignal records a transaction as an OpenTelemetry + # span, and every instrumented event as a child span. This adds attributes + # to whichever of those spans is open right now: the innermost event + # started by {Appsignal::Helpers::Instrumentation#instrument}, or the + # transaction's own span when no event is open. + # + # Use this to describe what is being instrumented in OpenTelemetry's own + # terms, following the OpenTelemetry semantic conventions where they apply. + # Attributes have no equivalent outside collector mode, so this does + # nothing when collector mode is not active. + # + # @example Describing a database query + # Appsignal.instrument("query.my_database") do + # Appsignal::Transaction.current.add_opentelemetry_attributes( + # "db.system.name" => "mysql" + # ) + # run_the_query + # end + # + # @param attributes [Hash, nil] Attributes to add to the + # current span. Values that are not a String, Integer, Float or boolean + # are converted to a String. Nothing is added when this is nil or empty. + # @return [void] + # + # @see https://opentelemetry.io/docs/specs/semconv/ + # OpenTelemetry semantic conventions + def add_opentelemetry_attributes(attributes = {}) + return if attributes.nil? || attributes.empty? + + @backend.set_attributes(attributes) + end + # @!visibility private # @see Appsignal::Helpers::Instrumentation#report_error def add_error(error, source: nil, &block) diff --git a/lib/appsignal/transaction/base_backend.rb b/lib/appsignal/transaction/base_backend.rb index 13bafc323..68f38ec6d 100644 --- a/lib/appsignal/transaction/base_backend.rb +++ b/lib/appsignal/transaction/base_backend.rb @@ -42,6 +42,13 @@ def set_queue_start(_start) raise NotImplementedError end + # OpenTelemetry span attributes, set on whichever span the backend is + # currently recording. Only meaningful in collector mode; agent mode has + # no span to hang them on. + def set_attributes(_attributes) + raise NotImplementedError + end + # Maps each logical params channel (`:params`, `:request_payload`, # `:function_parameters`) to the storage bucket it lands in. Channels that # share a bucket merge into one `SampleData` object on the transaction; diff --git a/lib/appsignal/transaction/extension_backend.rb b/lib/appsignal/transaction/extension_backend.rb index 3091aee46..874fdd038 100644 --- a/lib/appsignal/transaction/extension_backend.rb +++ b/lib/appsignal/transaction/extension_backend.rb @@ -75,6 +75,13 @@ def set_metadata(key, value) @handle.set_metadata(key, value) end + # Agent mode has no OpenTelemetry spans, so there is nothing to set the + # attributes on. A transaction event has no attribute equivalent in the + # agent protocol either, so they are dropped rather than stored somewhere + # else. + def set_attributes(_attributes) + end + # The agent has a single params slot, so every params channel maps to one # `:params` bucket. The transaction merges the channels into it, and only # the `:params` key ever reaches `set_sample_data`. diff --git a/lib/appsignal/transaction/opentelemetry_backend.rb b/lib/appsignal/transaction/opentelemetry_backend.rb index 4efde54f6..81c1c37bb 100644 --- a/lib/appsignal/transaction/opentelemetry_backend.rb +++ b/lib/appsignal/transaction/opentelemetry_backend.rb @@ -225,6 +225,19 @@ def set_metadata(key, value) @span.set_attribute("appsignal.tag.#{key}", value) end + # Sets OpenTelemetry attributes on AppSignal's current span -- the open + # event span, or the root span when no event is open. This is how an + # integration describes what it instrumented in OpenTelemetry's own terms, + # such as `db.system.name` on a database query. + # + # Never the OTel current span, which may belong to another + # instrumentation. Values are coerced to the primitives OTLP accepts. + def set_attributes(attributes) + current_span.add_attributes( + Appsignal::OpenTelemetry::Attributes.format(attributes) + ) + end + # The collector keeps the request payload, the function parameters and the # query parameters as separate attributes, so each gets its own bucket. # Legacy `params` has no channel of its own, so it maps to the request diff --git a/sig/appsignal.rbi b/sig/appsignal.rbi index 5b53f6a17..d41400d96 100644 --- a/sig/appsignal.rbi +++ b/sig/appsignal.rbi @@ -2022,6 +2022,36 @@ module Appsignal # _@param_ `start` — Queue start time in milliseconds. sig { params(start: Integer).void } def set_queue_start(start); end + + # Add OpenTelemetry attributes to the span AppSignal is currently + # recording. + # + # In collector mode, AppSignal records a transaction as an OpenTelemetry + # span, and every instrumented event as a child span. This adds attributes + # to whichever of those spans is open right now: the innermost event + # started by {Appsignal::Helpers::Instrumentation#instrument}, or the + # transaction's own span when no event is open. + # + # Use this to describe what is being instrumented in OpenTelemetry's own + # terms, following the OpenTelemetry semantic conventions where they apply. + # Attributes have no equivalent outside collector mode, so this does + # nothing when collector mode is not active. + # + # _@param_ `attributes` — Attributes to add to the current span. Values that are not a String, Integer, Float or boolean are converted to a String. Nothing is added when this is nil or empty. + # + # Describing a database query + # ```ruby + # Appsignal.instrument("query.my_database") do + # Appsignal::Transaction.current.add_opentelemetry_attributes( + # "db.system.name" => "mysql" + # ) + # run_the_query + # end + # ``` + # + # _@see_ `https://opentelemetry.io/docs/specs/semconv/` — OpenTelemetry semantic conventions + sig { params(attributes: T.nilable(T::Hash[String, Object])).void } + def add_opentelemetry_attributes(attributes = {}); end end # Custom markers are used on AppSignal.com to indicate events in an diff --git a/sig/appsignal.rbs b/sig/appsignal.rbs index b07b247ed..f81f2f9ee 100644 --- a/sig/appsignal.rbs +++ b/sig/appsignal.rbs @@ -1849,6 +1849,35 @@ module Appsignal # # _@param_ `start` — Queue start time in milliseconds. def set_queue_start: (Integer start) -> void + + # Add OpenTelemetry attributes to the span AppSignal is currently + # recording. + # + # In collector mode, AppSignal records a transaction as an OpenTelemetry + # span, and every instrumented event as a child span. This adds attributes + # to whichever of those spans is open right now: the innermost event + # started by {Appsignal::Helpers::Instrumentation#instrument}, or the + # transaction's own span when no event is open. + # + # Use this to describe what is being instrumented in OpenTelemetry's own + # terms, following the OpenTelemetry semantic conventions where they apply. + # Attributes have no equivalent outside collector mode, so this does + # nothing when collector mode is not active. + # + # _@param_ `attributes` — Attributes to add to the current span. Values that are not a String, Integer, Float or boolean are converted to a String. Nothing is added when this is nil or empty. + # + # Describing a database query + # ```ruby + # Appsignal.instrument("query.my_database") do + # Appsignal::Transaction.current.add_opentelemetry_attributes( + # "db.system.name" => "mysql" + # ) + # run_the_query + # end + # ``` + # + # _@see_ `https://opentelemetry.io/docs/specs/semconv/` — OpenTelemetry semantic conventions + def add_opentelemetry_attributes: (?::Hash[String, Object]? attributes) -> void end # Custom markers are used on AppSignal.com to indicate events in an diff --git a/spec/lib/appsignal/transaction/extension_backend_spec.rb b/spec/lib/appsignal/transaction/extension_backend_spec.rb index 4c18370f8..d73da5635 100644 --- a/spec/lib/appsignal/transaction/extension_backend_spec.rb +++ b/spec/lib/appsignal/transaction/extension_backend_spec.rb @@ -96,6 +96,15 @@ backend.set_metadata("key", "value") end + # OpenTelemetry attributes have nowhere to go in the agent protocol, so the + # backend drops them rather than storing them somewhere else. + it "drops #set_attributes without touching the handle" do + expect(handle).to_not receive(:set_metadata) + expect(handle).to_not receive(:set_sample_data) + + expect { backend.set_attributes("db.system.name" => "redis") }.to_not raise_error + end + it "serializes the sample data to Data and forwards #set_sample_data to the handle" do raw = { "a" => 1 } data = Appsignal::Utils::Data.generate(raw) diff --git a/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb b/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb index 0dee2aba9..c2b508a93 100644 --- a/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb +++ b/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb @@ -769,6 +769,91 @@ def event_span_for(category) end end + describe "#set_attributes" do + def root_span_of(backend) + finished_span(backend.instance_variable_get(:@span)) + end + + it "sets the attributes on the root span when no event is open" do + backend = create_backend + backend.set_attributes("http.request.method" => "GET") + backend.complete + + expect(root_span_of(backend).attributes["http.request.method"]).to eq("GET") + end + + it "sets the attributes on the open event span" do + backend = create_backend + backend.start_event + event_span = backend.instance_variable_get(:@event_stack).last.first + backend.set_attributes("db.system.name" => "redis") + backend.finish_event("query.redis", "GET foo", nil, Appsignal::EventFormatter::DEFAULT) + backend.complete + + expect(finished_span(event_span).attributes["db.system.name"]).to eq("redis") + end + + # The span an attribute lands on decides how the trace timeline reads it, so + # an attribute meant for the transaction must not leak onto an event. + it "leaves the root span untouched when an event is open" do + backend = create_backend + backend.start_event + backend.set_attributes("db.system.name" => "redis") + backend.finish_event("query.redis", "GET foo", nil, Appsignal::EventFormatter::DEFAULT) + backend.complete + + expect(root_span_of(backend).attributes).to_not have_key("db.system.name") + end + + it "sets the attributes on the innermost open event span" do + backend = create_backend + backend.start_event + outer_span = backend.instance_variable_get(:@event_stack).last.first + backend.start_event + inner_span = backend.instance_variable_get(:@event_stack).last.first + backend.set_attributes("db.system.name" => "redis") + backend.finish_event("query.redis", "GET foo", nil, Appsignal::EventFormatter::DEFAULT) + backend.finish_event("perform.job", "MyJob", nil, Appsignal::EventFormatter::DEFAULT) + backend.complete + + expect(finished_span(inner_span).attributes["db.system.name"]).to eq("redis") + expect(finished_span(outer_span).attributes).to_not have_key("db.system.name") + end + + it "converts values OTLP does not accept to strings" do + backend = create_backend + backend.set_attributes("appsignal.group" => :render) + backend.complete + + expect(root_span_of(backend).attributes["appsignal.group"]).to eq("render") + end + + it "keeps the values OTLP accepts as they are" do + backend = create_backend + backend.set_attributes( + "string" => "value", + "integer" => 1, + "float" => 1.5, + "boolean" => true + ) + backend.complete + + attributes = root_span_of(backend).attributes + expect(attributes["string"]).to eq("value") + expect(attributes["integer"]).to eq(1) + expect(attributes["float"]).to eq(1.5) + expect(attributes["boolean"]).to be(true) + end + + it "converts symbol keys to strings" do + backend = create_backend + backend.set_attributes(:"db.system.name" => "redis") + backend.complete + + expect(root_span_of(backend).attributes["db.system.name"]).to eq("redis") + end + end + describe "appsignal.namespace attribute" do # The backend converts the internal namespaces to the values the collector # expects; everything else passes through. @@ -1568,6 +1653,36 @@ def breadcrumb(overrides = {}) .not_to include("exception") end + it "sets attributes on the root span, not a foreign current span" do + backend = create_backend + foreign = nil + with_foreign_current_span do |f| + foreign = f + backend.set_attributes("http.request.method" => "GET") + end + backend.complete + + expect(finished_span(backend.instance_variable_get(:@span)) + .attributes["http.request.method"]).to eq("GET") + expect(finished_span(foreign).attributes).to_not have_key("http.request.method") + end + + it "sets attributes on the open event span, not a foreign current span" do + backend = create_backend + backend.start_event + event_span = backend.instance_variable_get(:@event_stack).last.first + foreign = nil + with_foreign_current_span do |f| + foreign = f + backend.set_attributes("db.system.name" => "redis") + end + backend.finish_event("query.redis", "GET foo", nil, Appsignal::EventFormatter::DEFAULT) + backend.complete + + expect(finished_span(event_span).attributes["db.system.name"]).to eq("redis") + expect(finished_span(foreign).attributes).to_not have_key("db.system.name") + end + it "records a breadcrumb on the root span, not a foreign current span" do backend = create_backend foreign = nil diff --git a/spec/lib/appsignal/transaction_spec.rb b/spec/lib/appsignal/transaction_spec.rb index 478c4efc3..a758452e4 100644 --- a/spec/lib/appsignal/transaction_spec.rb +++ b/spec/lib/appsignal/transaction_spec.rb @@ -1696,6 +1696,97 @@ def perform end end + describe "#add_opentelemetry_attributes" do + let(:transaction) { new_transaction } + + describe "adding attributes when no event is open" do + def perform + transaction.add_opentelemetry_attributes("http.request.method" => "GET") + end + + # Attributes describe an OpenTelemetry span, and agent mode has none, so + # they are dropped rather than stored as some other kind of data. + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + transaction._sample + + expect(transaction).to_not include_tags("http.request.method" => "GET") + expect(transaction).to_not include_metadata("http.request.method" => "GET") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + expect(root_span.attributes["http.request.method"]).to eq("GET") + end + end + + describe "adding attributes while an event is open" do + def perform + set_current_transaction(transaction) + Appsignal.instrument("query.redis") do + transaction.add_opentelemetry_attributes("db.system.name" => "redis") + end + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + transaction._sample + + expect(transaction).to_not include_tags("db.system.name" => "redis") + expect(transaction).to_not include_metadata("db.system.name" => "redis") + end + + # Which span an attribute lands on decides how the trace timeline reads + # it, so an event's attributes must not spill onto the transaction. + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + expect(event_span_for("query.redis").attributes["db.system.name"]).to eq("redis") + expect(root_span.attributes).to_not have_key("db.system.name") + end + end + + describe "adding attributes after an event has finished" do + def perform + set_current_transaction(transaction) + Appsignal.instrument("query.redis") { nil } + transaction.add_opentelemetry_attributes("http.request.method" => "GET") + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + transaction._sample + + expect(transaction).to_not include_tags("http.request.method" => "GET") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + expect(root_span.attributes["http.request.method"]).to eq("GET") + expect(event_span_for("query.redis").attributes) + .to_not have_key("http.request.method") + end + end + + describe "when no attributes are given" do + it_in_both_modes "does nothing" do + expect { transaction.add_opentelemetry_attributes }.to_not raise_error + expect { transaction.add_opentelemetry_attributes(nil) }.to_not raise_error + end + end + end + describe "#add_params deprecation" do let(:transaction) { new_transaction } From 5c006c876c36c0ae1d5ebc671a9ebb92e7751297 Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Mon, 3 Aug 2026 14:04:24 +0200 Subject: [PATCH 42/69] Describe outgoing HTTP requests The trace timeline decides what kind of work a span represents from its OpenTelemetry attributes, and a span with none of them is shown under "Other". The HTTP client spans had none, so an outgoing request looked no different from an uninstrumented block of code. They now carry what the semantic conventions ask for: the request method, the host and port being called, the full URL, and the response status code when a response came back. The method is normalised to the closed set of names the conventions define, keeping the original alongside. The URL is built from its parts rather than taken whole, so it can never carry credentials, and it leaves out the query string. The query string of a call to somebody else's API is the part most likely to hold a key, and the application did not choose its shape. --- lib/appsignal/integrations/faraday.rb | 34 ++++- lib/appsignal/integrations/http.rb | 27 +++- lib/appsignal/integrations/net_http.rb | 25 +++- lib/appsignal/opentelemetry.rb | 3 + .../opentelemetry/http_client_request.rb | 83 ++++++++++++ lib/appsignal/opentelemetry/http_method.rb | 59 +++++++++ lib/appsignal/opentelemetry/http_response.rb | 30 +++++ .../appsignal/integrations/faraday_spec.rb | 8 ++ spec/lib/appsignal/integrations/http_spec.rb | 24 ++++ .../appsignal/integrations/net_http_spec.rb | 76 +++++++++++ .../opentelemetry/http_client_request_spec.rb | 124 ++++++++++++++++++ .../opentelemetry/http_method_spec.rb | 58 ++++++++ .../opentelemetry/http_response_spec.rb | 28 ++++ 13 files changed, 570 insertions(+), 9 deletions(-) create mode 100644 lib/appsignal/opentelemetry/http_client_request.rb create mode 100644 lib/appsignal/opentelemetry/http_method.rb create mode 100644 lib/appsignal/opentelemetry/http_response.rb create mode 100644 spec/lib/appsignal/opentelemetry/http_client_request_spec.rb create mode 100644 spec/lib/appsignal/opentelemetry/http_method_spec.rb create mode 100644 spec/lib/appsignal/opentelemetry/http_response_spec.rb diff --git a/lib/appsignal/integrations/faraday.rb b/lib/appsignal/integrations/faraday.rb index 822cde389..70731490a 100644 --- a/lib/appsignal/integrations/faraday.rb +++ b/lib/appsignal/integrations/faraday.rb @@ -21,6 +21,18 @@ def call(env) :opentelemetry_kind => :client, :opentelemetry_scope => ["appsignal-ruby/faraday", Appsignal::VERSION] ) do + # Describes the span as an outgoing HTTP request. Together with the + # CLIENT kind, this is what the trace timeline reads to recognize it + # as one. + Appsignal::Transaction.current.add_opentelemetry_attributes( + Appsignal::OpenTelemetry::HttpClientRequest.attributes_for( + :method => env[:method], + :scheme => uri.scheme, + :host => uri.host, + :port => uri.port, + :path => uri.path + ) + ) # Write trace context onto the outgoing request so the called service # joins this trace. Injected inside the instrument block, so the written # `traceparent` reflects the Faraday client event's span. No-op outside @@ -32,11 +44,23 @@ def call(env) # instruments. Suppress the adapter's own instrumentation so the # request appears once (as the Faraday event) rather than as nested # Faraday + Net::HTTP client events. - if Appsignal::Transaction.current? - Appsignal::Transaction.current.suppress_http_client_events { @app.call(env) } - else - @app.call(env) - end + response = + if Appsignal::Transaction.current? + Appsignal::Transaction.current.suppress_http_client_events { @app.call(env) } + else + @app.call(env) + end + + # Describes the response on the same span, which the semantic + # conventions ask for whenever one was received. The event is still + # open here, so it lands on the request's own span. The status is read + # off the environment rather than the returned response, because an + # adapter fills the response in later than it fills in the environment. + Appsignal::Transaction.current.add_opentelemetry_attributes( + Appsignal::OpenTelemetry::HttpResponse.attributes_for(env[:status]) + ) + + response end end end diff --git a/lib/appsignal/integrations/http.rb b/lib/appsignal/integrations/http.rb index 1a082766a..17e333b50 100644 --- a/lib/appsignal/integrations/http.rb +++ b/lib/appsignal/integrations/http.rb @@ -13,9 +13,30 @@ def self.instrument(verb, uri, &block) "request.http_rb", "#{verb.to_s.upcase} #{request_uri}", :opentelemetry_kind => :client, - :opentelemetry_scope => ["appsignal-ruby/http_rb", Appsignal::VERSION], - &block - ) + :opentelemetry_scope => ["appsignal-ruby/http_rb", Appsignal::VERSION] + ) do + # Describes the span as an outgoing HTTP request. Together with the + # CLIENT kind, this is what the trace timeline reads to recognize it + # as one. + Appsignal::Transaction.current.add_opentelemetry_attributes( + Appsignal::OpenTelemetry::HttpClientRequest.attributes_for( + :method => verb, + :scheme => parsed_request_uri.scheme, + :host => parsed_request_uri.host, + :port => parsed_request_uri.port, + :path => parsed_request_uri.path + ) + ) + # Describes the response on the same span, which the semantic + # conventions ask for whenever one was received. The event is still + # open here, so it lands on the request's own span. A request that + # followed redirects reports the status of the response it ended on. + block.call.tap do |response| + Appsignal::Transaction.current.add_opentelemetry_attributes( + Appsignal::OpenTelemetry::HttpResponse.attributes_for(response&.code) + ) + end + end end # The event is recorded at the request boundary, so a redirected request diff --git a/lib/appsignal/integrations/net_http.rb b/lib/appsignal/integrations/net_http.rb index 494f03679..9fec35355 100644 --- a/lib/appsignal/integrations/net_http.rb +++ b/lib/appsignal/integrations/net_http.rb @@ -18,11 +18,34 @@ def request(request, body = nil, &block) :opentelemetry_kind => :client, :opentelemetry_scope => ["appsignal-ruby/net_http", Appsignal::VERSION] ) do + # Describes the span as an outgoing HTTP request. Together with the + # CLIENT kind, this is what the trace timeline reads to recognize it + # as one. + # + # The client's own `address` and `port` name the host being called. + # The request's `path` is a request target, so it can carry a query + # string, which the attribute builder cuts off. + Appsignal::Transaction.current.add_opentelemetry_attributes( + Appsignal::OpenTelemetry::HttpClientRequest.attributes_for( + :method => request.method, + :scheme => use_ssl? ? "https" : "http", + :host => address, + :port => port, + :path => request.path + ) + ) # Write trace context onto the outgoing request so the called service # joins this trace. No-op outside collector mode. The request object # is a valid carrier (it responds to `[]=`). Appsignal::OpenTelemetry.inject_context(request) - super + # Describes the response on the same span, which the semantic + # conventions ask for whenever one was received. The event is still + # open here, so it lands on the request's own span. + super.tap do |response| + Appsignal::Transaction.current.add_opentelemetry_attributes( + Appsignal::OpenTelemetry::HttpResponse.attributes_for(response&.code) + ) + end end end end diff --git a/lib/appsignal/opentelemetry.rb b/lib/appsignal/opentelemetry.rb index 48dae01c8..249dab6c4 100644 --- a/lib/appsignal/opentelemetry.rb +++ b/lib/appsignal/opentelemetry.rb @@ -2,6 +2,9 @@ require "appsignal/opentelemetry/attributes" require "appsignal/opentelemetry/dependencies" +require "appsignal/opentelemetry/http_client_request" +require "appsignal/opentelemetry/http_method" +require "appsignal/opentelemetry/http_response" module Appsignal # @!visibility private diff --git a/lib/appsignal/opentelemetry/http_client_request.rb b/lib/appsignal/opentelemetry/http_client_request.rb new file mode 100644 index 000000000..c4b7a3c9c --- /dev/null +++ b/lib/appsignal/opentelemetry/http_client_request.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +module Appsignal + module OpenTelemetry + # @!visibility private + # + # Builds the OpenTelemetry attributes that describe an outgoing HTTP request. + # + # The semantic conventions ask for the request method, the host and port + # being called, and the full URL. Those together are also what the trace + # timeline reads to recognize an outgoing request. + # + # The URL is built from the parts rather than taken whole, which has two + # consequences worth knowing about. + # + # It never contains credentials. A URL can carry a username and password, + # which the conventions say must not be sent. Building the URL from the + # scheme, host, port and path means there is nowhere for them to come from. + # + # It never contains the query string either. The conventions do ask for it, + # but the query string of a call to somebody else's API is the part most + # likely to carry a key or a token, and unlike an incoming request it is not + # something the application chose the shape of. Anything a caller passes as a + # path is cut at the first question mark for the same reason, because some + # clients hand us a request target rather than a path. + module HttpClientRequest + ADDRESS_ATTRIBUTE = "server.address" + PORT_ATTRIBUTE = "server.port" + URL_ATTRIBUTE = "url.full" + + # The port each scheme uses when a URL does not name one. Left out of the + # URL, which is how a URL is normally written, but still reported as the + # port, which the conventions ask for either way. + DEFAULT_PORTS = { + "http" => 80, + "https" => 443 + }.freeze + + class << self + # The attributes describing the given request, as a Hash to pass to + # `add_opentelemetry_attributes`. Takes the request's parts, because + # some clients hand us a URI and others only the parts. + def attributes_for(method:, scheme: nil, host: nil, port: nil, path: nil) + attributes = HttpMethod.attributes_for(method) + attributes[ADDRESS_ATTRIBUTE] = host.to_s unless host.to_s.empty? + + port = port_for(scheme, port) + attributes[PORT_ATTRIBUTE] = port if port + + url = url_for(scheme, host, port, path) + attributes[URL_ATTRIBUTE] = url if url + + attributes + end + + private + + # A client that was not given a port uses the one its scheme implies, so + # report that rather than nothing. + def port_for(scheme, port) + Integer(port, :exception => false) || DEFAULT_PORTS[scheme.to_s] + end + + def url_for(scheme, host, port, path) + return if scheme.to_s.empty? || host.to_s.empty? + + url = +"#{scheme}://#{host}" + url << ":#{port}" if port && port != DEFAULT_PORTS[scheme.to_s] + url << path_without_query(path) + url + end + + # Some clients report the path of a request to the root of a host as + # empty, and others as "/". The request goes to "/" either way, so say so + # rather than let the URL differ by which client made the request. + def path_without_query(path) + without_query = path.to_s.split("?", 2).first.to_s + without_query.empty? ? "/" : without_query + end + end + end + end +end diff --git a/lib/appsignal/opentelemetry/http_method.rb b/lib/appsignal/opentelemetry/http_method.rb new file mode 100644 index 000000000..1b999a0b8 --- /dev/null +++ b/lib/appsignal/opentelemetry/http_method.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +module Appsignal + module OpenTelemetry + # @!visibility private + # + # Builds the OpenTelemetry attributes that describe an HTTP request method. + # + # The semantic conventions treat `http.request.method` as a closed set of + # known method names, so an arbitrary value cannot be passed through. A + # method outside the set becomes `_OTHER`, and a method that only matches + # after upcasing is replaced by its canonical form. Both of those cases keep + # the value we were given in `http.request.method_original`, so the original + # is never lost. + module HttpMethod + # The methods the semantic conventions know about: those defined in + # RFC 9110, plus PATCH from RFC 5789. + KNOWN_METHODS = [ + "CONNECT", + "DELETE", + "GET", + "HEAD", + "OPTIONS", + "PATCH", + "POST", + "PUT", + "TRACE" + ].freeze + + # The value the semantic conventions use for a method they do not know. + OTHER = "_OTHER" + + METHOD_ATTRIBUTE = "http.request.method" + ORIGINAL_ATTRIBUTE = "http.request.method_original" + + class << self + # The attributes describing the given request method, as a Hash to pass + # to `add_opentelemetry_attributes`. Returns an empty Hash when there is + # no method to describe, so a caller that could not read one can pass + # the result on without checking. + def attributes_for(method) + original = method.to_s + return {} if original.empty? + + # Method names are case sensitive, so a value that already matches a + # known method is used as it is, with nothing to preserve. + return { METHOD_ATTRIBUTE => original } if KNOWN_METHODS.include?(original) + + canonical = original.upcase + if KNOWN_METHODS.include?(canonical) + { METHOD_ATTRIBUTE => canonical, ORIGINAL_ATTRIBUTE => original } + else + { METHOD_ATTRIBUTE => OTHER, ORIGINAL_ATTRIBUTE => original } + end + end + end + end + end +end diff --git a/lib/appsignal/opentelemetry/http_response.rb b/lib/appsignal/opentelemetry/http_response.rb new file mode 100644 index 000000000..b37a006f1 --- /dev/null +++ b/lib/appsignal/opentelemetry/http_response.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module Appsignal + module OpenTelemetry + # @!visibility private + # + # Builds the OpenTelemetry attributes that describe an HTTP response. + # + # The semantic conventions ask for the response status code on the span of an + # HTTP request, whether that is a request this application handled or one it + # made. They ask for it only when a response was actually received or sent, + # so a request that never got one is described without it. + module HttpResponse + STATUS_CODE_ATTRIBUTE = "http.response.status_code" + + class << self + # The attributes describing the given response status, as a Hash to pass + # to `add_opentelemetry_attributes`. Returns an empty Hash when there is + # no status to describe, so a caller whose request produced no response + # can pass the result on without checking. + def attributes_for(status) + code = Integer(status, :exception => false) + return {} unless code + + { STATUS_CODE_ATTRIBUTE => code } + end + end + end + end +end diff --git a/spec/lib/appsignal/integrations/faraday_spec.rb b/spec/lib/appsignal/integrations/faraday_spec.rb index bbe789fc9..354b1ab68 100644 --- a/spec/lib/appsignal/integrations/faraday_spec.rb +++ b/spec/lib/appsignal/integrations/faraday_spec.rb @@ -45,6 +45,14 @@ def perform faraday_span = event_span("request.faraday") expect(faraday_span).not_to be_nil expect(faraday_span.kind).to eq(:client) + expect(faraday_span.attributes["http.request.method"]).to eq("GET") + # The client hands us the method as a lowercase Symbol, so the + # canonical form is recorded and the original kept alongside it. + expect(faraday_span.attributes["http.request.method_original"]).to eq("get") + expect(faraday_span.attributes["server.address"]).to eq("www.example.com") + expect(faraday_span.attributes["server.port"]).to eq(80) + expect(faraday_span.attributes["url.full"]).to eq("http://www.example.com/") + expect(faraday_span.attributes["http.response.status_code"]).to eq(200) expect(faraday_span.parent_span_id).to eq(root_span.span_id) expect(scope_of(faraday_span)).to eq(["appsignal-ruby/faraday", Appsignal::VERSION]) diff --git a/spec/lib/appsignal/integrations/http_spec.rb b/spec/lib/appsignal/integrations/http_spec.rb index b23cdca1f..4b34a2126 100644 --- a/spec/lib/appsignal/integrations/http_spec.rb +++ b/spec/lib/appsignal/integrations/http_spec.rb @@ -39,6 +39,14 @@ def perform span = event_spans.first expect(span.name).to eq("request.http_rb (GET http://www.google.com)") expect(span.kind).to eq(:client) + expect(span.attributes["http.request.method"]).to eq("GET") + # The client hands us the method as a lowercase Symbol, so the + # canonical form is recorded and the original kept alongside it. + expect(span.attributes["http.request.method_original"]).to eq("get") + expect(span.attributes["server.address"]).to eq("www.google.com") + expect(span.attributes["server.port"]).to eq(80) + expect(span.attributes["url.full"]).to eq("http://www.google.com/") + expect(span.attributes["http.response.status_code"]).to eq(200) expect(span.parent_span_id).to eq(root_span.span_id) expect(event_category(span)).to eq("request.http_rb") expect(scope_of(span)).to eq(["appsignal-ruby/http_rb", Appsignal::VERSION]) @@ -84,6 +92,14 @@ def perform span = event_spans.first expect(span.name).to eq("request.http_rb (GET https://www.google.com)") expect(span.kind).to eq(:client) + expect(span.attributes["http.request.method"]).to eq("GET") + # The client hands us the method as a lowercase Symbol, so the + # canonical form is recorded and the original kept alongside it. + expect(span.attributes["http.request.method_original"]).to eq("get") + expect(span.attributes["server.address"]).to eq("www.google.com") + expect(span.attributes["server.port"]).to eq(443) + expect(span.attributes["url.full"]).to eq("https://www.google.com/") + expect(span.attributes["http.response.status_code"]).to eq(200) expect(span.parent_span_id).to eq(root_span.span_id) expect(event_category(span)).to eq("request.http_rb") expect(scope_of(span)).to eq(["appsignal-ruby/http_rb", Appsignal::VERSION]) @@ -125,6 +141,10 @@ def perform expect(event_category(span)).to eq("request.http_rb") expect(scope_of(span)).to eq(["appsignal-ruby/http_rb", Appsignal::VERSION]) expect(span.attributes).not_to have_key("appsignal.body") + # The query parameters are the client's to add, and they stay out of + # the URL too. + expect(span.attributes["url.full"]).to eq("https://www.google.com/") + expect(span.attributes["http.response.status_code"]).to eq(200) end end @@ -199,6 +219,10 @@ def perform span = event_spans.first expect(span.name).to eq("request.http_rb (GET http://www.google.com)") expect(span.kind).to eq(:client) + expect(span.attributes["http.request.method"]).to eq("GET") + # The client hands us the method as a lowercase Symbol, so the + # canonical form is recorded and the original kept alongside it. + expect(span.attributes["http.request.method_original"]).to eq("get") expect(event_category(span)).to eq("request.http_rb") expect(scope_of(span)).to eq(["appsignal-ruby/http_rb", Appsignal::VERSION]) end diff --git a/spec/lib/appsignal/integrations/net_http_spec.rb b/spec/lib/appsignal/integrations/net_http_spec.rb index 53957ebc5..68a8e042c 100644 --- a/spec/lib/appsignal/integrations/net_http_spec.rb +++ b/spec/lib/appsignal/integrations/net_http_spec.rb @@ -32,6 +32,11 @@ def perform span = event_spans.first expect(span.name).to eq("request.net_http (GET http://www.google.com)") expect(span.kind).to eq(:client) + expect(span.attributes["http.request.method"]).to eq("GET") + expect(span.attributes["server.address"]).to eq("www.google.com") + expect(span.attributes["server.port"]).to eq(80) + expect(span.attributes["url.full"]).to eq("http://www.google.com/") + expect(span.attributes["http.response.status_code"]).to eq(200) expect(span.parent_span_id).to eq(root_span.span_id) expect(event_category(span)).to eq("request.net_http") expect(scope_of(span)).to eq(["appsignal-ruby/net_http", Appsignal::VERSION]) @@ -78,6 +83,11 @@ def perform span = event_spans.first expect(span.name).to eq("request.net_http (GET https://www.google.com)") expect(span.kind).to eq(:client) + expect(span.attributes["http.request.method"]).to eq("GET") + expect(span.attributes["server.address"]).to eq("www.google.com") + expect(span.attributes["server.port"]).to eq(443) + expect(span.attributes["url.full"]).to eq("https://www.google.com/") + expect(span.attributes["http.response.status_code"]).to eq(200) expect(span.parent_span_id).to eq(root_span.span_id) expect(event_category(span)).to eq("request.net_http") expect(scope_of(span)).to eq(["appsignal-ruby/net_http", Appsignal::VERSION]) @@ -88,6 +98,72 @@ def perform end end + describe "a request with a path and a query string" do + def perform + stub_request(:any, "http://www.google.com/search?q=secret") + + Net::HTTP.get_response(URI.parse("http://www.google.com/search?q=secret")) + end + + it "in agent mode", :agent_mode do + start_agent + transaction = http_request_transaction + set_current_transaction(transaction) + perform + + expect(transaction).to include_event( + "name" => "request.net_http", + "title" => "GET http://www.google.com", + "body" => "" + ) + end + + it "in collector mode", :collector_mode do + start_collector_agent + transaction = http_request_transaction + set_current_transaction(transaction) + perform + Appsignal::Transaction.complete_current! + + span = event_spans.first + # The client gives us a request target, which carries the query string. + # The URL keeps the path and drops the query string. + expect(span.attributes["url.full"]).to eq("http://www.google.com/search") + end + end + + describe "a request the server answered with an error" do + def perform + stub_request(:any, "http://www.google.com/").to_return(:status => 503) + + Net::HTTP.get_response(URI.parse("http://www.google.com")) + end + + it "in agent mode", :agent_mode do + start_agent + transaction = http_request_transaction + set_current_transaction(transaction) + perform + + expect(transaction).to include_event( + "name" => "request.net_http", + "title" => "GET http://www.google.com" + ) + end + + it "in collector mode", :collector_mode do + start_collector_agent + transaction = http_request_transaction + set_current_transaction(transaction) + perform + Appsignal::Transaction.complete_current! + + # A response the server answered with an error is still a response, so it + # is reported the same way a successful one is. + expect(event_spans.first.attributes["http.response.status_code"]).to eq(503) + end + end + # Reads the `traceparent` header off the recorded outgoing request to `url`. def injected_traceparent(url) traceparent = nil diff --git a/spec/lib/appsignal/opentelemetry/http_client_request_spec.rb b/spec/lib/appsignal/opentelemetry/http_client_request_spec.rb new file mode 100644 index 000000000..a900776c0 --- /dev/null +++ b/spec/lib/appsignal/opentelemetry/http_client_request_spec.rb @@ -0,0 +1,124 @@ +# frozen_string_literal: true + +describe Appsignal::OpenTelemetry::HttpClientRequest do + describe ".attributes_for" do + def attributes_for(**args) + described_class.attributes_for(**args) + end + + it "describes the method, the host and port, and the URL" do + expect( + attributes_for( + :method => "GET", + :scheme => "https", + :host => "example.com", + :port => 443, + :path => "/users/1" + ) + ).to eq( + "http.request.method" => "GET", + "server.address" => "example.com", + "server.port" => 443, + "url.full" => "https://example.com/users/1" + ) + end + + # A URL is normally written without the port its scheme implies, but the + # conventions ask for the port either way. + it "leaves a scheme's own port out of the URL" do + expect( + attributes_for(:method => "GET", :scheme => "http", :host => "example.com", :port => 80) + ).to include( + "server.port" => 80, + "url.full" => "http://example.com/" + ) + end + + it "keeps a port the scheme does not imply in the URL" do + expect( + attributes_for( + :method => "GET", :scheme => "http", :host => "example.com", :port => 8080, + :path => "/path" + ) + ).to include( + "server.port" => 8080, + "url.full" => "http://example.com:8080/path" + ) + end + + # Some clients only name a port when the URL did, so the scheme's own port + # has to stand in. The conventions ask for the port on every client span. + it "falls back to the scheme's own port" do + expect( + attributes_for(:method => "GET", :scheme => "https", :host => "example.com") + ).to include( + "server.port" => 443, + "url.full" => "https://example.com/" + ) + end + + it "accepts a port given as a String" do + expect( + attributes_for(:method => "GET", :scheme => "http", :host => "example.com", :port => "8080") + ).to include("server.port" => 8080) + end + + # Some clients hand us a request target rather than a path, which can carry + # the query string along with it. + it "cuts the query string off the path" do + expect( + attributes_for( + :method => "GET", :scheme => "https", :host => "example.com", + :path => "/search?q=secret" + ) + ).to include("url.full" => "https://example.com/search") + end + + # A URL can carry a username and password, which must not be sent. Building + # the URL from the parts means there is nowhere for them to come from. + it "cannot include credentials" do + expect( + attributes_for( + :method => "GET", :scheme => "https", :host => "example.com", :path => "/path" + )["url.full"] + ).to eq("https://example.com/path") + end + + it "leaves out the URL when there is no host to build it from" do + attributes = attributes_for(:method => "GET", :scheme => "https") + + expect(attributes).to_not have_key("url.full") + expect(attributes).to_not have_key("server.address") + expect(attributes["http.request.method"]).to eq("GET") + end + + it "leaves out the URL when there is no scheme to build it from" do + attributes = attributes_for(:method => "GET", :host => "example.com") + + expect(attributes).to_not have_key("url.full") + expect(attributes).to_not have_key("server.port") + expect(attributes["server.address"]).to eq("example.com") + end + + it "normalizes the request method" do + expect( + attributes_for(:method => :get, :scheme => "https", :host => "example.com") + ).to include( + "http.request.method" => "GET", + "http.request.method_original" => "get" + ) + end + + # Some clients report the path of a request to the root of a host as empty, + # and others as "/". The request goes to "/" either way. + it "describes a request without a path as one to the root" do + expect( + attributes_for(:method => "GET", :scheme => "https", :host => "example.com", :path => "") + ).to include("url.full" => "https://example.com/") + end + + it "returns no attributes when there is nothing to describe" do + expect(attributes_for(:method => nil)).to eq({}) + end + end +end diff --git a/spec/lib/appsignal/opentelemetry/http_method_spec.rb b/spec/lib/appsignal/opentelemetry/http_method_spec.rb new file mode 100644 index 000000000..d436d917a --- /dev/null +++ b/spec/lib/appsignal/opentelemetry/http_method_spec.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +describe Appsignal::OpenTelemetry::HttpMethod do + describe ".attributes_for" do + it "passes a known method through, with nothing to preserve" do + expect(described_class.attributes_for("GET")).to eq( + "http.request.method" => "GET" + ) + end + + it "recognizes every method the semantic conventions define" do + %w[CONNECT DELETE GET HEAD OPTIONS PATCH POST PUT TRACE].each do |method| + expect(described_class.attributes_for(method)).to eq( + "http.request.method" => method + ) + end + end + + # Method names are case sensitive, so a lowercase method is not the known + # method. It is replaced by the canonical form, keeping what we were given. + it "upcases a known method given in another case, keeping the original" do + expect(described_class.attributes_for("get")).to eq( + "http.request.method" => "GET", + "http.request.method_original" => "get" + ) + end + + it "accepts a Symbol, as the HTTP client integrations pass" do + expect(described_class.attributes_for(:post)).to eq( + "http.request.method" => "POST", + "http.request.method_original" => "post" + ) + end + + # The conventions treat the method as a closed set, so anything outside it + # has to be reported as `_OTHER` rather than passed through. + it "reports an unknown method as _OTHER, keeping the original" do + expect(described_class.attributes_for("PROPFIND")).to eq( + "http.request.method" => "_OTHER", + "http.request.method_original" => "PROPFIND" + ) + end + + it "keeps the original of an unknown method exactly as given" do + expect(described_class.attributes_for("PropFind")).to eq( + "http.request.method" => "_OTHER", + "http.request.method_original" => "PropFind" + ) + end + + # Callers that could not read a method pass the result on without checking, + # and an empty Hash adds no attributes. + it "returns no attributes when there is no method" do + expect(described_class.attributes_for(nil)).to eq({}) + expect(described_class.attributes_for("")).to eq({}) + end + end +end diff --git a/spec/lib/appsignal/opentelemetry/http_response_spec.rb b/spec/lib/appsignal/opentelemetry/http_response_spec.rb new file mode 100644 index 000000000..245199c0c --- /dev/null +++ b/spec/lib/appsignal/opentelemetry/http_response_spec.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +describe Appsignal::OpenTelemetry::HttpResponse do + describe ".attributes_for" do + it "describes the status code" do + expect(described_class.attributes_for(200)).to eq( + "http.response.status_code" => 200 + ) + end + + it "describes a status code given as a String" do + expect(described_class.attributes_for("404")).to eq( + "http.response.status_code" => 404 + ) + end + + # Callers whose request never produced a response pass the result on without + # checking. + it "returns no attributes when there is no status" do + expect(described_class.attributes_for(nil)).to eq({}) + end + + it "returns no attributes for a status that is not a number" do + expect(described_class.attributes_for("nonsense")).to eq({}) + expect(described_class.attributes_for("")).to eq({}) + end + end +end From 39bc0eb1345a64345143cbe2123da59839762e8b Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Mon, 3 Aug 2026 14:04:58 +0200 Subject: [PATCH 43/69] Describe incoming HTTP requests A web transaction's span already carried the SERVER span kind, but the trace timeline needs an HTTP attribute alongside it before it reads the span as a web request. Without one the request was shown under "Other". It now carries what the semantic conventions ask for: the request method, the path, the scheme, the query string when there is one, and the response status code when a response was sent. The path is the concrete path rather than the route template the conventions call `http.route`, which a Rack application does not necessarily have. The query string is not filtered here, because the collector already filters this attribute. These attributes belong on the transaction's own span, so they are set where the transaction is created rather than after the app has been called, when an event span may be open and would take them instead. The status is only known once the app has responded, so it is set at the next point where the transaction's span is the one attributes go on. A request that raised sent no response, so it gets no status. --- lib/appsignal/integrations/webmachine.rb | 32 ++++++- lib/appsignal/opentelemetry.rb | 1 + .../opentelemetry/http_server_request.rb | 45 ++++++++++ lib/appsignal/rack.rb | 48 ++++++++--- lib/appsignal/rack/abstract_middleware.rb | 52 +++++++++++- lib/appsignal/rack/event_handler.rb | 27 ++++++ .../appsignal/integrations/webmachine_spec.rb | 40 +++++++++ .../opentelemetry/http_server_request_spec.rb | 65 +++++++++++++++ .../rack/abstract_middleware_spec.rb | 26 ++++++ spec/lib/appsignal/rack/event_handler_spec.rb | 78 +++++++++++++++++ .../rack/instrumentation_middleware_spec.rb | 83 +++++++++++++++++++ 11 files changed, 484 insertions(+), 13 deletions(-) create mode 100644 lib/appsignal/opentelemetry/http_server_request.rb create mode 100644 spec/lib/appsignal/opentelemetry/http_server_request_spec.rb diff --git a/lib/appsignal/integrations/webmachine.rb b/lib/appsignal/integrations/webmachine.rb index 6729b0b6c..2e1d42709 100644 --- a/lib/appsignal/integrations/webmachine.rb +++ b/lib/appsignal/integrations/webmachine.rb @@ -23,6 +23,26 @@ def run ) end + unless has_parent_transaction + # Describes the transaction's span as an incoming HTTP request. + # Together with the SERVER span kind the transaction already carries, + # this is what the trace timeline reads to recognize a web request. + # Set here, where the transaction is created, so they land on the + # transaction's own span rather than on the event started below. + # + # Webmachine isn't Rack: the path, scheme and query string come off the + # request's `URI` rather than from Rack's readers. The request's own + # `query` is the parsed form, so the URI is where the string itself is. + transaction.add_opentelemetry_attributes( + Appsignal::OpenTelemetry::HttpServerRequest.attributes_for( + :method => request.method, + :path => request.uri&.path, + :scheme => request.uri&.scheme, + :query => request.uri&.query + ) + ) + end + begin transaction.add_query_parameters_if_nil { request.query } transaction.add_headers_if_nil { request.headers if request.respond_to?(:headers) } @@ -36,7 +56,17 @@ def run ensure transaction.set_action_if_nil("#{resource.class.name}##{request.method}") - Appsignal::Transaction.complete_current! unless has_parent_transaction + unless has_parent_transaction + # Describes the response on the transaction's span, which the + # semantic conventions ask for whenever a response was sent. The + # event above has closed by now, so this lands on the transaction's + # own span. + transaction.add_opentelemetry_attributes( + Appsignal::OpenTelemetry::HttpResponse.attributes_for(response.code) + ) + + Appsignal::Transaction.complete_current! + end end end diff --git a/lib/appsignal/opentelemetry.rb b/lib/appsignal/opentelemetry.rb index 249dab6c4..725ee077a 100644 --- a/lib/appsignal/opentelemetry.rb +++ b/lib/appsignal/opentelemetry.rb @@ -5,6 +5,7 @@ require "appsignal/opentelemetry/http_client_request" require "appsignal/opentelemetry/http_method" require "appsignal/opentelemetry/http_response" +require "appsignal/opentelemetry/http_server_request" module Appsignal # @!visibility private diff --git a/lib/appsignal/opentelemetry/http_server_request.rb b/lib/appsignal/opentelemetry/http_server_request.rb new file mode 100644 index 000000000..19ba934d1 --- /dev/null +++ b/lib/appsignal/opentelemetry/http_server_request.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +module Appsignal + module OpenTelemetry + # @!visibility private + # + # Builds the OpenTelemetry attributes that describe an incoming HTTP + # request. + # + # The semantic conventions ask for the request method, the path and the + # scheme on the span of a request a server handled. Those three together are + # also what the trace timeline reads to recognize a web request. + # + # The path is the concrete path the request was made to, such as `/users/1`. + # It is not the route template the application matched it against, which the + # conventions call `http.route` and which a Rack application does not + # necessarily have. + # + # The query string is asked for whenever the request had one. It is sent + # whole, without the leading question mark, and it is not filtered here. The + # collector filters it with the `filter_request_query_parameters` option, and + # builds the request's query parameters out of it. + # + # Every value is optional, because reading any of them from the request can + # fail. An attribute we have no value for is left out rather than sent + # empty. + module HttpServerRequest + PATH_ATTRIBUTE = "url.path" + SCHEME_ATTRIBUTE = "url.scheme" + QUERY_ATTRIBUTE = "url.query" + + class << self + # The attributes describing the given request, as a Hash to pass to + # `add_opentelemetry_attributes`. + def attributes_for(method:, path: nil, scheme: nil, query: nil) + attributes = HttpMethod.attributes_for(method) + attributes[PATH_ATTRIBUTE] = path.to_s unless path.to_s.empty? + attributes[SCHEME_ATTRIBUTE] = scheme.to_s unless scheme.to_s.empty? + attributes[QUERY_ATTRIBUTE] = query.to_s unless query.to_s.empty? + attributes + end + end + end + end +end diff --git a/lib/appsignal/rack.rb b/lib/appsignal/rack.rb index 20e3c47bc..637e8f510 100644 --- a/lib/appsignal/rack.rb +++ b/lib/appsignal/rack.rb @@ -7,9 +7,46 @@ module Rack APPSIGNAL_EVENT_HANDLER_ID = "appsignal.event_handler_id" APPSIGNAL_EVENT_HANDLER_HAS_ERROR = "appsignal.event_handler.error" APPSIGNAL_RESPONSE_INSTRUMENTED = "appsignal.response_instrumentation_active" + APPSIGNAL_RESPONSE_STATUS = "appsignal.response_status" RACK_AFTER_REPLY = "rack.after_reply" class Utils + # Fetch the HTTP request method from the request. + # + # The request class is configurable, so reading the method can raise. + # Log and return nil in that case, leaving it to the caller to skip + # whatever it needed the method for. + # + # @param request [Rack::Request] Request object. + # @return [String, NilClass] + def self.request_method_from(request) + request.request_method + rescue => error + Appsignal.internal_logger.error( + "Exception while fetching the HTTP request method: #{error.class}: #{error}" + ) + nil + end + + # Fetch a value that describes the request, named after the method that + # reads it. + # + # The request class is configurable, so reading from the request can + # raise. Log and return nil in that case, leaving it to the caller to skip + # whatever it needed the value for. + # + # @param request [Rack::Request] Request object. + # @param name [Symbol] Name of the method that reads the value. + # @return [Object, NilClass] + def self.request_value_from(request, name) + request.public_send(name) + rescue => error + Appsignal.internal_logger.error( + "Exception while fetching the HTTP request #{name}: #{error.class}: #{error}" + ) + nil + end + # Fetch the queue start time from the request environment. # # @since 3.11.0 @@ -53,7 +90,7 @@ def apply_to(transaction) # TODO: Remove in next major/minor version transaction.set_metadata("path", request_path) - request_method = request_method_for(request) + request_method = Appsignal::Rack::Utils.request_method_from(request) if request_method transaction.set_metadata("request_method", request_method) # TODO: Remove in next major/minor version @@ -84,15 +121,6 @@ def params_for(request) nil end - def request_method_for(request) - request.request_method - rescue => error - Appsignal.internal_logger.error( - "Exception while fetching the HTTP request method: #{error.class}: #{error}" - ) - nil - end - def session_data_for(request) return unless request.respond_to?(:session) diff --git a/lib/appsignal/rack/abstract_middleware.rb b/lib/appsignal/rack/abstract_middleware.rb index 05e652b60..ba815d546 100644 --- a/lib/appsignal/rack/abstract_middleware.rb +++ b/lib/appsignal/rack/abstract_middleware.rb @@ -49,6 +49,7 @@ def call(env) # middleware can detect if there is parent instrumentation # middleware active. env[Appsignal::Rack::APPSIGNAL_TRANSACTION] = transaction + add_opentelemetry_request_attributes(transaction, request) end begin @@ -67,8 +68,13 @@ def call(env) ensure add_transaction_metadata_after(transaction, request) - # Complete transaction because this is the top instrumentation middleware. - Appsignal::Transaction.complete_current! unless wrapped_instrumentation + unless wrapped_instrumentation + add_opentelemetry_response_attributes(transaction, request) + + # Complete transaction because this is the top instrumentation + # middleware. + Appsignal::Transaction.complete_current! + end end else @app.call(env) @@ -77,6 +83,45 @@ def call(env) private + # Describes the transaction's span as an incoming HTTP request. Together + # with the SERVER span kind the transaction already carries, this is what + # the trace timeline reads to recognize a web request. + # + # Set where the transaction is created, rather than with the rest of the + # request metadata in {ApplyRackRequest}, which runs after the app has been + # called: by then an event span may be open, and the attributes would land + # on that instead. Only the middleware that created the transaction sets + # them, so nested middleware does not write them again. + def add_opentelemetry_request_attributes(transaction, request) + transaction.add_opentelemetry_attributes( + Appsignal::OpenTelemetry::HttpServerRequest.attributes_for( + :method => Appsignal::Rack::Utils.request_method_from(request), + :path => Appsignal::Rack::Utils.request_value_from(request, :path), + :scheme => Appsignal::Rack::Utils.request_value_from(request, :scheme), + :query => Appsignal::Rack::Utils.request_value_from(request, :query_string) + ) + ) + end + + # Describes the response the app produced on the transaction's span, which + # the semantic conventions ask for whenever a response was sent. + # + # Set from the `ensure` in {#call} rather than from {#call_app}, where the + # status is first known. That runs inside the instrumented event, so the + # attribute would land on the event span instead of on the transaction's + # own span. The status travels between the two in the request environment + # for that reason. + # + # A request whose app raised never produced a status, and is described + # without one. + def add_opentelemetry_response_attributes(transaction, request) + transaction.add_opentelemetry_attributes( + Appsignal::OpenTelemetry::HttpResponse.attributes_for( + request.env[Appsignal::Rack::APPSIGNAL_RESPONSE_STATUS] + ) + ) + end + # Another instrumentation middleware is active earlier in the stack, so # don't report any exceptions here, the top instrumentation middleware # will be the one reporting the exception. @@ -100,6 +145,9 @@ def instrument_app_call(env, transaction) def call_app(env, transaction) status, headers, obody = @app.call(env) + # Remember the status for {#add_opentelemetry_response_attributes}, which + # runs once this event has closed. + env[Appsignal::Rack::APPSIGNAL_RESPONSE_STATUS] = status body = if env[Appsignal::Rack::APPSIGNAL_RESPONSE_INSTRUMENTED] obody diff --git a/lib/appsignal/rack/event_handler.rb b/lib/appsignal/rack/event_handler.rb index 0b792a296..f9e383af6 100644 --- a/lib/appsignal/rack/event_handler.rb +++ b/lib/appsignal/rack/event_handler.rb @@ -68,6 +68,22 @@ def on_start(request, _response) :opentelemetry_context => Appsignal::OpenTelemetry.extract_rack_context(request.env), :opentelemetry_scope => ["appsignal-ruby/rack", Appsignal::VERSION] ) + # Describes the transaction's span as an incoming HTTP request. + # Together with the SERVER span kind the transaction already carries, + # this is what the trace timeline reads to recognize a web request. + # + # Set before the event below starts, because attributes go on + # whichever span is open and these belong on the transaction's own + # span. That event stays open until the response finishes, so there is + # no later point in the request at which these could be set. + transaction.add_opentelemetry_attributes( + Appsignal::OpenTelemetry::HttpServerRequest.attributes_for( + :method => Appsignal::Rack::Utils.request_method_from(request), + :path => Appsignal::Rack::Utils.request_value_from(request, :path), + :scheme => Appsignal::Rack::Utils.request_value_from(request, :scheme), + :query => Appsignal::Rack::Utils.request_value_from(request, :query_string) + ) + ) transaction.start_event( :opentelemetry_scope => ["appsignal-ruby/rack", Appsignal::VERSION] ) @@ -131,6 +147,17 @@ def on_finish(request, response) end queue_start = Appsignal::Rack::Utils.queue_start_from(request.env) transaction.set_queue_start(queue_start) if queue_start + # Describes the response on the transaction's span, which the semantic + # conventions ask for whenever a response was sent. It can be set here + # because the `process_request.rack` event was finished above, which + # leaves the transaction's own span as the one attributes go on. + # + # Only a response the app actually produced counts. The 500 below + # stands in for a status that was never sent, so it is reported as a + # tag and a metric but not as this attribute. + transaction.add_opentelemetry_attributes( + Appsignal::OpenTelemetry::HttpResponse.attributes_for(response&.status) + ) response_status = if response response.status diff --git a/spec/lib/appsignal/integrations/webmachine_spec.rb b/spec/lib/appsignal/integrations/webmachine_spec.rb index 4bc932b57..5c744e5b4 100644 --- a/spec/lib/appsignal/integrations/webmachine_spec.rb +++ b/spec/lib/appsignal/integrations/webmachine_spec.rb @@ -74,6 +74,46 @@ def perform end end + describe "marking the transaction as an incoming HTTP request" do + # These describe the request as a whole, so they belong on the + # transaction's span and on none of the events recorded within it. + it "sets the request attributes on the transaction span only", :collector_mode do + start_collector_agent + perform + + expect(root_span.attributes["http.request.method"]).to eq("GET") + # The path and scheme come off the request's URI, which Webmachine + # builds from the full request URL. The query string is a separate + # attribute, so it must not end up in the path. + expect(root_span.attributes["url.path"]).to eq("/foo") + expect(root_span.attributes["url.scheme"]).to eq("http") + expect(root_span.attributes["url.query"]) + .to eq("param1=value1¶m2=value2") + expect(event_spans).to_not be_empty + event_spans.each do |span| + expect(span.attributes).to_not have_key("http.request.method") + expect(span.attributes).to_not have_key("url.path") + expect(span.attributes).to_not have_key("url.scheme") + expect(span.attributes).to_not have_key("url.query") + end + end + end + + describe "describing the response" do + # The event has closed by the time the response code is known, which is + # what makes the transaction's own span the one this lands on. + it "sets the response status on the transaction span only", :collector_mode do + start_collector_agent + perform + + expect(root_span.attributes["http.response.status_code"]).to eq(200) + expect(event_spans).to_not be_empty + event_spans.each do |span| + expect(span.attributes).to_not have_key("http.response.status_code") + end + end + end + context "with action already set" do let(:app) do proc do diff --git a/spec/lib/appsignal/opentelemetry/http_server_request_spec.rb b/spec/lib/appsignal/opentelemetry/http_server_request_spec.rb new file mode 100644 index 000000000..e8be141ca --- /dev/null +++ b/spec/lib/appsignal/opentelemetry/http_server_request_spec.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +describe Appsignal::OpenTelemetry::HttpServerRequest do + describe ".attributes_for" do + it "describes the method, path, scheme and query string" do + expect( + described_class.attributes_for( + :method => "GET", + :path => "/users/1", + :scheme => "https", + :query => "page=2&query=lorem" + ) + ).to eq( + "http.request.method" => "GET", + "url.path" => "/users/1", + "url.scheme" => "https", + "url.query" => "page=2&query=lorem" + ) + end + + # A request without a query string gets no query attribute, rather than an + # empty one. Rack reports a missing query string as an empty String. + it "leaves out an empty query string" do + expect( + described_class.attributes_for(:method => "GET", :path => "/", :query => "") + ).to eq( + "http.request.method" => "GET", + "url.path" => "/" + ) + end + + # The method goes through the same normalization as anywhere else, so an + # unknown method is reported as `_OTHER` with the original kept. + it "normalizes the request method" do + expect( + described_class.attributes_for(:method => "get", :path => "/", :scheme => "http") + ).to eq( + "http.request.method" => "GET", + "http.request.method_original" => "get", + "url.path" => "/", + "url.scheme" => "http" + ) + end + + # Reading any of these from the request can fail, and the callers pass on + # whatever they got without checking. + it "leaves out a value it was not given" do + expect(described_class.attributes_for(:method => "GET")).to eq( + "http.request.method" => "GET" + ) + end + + it "leaves out an empty value" do + expect( + described_class.attributes_for( + :method => "GET", :path => "", :scheme => "", :query => "" + ) + ).to eq("http.request.method" => "GET") + end + + it "returns no attributes when there is nothing to describe" do + expect(described_class.attributes_for(:method => nil)).to eq({}) + end + end +end diff --git a/spec/lib/appsignal/rack/abstract_middleware_spec.rb b/spec/lib/appsignal/rack/abstract_middleware_spec.rb index 76a63858f..c6e3ae61c 100644 --- a/spec/lib/appsignal/rack/abstract_middleware_spec.rb +++ b/spec/lib/appsignal/rack/abstract_middleware_spec.rb @@ -113,6 +113,32 @@ def perform end end + context "with a nested instrumentation middleware" do + let(:options) { { :instrument_event_name => "outer_event.category" } } + let(:app) do + described_class.new( + DummyApp.new, + :instrument_event_name => "inner_event.category" + ) + end + + # Only the middleware that created the transaction describes the + # response. The nested one finishes while the outer one's event is + # still open, so it would describe that event rather than the + # transaction. + it "describes the response on the transaction span only", :collector_mode do + start_collector_agent + make_request + + expect(root_span.attributes["http.response.status_code"]).to eq(200) + expect(event_spans.map(&:name)) + .to include("outer_event.category", "inner_event.category") + event_spans.each do |span| + expect(span.attributes).to_not have_key("http.response.status_code") + end + end + end + context "without :instrument_event_name option set" do let(:options) { {} } diff --git a/spec/lib/appsignal/rack/event_handler_spec.rb b/spec/lib/appsignal/rack/event_handler_spec.rb index 0b6984ce0..3eaadacf2 100644 --- a/spec/lib/appsignal/rack/event_handler_spec.rb +++ b/spec/lib/appsignal/rack/event_handler_spec.rb @@ -132,6 +132,44 @@ def perform end end + describe "marking the transaction as an incoming HTTP request" do + # The `process_request.rack` event opens together with the transaction and + # stays open until the response finishes. An attribute set anywhere later + # in the request would land on that event span instead of the transaction + # span, which is why these are set before the event starts. + it "sets the request attributes on the transaction span only", :collector_mode do + start_collector_agent + on_start + Appsignal::Transaction.complete_current! + + expect(root_span.attributes["http.request.method"]).to eq("POST") + expect(root_span.attributes["url.path"]).to eq("/path") + # Sent whole and unfiltered. The collector filters it with the + # `filter_request_query_parameters` option. + expect(root_span.attributes["url.query"]) + .to eq("query_param1=value1&query_param2=value2") + # This environment carries no scheme, so there is nothing to report and + # the attribute is left out rather than sent empty. + expect(root_span.attributes).to_not have_key("url.scheme") + expect(event_spans).to_not be_empty + event_spans.each do |span| + expect(span.attributes).to_not have_key("http.request.method") + expect(span.attributes).to_not have_key("url.path") + expect(span.attributes).to_not have_key("url.scheme") + expect(span.attributes).to_not have_key("url.query") + end + end + + it "reads the scheme off the request", :collector_mode do + env["rack.url_scheme"] = "https" + start_collector_agent + on_start + Appsignal::Transaction.complete_current! + + expect(root_span.attributes["url.scheme"]).to eq("https") + end + end + context "when not active" do let(:appsignal_env) { :inactive_env } @@ -790,6 +828,19 @@ def perform end end + it "does not describe a response that was never sent", :collector_mode do + start_collector_agent + use_test_logger + on_start + on_error(ExampleStandardError.new("the error")) + on_finish(request, nil) + + # The 500 stands in for a status the app never sent, so it is reported + # as a tag but not as the attribute the conventions define. + expect(root_span.attributes["appsignal.tag.response_status"]).to eq(500) + expect(root_span.attributes).to_not have_key("http.response.status_code") + end + describe "increments the response status counter for response status 500" do def perform on_start @@ -935,6 +986,33 @@ def perform end context "with response" do + describe "describing the response" do + # The `process_request.rack` event is finished at the start of + # `on_finish`, which leaves the transaction's own span as the one + # attributes go on. + it "sets the response status on the transaction span only", :collector_mode do + start_collector_agent + use_test_logger + on_start + on_finish + + expect(root_span.attributes["http.response.status_code"]).to eq(200) + expect(event_spans).to_not be_empty + event_spans.each do |span| + expect(span.attributes).to_not have_key("http.response.status_code") + end + end + + it "reads the status off the response", :collector_mode do + start_collector_agent + use_test_logger + on_start + on_finish(request, Rack::Events::BufferedResponse.new(404, {}, ["body"])) + + expect(root_span.attributes["http.response.status_code"]).to eq(404) + end + end + describe "sets the response status as a tag" do def perform on_start diff --git a/spec/lib/appsignal/rack/instrumentation_middleware_spec.rb b/spec/lib/appsignal/rack/instrumentation_middleware_spec.rb index a3b182831..33c31e2ce 100644 --- a/spec/lib/appsignal/rack/instrumentation_middleware_spec.rb +++ b/spec/lib/appsignal/rack/instrumentation_middleware_spec.rb @@ -33,6 +33,89 @@ def perform expect(scope_of(span)).to eq(["appsignal-ruby/rack", Appsignal::VERSION]) end end + + describe "marking the transaction as an incoming HTTP request" do + # These describe the request as a whole, so they belong on the + # transaction's span and on none of the events recorded within it. + it "sets the request attributes on the transaction span only", :collector_mode do + start_collector_agent + make_request(env) + + expect(root_span.attributes["http.request.method"]).to eq("GET") + expect(root_span.attributes["url.path"]).to eq("/some/path") + expect(root_span.attributes["url.scheme"]).to eq("http") + # This request has no query string, so there is nothing to report and + # the attribute is left out rather than sent empty. + expect(root_span.attributes).to_not have_key("url.query") + expect(event_spans).to_not be_empty + event_spans.each do |span| + expect(span.attributes).to_not have_key("http.request.method") + expect(span.attributes).to_not have_key("url.path") + expect(span.attributes).to_not have_key("url.scheme") + expect(span.attributes).to_not have_key("url.query") + end + end + + it "reads the method off the request", :collector_mode do + start_collector_agent + make_request(Rack::MockRequest.env_for("/some/path", :method => "POST")) + + expect(root_span.attributes["http.request.method"]).to eq("POST") + end + + it "reads the path, scheme and query string off the request", :collector_mode do + start_collector_agent + make_request(Rack::MockRequest.env_for("https://example.com/other/path?query=value")) + + # The path is the path on its own. The query string is a separate + # attribute, so it must not end up in the path. + expect(root_span.attributes["url.path"]).to eq("/other/path") + expect(root_span.attributes["url.scheme"]).to eq("https") + # Sent whole and unfiltered. The collector filters it with the + # `filter_request_query_parameters` option. + expect(root_span.attributes["url.query"]).to eq("query=value") + end + end + + describe "describing the response" do + # The status is only known after the app has been called, by which time the + # instrumented event has closed. That is what makes the transaction's own + # span the one this lands on. + it "sets the response status on the transaction span only", :collector_mode do + start_collector_agent + make_request(env) + + expect(root_span.attributes["http.response.status_code"]).to eq(200) + expect(event_spans).to_not be_empty + event_spans.each do |span| + expect(span.attributes).to_not have_key("http.response.status_code") + end + end + + context "when the app responds with an error status" do + let(:app) { DummyApp.new { |_env| [404, {}, ["Not found"]] } } + + it "reads the status off the response", :collector_mode do + start_collector_agent + make_request(env) + + expect(root_span.attributes["http.response.status_code"]).to eq(404) + end + end + + context "when the app raises" do + let(:app) { DummyApp.new { |_env| raise ExampleException, "error" } } + + # The conventions ask for the status only when a response was sent, and + # a request that raised never sent one. + it "sets no response status", :collector_mode do + start_collector_agent + expect { make_request(env) }.to raise_error(ExampleException) + + expect(root_span.attributes).to_not have_key("http.response.status_code") + end + end + end end context "with custom action name" do From 6c4d5431fa9a5ea05833ba8fd85f590b4db7e362 Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Mon, 3 Aug 2026 14:13:32 +0200 Subject: [PATCH 44/69] Describe database queries The trace timeline recognises a datastore call by its `db.system.name` attribute, and splits caches out from databases by the value. Only SQL query spans carried it, so a Redis, MongoDB or Elasticsearch query was shown under "Other". They now carry the values the conventions define and OpenTelemetry's own instrumentation for these libraries uses: `redis`, `mongodb` and `elasticsearch`. Each datastore's own page raises some of the rest to a requirement, so a Redis span also carries the command and the database, a MongoDB span the command and the collection, and an Elasticsearch span the operation and the index. An Elasticsearch search is an outgoing call to a cluster, so it carries CLIENT kind rather than the default. Its index has to be read out of the search itself, so an attribute can now be derived from an event's payload rather than only mapped from its name. A SQL span is named as a database span even when the event has no query to record, where before it was named only when it had one. --- .../active_support_notifications.rb | 57 +++++++++- .../integrations/mongo_ruby_driver.rb | 51 +++++++++ lib/appsignal/integrations/redis.rb | 15 +++ lib/appsignal/integrations/redis_client.rb | 15 +++ .../transaction/opentelemetry_backend.rb | 9 +- .../instrument_shared_examples.rb | 95 ++++++++++++++++ .../start_finish_shared_examples.rb | 7 +- spec/lib/appsignal/hooks/redis_client_spec.rb | 20 ++++ spec/lib/appsignal/hooks/redis_spec.rb | 16 +++ .../integrations/mongo_ruby_driver_spec.rb | 105 +++++++++++++++++- .../transaction/opentelemetry_backend_spec.rb | 16 +++ 11 files changed, 395 insertions(+), 11 deletions(-) diff --git a/lib/appsignal/integrations/active_support_notifications.rb b/lib/appsignal/integrations/active_support_notifications.rb index b6892bceb..b4f037996 100644 --- a/lib/appsignal/integrations/active_support_notifications.rb +++ b/lib/appsignal/integrations/active_support_notifications.rb @@ -19,7 +19,30 @@ class << self # the dedicated Sequel hook (which already tags its own query events as # CLIENT). Including it keeps a Sequel query CLIENT regardless of which # path records it. - CLIENT_EVENT_NAMES = ["sql.active_record", "sql.sequel"].freeze + # + # `search.elasticsearch` is a query sent to an Elasticsearch cluster, so + # it is a client call for the same reason a SQL query is. + CLIENT_EVENT_NAMES = [ + "sql.active_record", + "sql.sequel", + "search.elasticsearch" + ].freeze + + # OpenTelemetry attributes to add to an event's span, by event name. + # These name the kind of work an event represents, which is what the + # trace timeline reads to tell one kind of span from another. + # + # SQL events are not listed here: their span already gets + # `db.system.name` from the SQL body format, which also tells the + # collector to sanitize the query. + EVENT_ATTRIBUTES = { + "search.elasticsearch" => { + "db.system.name" => "elasticsearch", + # This notification is only emitted for a search, so that is the + # operation every one of these spans describes. + "db.operation.name" => "search" + }.freeze + }.freeze # Events a dedicated AppSignal integration already records with richer # semantics, so the generic notifications path must not record them a @@ -61,7 +84,13 @@ def finish_event(name, payload = {}) return unless record_event?(name) title, body, body_format = Appsignal::EventFormatter.format(name, payload) - Appsignal::Transaction.current.finish_event( + transaction = Appsignal::Transaction.current + # Set while the event's span is still open, so the attributes land on + # the event rather than on the transaction. + attributes = EVENT_ATTRIBUTES[name.to_s] + transaction.add_opentelemetry_attributes(attributes) if attributes + transaction.add_opentelemetry_attributes(payload_attributes(name, payload)) + transaction.finish_event( name.to_s, title, body, @@ -69,6 +98,30 @@ def finish_event(name, payload = {}) ) end + # Attributes whose value has to be read from the event's payload, so they + # cannot live in the static map above. An event with nothing to read gets + # no attributes. + def payload_attributes(name, payload) + case name.to_s + when "search.elasticsearch" + { "db.collection.name" => search_index(payload) }.compact + else + {} + end + end + + # The index a search ran against, which the notification carries in the + # search it describes. A search that names more than one index, or none + # at all, is left without this attribute rather than described with a + # value that is not an index name. + def search_index(payload) + search = payload[:search] + return unless search.respond_to?(:[]) + + index = search[:index] + index if index.is_a?(String) + end + # Events starting with a bang are internal to Rails; suppressed events # are recorded by a dedicated integration instead. Both `start_event` # and `finish_event` gate on this so the event stack stays balanced. diff --git a/lib/appsignal/integrations/mongo_ruby_driver.rb b/lib/appsignal/integrations/mongo_ruby_driver.rb index d944747d6..2911a4672 100644 --- a/lib/appsignal/integrations/mongo_ruby_driver.rb +++ b/lib/appsignal/integrations/mongo_ruby_driver.rb @@ -26,6 +26,20 @@ def started(event) :opentelemetry_kind => :client, :opentelemetry_scope => ["appsignal-ruby/mongo", Appsignal::VERSION] ) + # Names the datastore this span talks to, which is what the trace + # timeline reads to recognize a database call, along with the command + # that ran, what it ran against, and the server it went to. Set here, + # where the event span it describes is the open one. + transaction.add_opentelemetry_attributes( + { + "db.system.name" => "mongodb", + "db.operation.name" => event.command_name, + "db.collection.name" => collection_name(event), + "db.namespace" => event.database_name, + "server.address" => event.address&.host, + "server.port" => event.address&.port + }.compact + ) end # Called by Mongo::Monitor when query succeeds @@ -51,6 +65,20 @@ def finish(result, event) store = transaction.store("mongo_driver") command = store.delete(event.request_id) || {} + # MongoDB's own code for the error, which the conventions ask for + # whenever the database reported one. The driver reports a failure by + # calling us rather than by raising, so this is the only place it can be + # read. Set before the event is finished, so it lands on the query's own + # span. + # + # Only a failure event carries a failure, which is what tells the two + # apart here. + if event.respond_to?(:failure) + transaction.add_opentelemetry_attributes( + { "db.response.status_code" => error_code(event.failure) }.compact + ) + end + # Finish the event. The sanitized command is a (nested) Hash; emit it # as a JSON string so it works with both transaction backends. The # agent serializes structured bodies to JSON anyway, so this is @@ -69,6 +97,29 @@ def finish(result, event) :database => event.database_name ) end + + private + + # The collection a command worked on. MongoDB puts it in the field named + # after the command itself, as in `{ "find" => "users" }`. A command that + # works on the database as a whole rather than on one collection has + # something else in that field, such as the number 1, so only a String + # counts as a collection name. + def collection_name(event) + return unless event.command.respond_to?(:[]) + + collection = event.command[event.command_name] + collection if collection.is_a?(String) + end + + # MongoDB's own code for an error, which it puts in the `code` field of the + # error document it replies with. Reported as a String, which is what the + # conventions ask for. + def error_code(failure) + return unless failure.respond_to?(:[]) + + failure["code"]&.to_s + end end end end diff --git a/lib/appsignal/integrations/redis.rb b/lib/appsignal/integrations/redis.rb index def2a74c9..159aa17a2 100644 --- a/lib/appsignal/integrations/redis.rb +++ b/lib/appsignal/integrations/redis.rb @@ -11,6 +11,7 @@ def write(command) else "#{command[0]}#{" ?" * (command.size - 1)}" end + operation_name = command[0].to_s Appsignal.instrument( "query.redis", @@ -19,6 +20,20 @@ def write(command) :opentelemetry_kind => :client, :opentelemetry_scope => ["appsignal-ruby/redis", Appsignal::VERSION] ) do + # Names the datastore this span talks to, which is what the trace + # timeline reads to recognize a cache call, along with the command and + # the database it ran against. + # + # The command stays in the event body rather than moving to + # `db.query.text`: it is sanitized here already, and the collector + # sanitizes `db.query.text` again for Redis, which would mangle it. + Appsignal::Transaction.current.add_opentelemetry_attributes( + { + "db.system.name" => "redis", + "db.operation.name" => (operation_name unless operation_name.empty?), + "db.namespace" => (db.to_s if respond_to?(:db) && db) + }.compact + ) super end end diff --git a/lib/appsignal/integrations/redis_client.rb b/lib/appsignal/integrations/redis_client.rb index 1cb4cff22..45ed4c06e 100644 --- a/lib/appsignal/integrations/redis_client.rb +++ b/lib/appsignal/integrations/redis_client.rb @@ -11,6 +11,7 @@ def write(command) else "#{command[0]}#{" ?" * (command.size - 1)}" end + operation_name = command[0].to_s Appsignal.instrument( "query.redis", @@ -19,6 +20,20 @@ def write(command) :opentelemetry_kind => :client, :opentelemetry_scope => ["appsignal-ruby/redis_client", Appsignal::VERSION] ) do + # Names the datastore this span talks to, which is what the trace + # timeline reads to recognize a cache call, along with the command and + # the database it ran against. + # + # The command stays in the event body rather than moving to + # `db.query.text`: it is sanitized here already, and the collector + # sanitizes `db.query.text` again for Redis, which would mangle it. + Appsignal::Transaction.current.add_opentelemetry_attributes( + { + "db.system.name" => "redis", + "db.operation.name" => (operation_name unless operation_name.empty?), + "db.namespace" => (@config.db.to_s if @config.respond_to?(:db) && @config.db) + }.compact + ) super end end diff --git a/lib/appsignal/transaction/opentelemetry_backend.rb b/lib/appsignal/transaction/opentelemetry_backend.rb index 81c1c37bb..b921a8050 100644 --- a/lib/appsignal/transaction/opentelemetry_backend.rb +++ b/lib/appsignal/transaction/opentelemetry_backend.rb @@ -736,12 +736,15 @@ def write_event_span_name(span, name, title) end def write_event_body_attributes(span, body, body_format) - return if body.nil? || body.empty? + has_body = !body.to_s.empty? if body_format == Appsignal::EventFormatter::SQL_BODY_FORMAT - span.set_attribute("db.query.text", body) + # Name the datastore whether or not there is a query to record with it. + # The semantic conventions require the attribute on every database + # span, and a SQL event with nothing in its body is still a SQL event. span.set_attribute("db.system.name", SQL_DB_SYSTEM) - else + span.set_attribute("db.query.text", body) if has_body + elsif has_body span.set_attribute("appsignal.body", body) 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 446f56269..77919a295 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 @@ -92,6 +92,101 @@ def perform end end + describe "an Elasticsearch search event" do + def perform + as.instrument( + "search.elasticsearch", + :name => "Search", + :klass => "User", + :search => { :index => "users" } + ) { "value" } + end + + it "in agent mode", :agent_mode do + start_agent + transaction = http_request_transaction + set_current_transaction(transaction) + as.notifier = notifier + + expect(perform).to eq "value" + expect(transaction).to include_event( + "name" => "search.elasticsearch", + "title" => "Search: User", + # The formatter inspects the sanitized search, and Hash#inspect changed + # format in Ruby 3.4, so match on the content rather than the layout. + "body" => a_string_including("users") + ) + end + + it "in collector mode", :collector_mode do + start_collector_agent + transaction = http_request_transaction + set_current_transaction(transaction) + as.notifier = notifier + + expect(perform).to eq "value" + Appsignal::Transaction.complete_current! + + span = event_span_for("search.elasticsearch") + expect(span).not_to be_nil + expect(span.parent_span_id).to eq(root_span.span_id) + # A search is an outgoing call to the cluster, so it carries CLIENT kind. + expect(span.kind).to eq(:client) + # There is no SQL body to infer the datastore from, so it is named + # explicitly. Without it the trace timeline cannot tell this span apart + # from any other kind of work. + expect(span.attributes["db.system.name"]).to eq("elasticsearch") + # This notification is only emitted for a search, so that is the + # operation. The index comes off the search itself. + expect(span.attributes["db.operation.name"]).to eq("search") + expect(span.attributes["db.collection.name"]).to eq("users") + expect(event_category(span)).to eq("search.elasticsearch") + expect(scope_of(span)).to eq(["appsignal-ruby/elasticsearch", Appsignal::VERSION]) + end + end + + describe "an Elasticsearch search event across more than one index" do + def perform + as.instrument( + "search.elasticsearch", + :name => "Search", + :klass => "User", + :search => { :index => ["users", "admins"] } + ) { "value" } + end + + it "in agent mode", :agent_mode do + start_agent + transaction = http_request_transaction + set_current_transaction(transaction) + as.notifier = notifier + + expect(perform).to eq "value" + expect(transaction).to include_event( + "name" => "search.elasticsearch", + "title" => "Search: User", + "body" => a_string_including("users") + ) + end + + it "in collector mode", :collector_mode do + start_collector_agent + transaction = http_request_transaction + set_current_transaction(transaction) + as.notifier = notifier + + expect(perform).to eq "value" + Appsignal::Transaction.complete_current! + + span = event_span_for("search.elasticsearch") + expect(span).not_to be_nil + expect(span.attributes["db.operation.name"]).to eq("search") + # The attribute names one index, so a search across several is left + # without it rather than described with a value that is not an index name. + expect(span.attributes).to_not have_key("db.collection.name") + end + end + describe "an event with no registered formatter" do def perform as.instrument("no-registered.formatter", :key => "something") { "value" } 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 efad02d38..231b7fd58 100644 --- a/spec/lib/appsignal/hooks/active_support_notifications/start_finish_shared_examples.rb +++ b/spec/lib/appsignal/hooks/active_support_notifications/start_finish_shared_examples.rb @@ -39,10 +39,11 @@ def perform 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. + # The formatter received an empty finish payload, so there is no query to + # record. The datastore is named anyway, because this is a database query + # either way. expect(span.attributes).not_to have_key("db.query.text") - expect(span.attributes).not_to have_key("db.system.name") + expect(span.attributes["db.system.name"]).to eq("other_sql") end end diff --git a/spec/lib/appsignal/hooks/redis_client_spec.rb b/spec/lib/appsignal/hooks/redis_client_spec.rb index 7f336fbad..d8ed1b8d5 100644 --- a/spec/lib/appsignal/hooks/redis_client_spec.rb +++ b/spec/lib/appsignal/hooks/redis_client_spec.rb @@ -112,6 +112,11 @@ def perform expect(span.attributes["appsignal.body"]).to eq("get ?") expect(event_category(span)).to eq("query.redis") expect(scope_of(span)).to eq(["appsignal-ruby/redis_client", Appsignal::VERSION]) + expect(span.attributes["db.system.name"]).to eq("redis") + # The command name, in the case the application wrote it. + expect(span.attributes["db.operation.name"]).to eq("get") + # The index of the database the connection is on, as a String. + expect(span.attributes["db.namespace"]).to eq("0") expect(span.attributes).not_to have_key("db.query.text") end end @@ -153,6 +158,11 @@ def perform expect(span.parent_span_id).to eq(root_span.span_id) expect(span.attributes["appsignal.body"]).to eq("#{script} ? ?") expect(event_category(span)).to eq("query.redis") + expect(span.attributes["db.system.name"]).to eq("redis") + # A script is run with the EVAL command, so that is the operation. + # The script itself stays in the event body. + expect(span.attributes["db.operation.name"]).to eq("eval") + expect(span.attributes["db.namespace"]).to eq("0") expect(span.attributes).not_to have_key("db.query.text") end end @@ -244,6 +254,11 @@ def perform expect(span.parent_span_id).to eq(root_span.span_id) expect(span.attributes["appsignal.body"]).to eq("get ?") expect(event_category(span)).to eq("query.redis") + expect(span.attributes["db.system.name"]).to eq("redis") + # The command name, in the case the application wrote it. + expect(span.attributes["db.operation.name"]).to eq("get") + # The index of the database the connection is on, as a String. + expect(span.attributes["db.namespace"]).to eq("0") expect(span.attributes).not_to have_key("db.query.text") end end @@ -284,6 +299,11 @@ def perform expect(span.parent_span_id).to eq(root_span.span_id) expect(span.attributes["appsignal.body"]).to eq("#{script} ? ?") expect(event_category(span)).to eq("query.redis") + expect(span.attributes["db.system.name"]).to eq("redis") + # A script is run with the EVAL command, so that is the operation. + # The script itself stays in the event body. + expect(span.attributes["db.operation.name"]).to eq("eval") + expect(span.attributes["db.namespace"]).to eq("0") expect(span.attributes).not_to have_key("db.query.text") end end diff --git a/spec/lib/appsignal/hooks/redis_spec.rb b/spec/lib/appsignal/hooks/redis_spec.rb index 9368dd543..795e5ae2f 100644 --- a/spec/lib/appsignal/hooks/redis_spec.rb +++ b/spec/lib/appsignal/hooks/redis_spec.rb @@ -63,6 +63,12 @@ def id "stub_id" end + # The index of the database the connection is on, which the + # real client exposes the same way. + def db + 3 + end + def write(_commands) "stub_write" end @@ -106,6 +112,11 @@ def perform expect(span.attributes["appsignal.body"]).to eq("get ?") expect(event_category(span)).to eq("query.redis") expect(scope_of(span)).to eq(["appsignal-ruby/redis", Appsignal::VERSION]) + expect(span.attributes["db.system.name"]).to eq("redis") + # The command name, in the case the application wrote it. + expect(span.attributes["db.operation.name"]).to eq("get") + # The index of the database the connection is on, as a String. + expect(span.attributes["db.namespace"]).to eq("3") expect(span.attributes).not_to have_key("db.query.text") end end @@ -146,6 +157,11 @@ def perform expect(span.parent_span_id).to eq(root_span.span_id) expect(span.attributes["appsignal.body"]).to eq("#{script} ? ?") expect(event_category(span)).to eq("query.redis") + expect(span.attributes["db.system.name"]).to eq("redis") + # A script is run with the EVAL command, so that is the + # operation. The script itself stays in the event body. + expect(span.attributes["db.operation.name"]).to eq("eval") + expect(span.attributes["db.namespace"]).to eq("3") expect(span.attributes).not_to have_key("db.query.text") end end diff --git a/spec/lib/appsignal/integrations/mongo_ruby_driver_spec.rb b/spec/lib/appsignal/integrations/mongo_ruby_driver_spec.rb index 6d8ca168a..330f57c6b 100644 --- a/spec/lib/appsignal/integrations/mongo_ruby_driver_spec.rb +++ b/spec/lib/appsignal/integrations/mongo_ruby_driver_spec.rb @@ -26,12 +26,15 @@ def command_succeeded_event( ) end - def command_failed_event( + # `failure` is the error document MongoDB replied with, which carries its + # own code for the error in the `code` field. + def command_failed_event( # rubocop:disable Metrics/ParameterLists started_event, request_id: 1, command_name: "find", - database_name: "test", duration: 0.9919 + database_name: "test", duration: 0.9919, + failure: { "code" => 26, "codeName" => "NamespaceNotFound" } ) Mongo::Monitoring::Event::CommandFailed.new( - command_name, database_name, address, request_id, 1, "message", {}, duration, + command_name, database_name, address, request_id, 1, "message", failure, duration, :started_event => started_event ) end @@ -92,7 +95,15 @@ def perform expect(span.parent_span_id).to eq(root_span.span_id) expect(event_category(span)).to eq("query.mongodb") expect(scope_of(span)).to eq(["appsignal-ruby/mongo", Appsignal::VERSION]) + expect(span.attributes["db.system.name"]).to eq("mongodb") + expect(span.attributes["db.operation.name"]).to eq("find") + expect(span.attributes["db.namespace"]).to eq("test") + expect(span.attributes["server.address"]).to eq("127.0.0.1") + expect(span.attributes["server.port"]).to eq(27_017) + # This command names no collection, so none is reported. + expect(span.attributes).not_to have_key("db.collection.name") expect(span.attributes["appsignal.body"]).to eq("{\"foo\":\"?\"}") + expect(span.attributes).not_to have_key("db.response.status_code") snapshot = metric_snapshot("mongodb_query_duration") expect(snapshot).not_to be_nil @@ -101,6 +112,50 @@ def perform end end + describe "instrumenting a query on a collection" do + let(:started_event) do + command_started_event( + :request_id => 2, + :command => { "find" => "users", "filter" => { "foo" => "bar" } } + ) + end + let(:succeeded_event) { command_succeeded_event(started_event, :request_id => 2) } + + def perform + subscriber.started(started_event) + subscriber.succeeded(succeeded_event) + end + + it "in agent mode", :agent_mode do + start_agent + transaction = http_request_transaction + set_current_transaction(transaction) + + perform + + expect(transaction).to include_event( + "name" => "query.mongodb", + "title" => "find | test | SUCCEEDED", + "body" => "{\"find\":\"users\",\"filter\":{\"foo\":\"?\"}}" + ) + end + + it "in collector mode", :collector_mode do + start_collector_agent + transaction = http_request_transaction + set_current_transaction(transaction) + perform + Appsignal::Transaction.complete_current! + + span = event_span_for("query.mongodb") + expect(span).not_to be_nil + # MongoDB names the collection in the field named after the command, so + # a query on one reports it. + expect(span.attributes["db.collection.name"]).to eq("users") + expect(span.attributes["db.operation.name"]).to eq("find") + end + end + describe "instrumenting a failed query" do let(:started_event) { command_started_event(:request_id => 2) } let(:failed_event) { command_failed_event(started_event, :request_id => 2) } @@ -137,7 +192,51 @@ def perform expect(span.kind).to eq(:client) expect(event_category(span)).to eq("query.mongodb") expect(scope_of(span)).to eq(["appsignal-ruby/mongo", Appsignal::VERSION]) + expect(span.attributes["db.system.name"]).to eq("mongodb") expect(span.attributes["appsignal.body"]).to eq("{\"foo\":\"?\"}") + # The query failed, and MongoDB reported its own code for the error in + # the document it replied with. + expect(span.attributes["db.response.status_code"]).to eq("26") + end + end + + describe "instrumenting a query that failed without an error code" do + let(:started_event) { command_started_event(:request_id => 2) } + let(:failed_event) do + command_failed_event(started_event, :request_id => 2, :failure => {}) + end + + def perform + subscriber.started(started_event) + subscriber.failed(failed_event) + end + + it "in agent mode", :agent_mode do + start_agent + transaction = http_request_transaction + set_current_transaction(transaction) + + perform + + expect(transaction).to include_event( + "name" => "query.mongodb", + "title" => "find | test | FAILED", + "body" => "{\"foo\":\"?\"}" + ) + end + + it "in collector mode", :collector_mode do + start_collector_agent + transaction = http_request_transaction + set_current_transaction(transaction) + perform + Appsignal::Transaction.complete_current! + + span = event_span_for("query.mongodb") + expect(span).not_to be_nil + # A dropped connection is a failure MongoDB never replied to, so there + # is no code to report. + expect(span.attributes).to_not have_key("db.response.status_code") end end diff --git a/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb b/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb index c2b508a93..63b4bec9d 100644 --- a/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb +++ b/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb @@ -1513,6 +1513,22 @@ def new_transaction_with_otel_backend(namespace = Appsignal::Transaction::HTTP_R expect(empty_body.attributes).not_to have_key("db.query.text") end + # The semantic conventions require the datastore on every database span, + # and an event with a SQL body format is a database span whether or not + # there is a query to record with it. + it "names the datastore for a SQL body format without a query" do + backend = create_backend + backend.start_event + backend.finish_event("sql.no_query", "T", nil, + Appsignal::EventFormatter::SQL_BODY_FORMAT) + + attrs = span_exporter.finished_spans + .find { |s| s.name == "sql.no_query (T)" }.attributes + expect(attrs["db.system.name"]).to eq("other_sql") + expect(attrs).not_to have_key("db.query.text") + expect(attrs).not_to have_key("appsignal.body") + end + it "falls back to the event name as the span name when title is empty or nil" do backend = create_backend backend.start_event From fb4f0396996116da384a687a85abe2ce57108c0f Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Mon, 3 Aug 2026 14:19:30 +0200 Subject: [PATCH 45/69] Describe background job spans The trace timeline recognises background job work by the `messaging.system` attribute, and uses the span kind to tell an enqueue apart from the job it enqueued. The job spans carried the kind but not the attribute, so both were shown under "Other". Each library takes its own name, which is what OpenTelemetry's instrumentation for it uses. Shoryuken is the exception, because it runs on Amazon SQS and so takes `aws_sqs`. The conventions also require the operation's name and kind, and ask for the destination, which for a job library is the queue. Those three travel together, so one place builds them and every integration passes its own messaging system. Not every job has a queue to name. Que records the queue only on the job itself, and Delayed Job has backends without queues, so a span with none is described without one. Shoryuken is the only integration that can report a batch size, because its server middleware is handed the whole array. Active Job describes itself only when no adapter integration already has, because the adapter's answer is more specific. --- lib/appsignal/hooks/active_job.rb | 19 +++++ .../active_support_notifications.rb | 13 ++- .../integrations/delayed_job_plugin.rb | 22 +++++ lib/appsignal/integrations/que.rb | 19 ++++- lib/appsignal/integrations/resque.rb | 15 ++++ lib/appsignal/integrations/shoryuken.rb | 36 ++++++-- lib/appsignal/integrations/sidekiq.rb | 26 +++++- lib/appsignal/opentelemetry.rb | 1 + lib/appsignal/opentelemetry/messaging.rb | 82 +++++++++++++++++++ spec/lib/appsignal/hooks/activejob_spec.rb | 8 ++ .../integrations/delayed_job_plugin_spec.rb | 20 ++++- spec/lib/appsignal/integrations/que_spec.rb | 21 +++++ .../lib/appsignal/integrations/resque_spec.rb | 12 +++ .../integrations/shoryuken_client_spec.rb | 4 + .../appsignal/integrations/shoryuken_spec.rb | 19 +++++ .../appsignal/integrations/sidekiq_spec.rb | 18 +++- .../appsignal/opentelemetry/messaging_spec.rb | 71 ++++++++++++++++ 17 files changed, 391 insertions(+), 15 deletions(-) create mode 100644 lib/appsignal/opentelemetry/messaging.rb create mode 100644 spec/lib/appsignal/opentelemetry/messaging_spec.rb diff --git a/lib/appsignal/hooks/active_job.rb b/lib/appsignal/hooks/active_job.rb index f32109879..b909b1746 100644 --- a/lib/appsignal/hooks/active_job.rb +++ b/lib/appsignal/hooks/active_job.rb @@ -84,6 +84,21 @@ def execute(job) ) end + unless has_wrapper_transaction + # Describes this span as a job being performed. The messaging + # system is what the trace timeline reads to recognize background + # job work, and `active_job` is the value OpenTelemetry's own Active + # Job instrumentation uses. + # + # Only set when this hook created the transaction. When an adapter + # integration created it, that adapter already named itself, and its + # answer is the more specific one. + transaction.add_opentelemetry_attributes( + Appsignal::OpenTelemetry::Messaging + .perform_attributes("active_job", :destination => job["queue_name"]) + ) + end + begin transaction.add_function_parameters_if_nil(job["arguments"]) @@ -179,6 +194,10 @@ def enqueue(*, **) :opentelemetry_kind => :producer, :opentelemetry_scope => ["appsignal-ruby/active_job", Appsignal::VERSION] ) do + Appsignal::Transaction.current.add_opentelemetry_attributes( + Appsignal::OpenTelemetry::Messaging + .enqueue_attributes("active_job", :destination => queue_name) + ) Appsignal::OpenTelemetry.inject_context(__otel_headers) # Active Job enqueues through an adapter (Sidekiq, Resque, ...) that # has its own enqueue instrumentation. Suppress it so the enqueue is diff --git a/lib/appsignal/integrations/active_support_notifications.rb b/lib/appsignal/integrations/active_support_notifications.rb index b4f037996..8d447f916 100644 --- a/lib/appsignal/integrations/active_support_notifications.rb +++ b/lib/appsignal/integrations/active_support_notifications.rb @@ -41,7 +41,9 @@ class << self # This notification is only emitted for a search, so that is the # operation every one of these spans describes. "db.operation.name" => "search" - }.freeze + }.freeze, + "perform.active_job" => + Appsignal::OpenTelemetry::Messaging.perform_attributes("active_job").freeze }.freeze # Events a dedicated AppSignal integration already records with richer @@ -105,6 +107,8 @@ def payload_attributes(name, payload) case name.to_s when "search.elasticsearch" { "db.collection.name" => search_index(payload) }.compact + when "perform.active_job" + { "messaging.destination.name" => job_queue_name(payload) }.compact else {} end @@ -122,6 +126,13 @@ def search_index(payload) index if index.is_a?(String) end + # The queue the job being performed is on, which the notification carries + # as the job itself. + def job_queue_name(payload) + job = payload[:job] + job.queue_name if job.respond_to?(:queue_name) + end + # Events starting with a bang are internal to Rails; suppressed events # are recorded by a dedicated integration instead. Both `start_event` # and `finish_event` gate on this so the event stack stays balanced. diff --git a/lib/appsignal/integrations/delayed_job_plugin.rb b/lib/appsignal/integrations/delayed_job_plugin.rb index 91a9be38d..139ff1c4f 100644 --- a/lib/appsignal/integrations/delayed_job_plugin.rb +++ b/lib/appsignal/integrations/delayed_job_plugin.rb @@ -43,6 +43,14 @@ def self.enqueue_with_instrumentation(job, block) :opentelemetry_kind => :producer, :opentelemetry_scope => ["appsignal-ruby/delayed_job", Appsignal::VERSION] ) do + # Describes this span as a job being enqueued. The messaging system + # is what the trace timeline reads to recognize background job work, + # and `delayed_job` is the value OpenTelemetry's own Delayed Job + # instrumentation uses. + Appsignal::Transaction.current.add_opentelemetry_attributes( + Appsignal::OpenTelemetry::Messaging + .enqueue_attributes("delayed_job", :destination => queue_name(job)) + ) block.call(job) end end @@ -53,6 +61,12 @@ def self.enqueue_with_instrumentation(job, block) # `enqueue Class#method job` rather than the bare `enqueue Class job`. We # accept that inconsistency so the enqueue and perform events stay tied to # the same name for the rare job that sets it. + # The queue a job is on. Not every Delayed Job backend has queues, so a + # job that does not know its queue is described without one. + def self.queue_name(job) + job.queue if job.respond_to?(:queue) + end + def self.enqueue_name(job) payload = job.payload_object appsignal_name = extract_value(payload, :appsignal_name, nil) @@ -69,12 +83,20 @@ def self.invoke_with_instrumentation(job, block) :opentelemetry_kind => :consumer, :opentelemetry_relationship => :both ) + transaction.add_opentelemetry_attributes( + Appsignal::OpenTelemetry::Messaging + .perform_attributes("delayed_job", :destination => queue_name(job)) + ) begin Appsignal.instrument( "perform_job.delayed_job", :opentelemetry_scope => ["appsignal-ruby/delayed_job", Appsignal::VERSION] ) do + Appsignal::Transaction.current.add_opentelemetry_attributes( + Appsignal::OpenTelemetry::Messaging + .perform_attributes("delayed_job", :destination => queue_name(job)) + ) block.call(job) end rescue Exception => error diff --git a/lib/appsignal/integrations/que.rb b/lib/appsignal/integrations/que.rb index e80555b6c..983d6a10d 100644 --- a/lib/appsignal/integrations/que.rb +++ b/lib/appsignal/integrations/que.rb @@ -123,12 +123,25 @@ def _run(*args) :opentelemetry_kind => :consumer, :opentelemetry_relationship => relationship ) + # Describes this span as a job being performed. The messaging system is + # what the trace timeline reads to recognize background job work, and + # `que` is the value OpenTelemetry's own Que instrumentation uses. + transaction.add_opentelemetry_attributes( + Appsignal::OpenTelemetry::Messaging + .perform_attributes("que", :destination => local_attrs[:queue]) + ) begin Appsignal.instrument( "perform_job.que", :opentelemetry_scope => ["appsignal-ruby/que", Appsignal::VERSION] - ) { super } + ) do + Appsignal::Transaction.current.add_opentelemetry_attributes( + Appsignal::OpenTelemetry::Messaging + .perform_attributes("que", :destination => local_attrs[:queue]) + ) + super + end rescue Exception => error transaction.set_error(error) raise error @@ -226,6 +239,10 @@ def record_enqueue(job_options, event_name, title, bulk: false) :opentelemetry_kind => :producer, :opentelemetry_scope => ["appsignal-ruby/que", Appsignal::VERSION] ) do + Appsignal::Transaction.current.add_opentelemetry_attributes( + Appsignal::OpenTelemetry::Messaging + .enqueue_attributes("que", :destination => job_options[:queue]) + ) yield job_options_with_context(job_options, :bulk => bulk) end end diff --git a/lib/appsignal/integrations/resque.rb b/lib/appsignal/integrations/resque.rb index 7914de425..87ee9d1b1 100644 --- a/lib/appsignal/integrations/resque.rb +++ b/lib/appsignal/integrations/resque.rb @@ -14,11 +14,22 @@ def perform :opentelemetry_kind => :consumer, :opentelemetry_relationship => :both ) + # Describes this span as a job being performed. The messaging system is + # what the trace timeline reads to recognize background job work, and + # `resque` is the value OpenTelemetry's own Resque instrumentation uses. + transaction.add_opentelemetry_attributes( + Appsignal::OpenTelemetry::Messaging + .perform_attributes("resque", :destination => queue) + ) Appsignal.instrument( "perform.resque", :opentelemetry_scope => ["appsignal-ruby/resque", Appsignal::VERSION] ) do + Appsignal::Transaction.current.add_opentelemetry_attributes( + Appsignal::OpenTelemetry::Messaging + .perform_attributes("resque", :destination => queue) + ) super end rescue Exception => exception @@ -70,6 +81,10 @@ def push(queue, item) :opentelemetry_kind => :producer, :opentelemetry_scope => ["appsignal-ruby/resque", Appsignal::VERSION] ) do + Appsignal::Transaction.current.add_opentelemetry_attributes( + Appsignal::OpenTelemetry::Messaging + .enqueue_attributes("resque", :destination => queue) + ) Appsignal::OpenTelemetry.inject_context(item) super end diff --git a/lib/appsignal/integrations/shoryuken.rb b/lib/appsignal/integrations/shoryuken.rb index 3f67423ae..c8d500e09 100644 --- a/lib/appsignal/integrations/shoryuken.rb +++ b/lib/appsignal/integrations/shoryuken.rb @@ -70,6 +70,8 @@ def inject(options) class ShoryukenMiddleware def call(worker_instance, queue, sqs_msg, body, &block) batch = sqs_msg.is_a?(Array) + # How many messages this call covers, which is only reported for a batch. + batch_size = sqs_msg.size if batch # Read the incoming trace context off the message so the transaction # links back to the enqueuer. A batch carries messages from multiple @@ -84,12 +86,27 @@ def call(worker_instance, queue, sqs_msg, body, &block) :opentelemetry_kind => :consumer, :opentelemetry_relationship => :both ) + # Describes this span as a job being performed. The messaging system is + # what the trace timeline reads to recognize background job work. + # Shoryuken runs on Amazon SQS, so it takes the `aws_sqs` value the + # OpenTelemetry semantic conventions define for it. + transaction.add_opentelemetry_attributes( + Appsignal::OpenTelemetry::Messaging.perform_attributes( + "aws_sqs", :destination => queue, :batch_size => batch_size + ) + ) Appsignal.instrument( "perform_job.shoryuken", - :opentelemetry_scope => ["appsignal-ruby/shoryuken", Appsignal::VERSION], - &block - ) + :opentelemetry_scope => ["appsignal-ruby/shoryuken", Appsignal::VERSION] + ) do + Appsignal::Transaction.current.add_opentelemetry_attributes( + Appsignal::OpenTelemetry::Messaging.perform_attributes( + "aws_sqs", :destination => queue, :batch_size => batch_size + ) + ) + block.call + end rescue Exception => error transaction.set_error(error) raise @@ -187,6 +204,10 @@ def call(options) :opentelemetry_kind => :producer, :opentelemetry_scope => ["appsignal-ruby/shoryuken", Appsignal::VERSION] ) do + Appsignal::Transaction.current.add_opentelemetry_attributes( + Appsignal::OpenTelemetry::Messaging + .enqueue_attributes("aws_sqs", :destination => queue_name(options)) + ) ShoryukenTraceContext.inject(options) yield end @@ -201,8 +222,13 @@ def enqueue_title(options) worker_class = options.dig(:message_attributes, "shoryuken_class", :string_value) return "enqueue #{worker_class} job" if worker_class - queue = options[:queue_url].to_s.split("/").last - "enqueue on #{queue}" + "enqueue on #{queue_name(options)}" + end + + # The queue a message is being sent to, which SQS identifies by a URL whose + # last segment is the queue's name. + def queue_name(options) + options[:queue_url].to_s.split("/").last end end end diff --git a/lib/appsignal/integrations/sidekiq.rb b/lib/appsignal/integrations/sidekiq.rb index 9a1a58d93..33925995b 100644 --- a/lib/appsignal/integrations/sidekiq.rb +++ b/lib/appsignal/integrations/sidekiq.rb @@ -44,6 +44,9 @@ def call(exception, sidekiq_context, _sidekiq_config = nil) :opentelemetry_kind => :consumer, :opentelemetry_relationship => :both ) + transaction.add_opentelemetry_attributes( + Appsignal::OpenTelemetry::Messaging.perform_attributes("sidekiq") + ) transaction.set_action_if_nil("SidekiqInternal") transaction.set_metadata("sidekiq_error", sidekiq_context[:context]) transaction.add_function_parameters_if_nil(:jobstr => sidekiq_context[:jobstr]) @@ -138,6 +141,14 @@ def call(_worker_class, job, _queue, _redis_pool) :opentelemetry_kind => :producer, :opentelemetry_scope => ["appsignal-ruby/sidekiq", Appsignal::VERSION] ) do + # Describes this span as a job being enqueued. The messaging system + # is what the trace timeline reads to recognize background job work, + # and `sidekiq` is the value OpenTelemetry's own Sidekiq + # instrumentation uses. + Appsignal::Transaction.current.add_opentelemetry_attributes( + Appsignal::OpenTelemetry::Messaging + .enqueue_attributes("sidekiq", :destination => job["queue"]) + ) Appsignal::OpenTelemetry.inject_context(job) yield end @@ -172,6 +183,10 @@ def call(_worker, item, _queue, &block) :opentelemetry_kind => :consumer, :opentelemetry_relationship => :both ) + transaction.add_opentelemetry_attributes( + Appsignal::OpenTelemetry::Messaging + .perform_attributes("sidekiq", :destination => item["queue"]) + ) transaction.set_action_if_nil(action_name) formatted_metadata(item).each do |key, value| @@ -181,9 +196,14 @@ def call(_worker, item, _queue, &block) begin Appsignal.instrument( "perform_job.sidekiq", - :opentelemetry_scope => ["appsignal-ruby/sidekiq", Appsignal::VERSION], - &block - ) + :opentelemetry_scope => ["appsignal-ruby/sidekiq", Appsignal::VERSION] + ) do + Appsignal::Transaction.current.add_opentelemetry_attributes( + Appsignal::OpenTelemetry::Messaging + .perform_attributes("sidekiq", :destination => item["queue"]) + ) + block.call + end rescue Exception => exception job_status = :failed raise exception diff --git a/lib/appsignal/opentelemetry.rb b/lib/appsignal/opentelemetry.rb index 725ee077a..c01f6b32c 100644 --- a/lib/appsignal/opentelemetry.rb +++ b/lib/appsignal/opentelemetry.rb @@ -6,6 +6,7 @@ require "appsignal/opentelemetry/http_method" require "appsignal/opentelemetry/http_response" require "appsignal/opentelemetry/http_server_request" +require "appsignal/opentelemetry/messaging" module Appsignal # @!visibility private diff --git a/lib/appsignal/opentelemetry/messaging.rb b/lib/appsignal/opentelemetry/messaging.rb new file mode 100644 index 000000000..2db28ac94 --- /dev/null +++ b/lib/appsignal/opentelemetry/messaging.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +module Appsignal + module OpenTelemetry + # @!visibility private + # + # Builds the OpenTelemetry attributes that describe a messaging operation, + # which for AppSignal means a background job being enqueued or performed. + # + # The semantic conventions ask for the messaging system, the name of the + # operation, and the kind of operation it was. The name is the job library's + # own word for what happened. The kind is one of the five values the + # conventions define, and AppSignal only ever produces two of them: + # enqueuing a job sends a message, and performing one processes it. + # + # They also ask for the destination of the message, which for a job library + # is the queue it is on, and for how many messages a span covers when it + # covers a batch of them. + # + # The system is the only part that differs per job library, so each + # integration passes its own. + module Messaging + SYSTEM_ATTRIBUTE = "messaging.system" + OPERATION_NAME_ATTRIBUTE = "messaging.operation.name" + OPERATION_TYPE_ATTRIBUTE = "messaging.operation.type" + DESTINATION_ATTRIBUTE = "messaging.destination.name" + BATCH_COUNT_ATTRIBUTE = "messaging.batch.message_count" + + # The operations AppSignal records, and the kind of operation the + # conventions class each of them as. + ENQUEUE = "enqueue" + PERFORM = "perform" + OPERATION_TYPES = { + ENQUEUE => "send", + PERFORM => "process" + }.freeze + + class << self + # The attributes describing a job being enqueued, as a Hash to pass to + # `add_opentelemetry_attributes`. + def enqueue_attributes(system, destination: nil, batch_size: nil) + attributes_for(system, ENQUEUE, destination, batch_size) + end + + # The attributes describing a job being performed, as a Hash to pass to + # `add_opentelemetry_attributes`. + def perform_attributes(system, destination: nil, batch_size: nil) + attributes_for(system, PERFORM, destination, batch_size) + end + + private + + def attributes_for(system, operation, destination, batch_size) + { + SYSTEM_ATTRIBUTE => system, + OPERATION_NAME_ATTRIBUTE => operation, + OPERATION_TYPE_ATTRIBUTE => OPERATION_TYPES.fetch(operation), + DESTINATION_ATTRIBUTE => destination_name(destination), + BATCH_COUNT_ATTRIBUTE => batch_count(batch_size) + }.compact + end + + # The queue the job is on, which the conventions call the destination of + # the message. A job whose queue we cannot name is described without it. + def destination_name(destination) + name = destination.to_s + name unless name.empty? + end + + # How many messages the span covers. The conventions ask for this only + # when a span describes a batch, and say it must not be set on a span + # that describes a single message. + def batch_count(batch_size) + return unless batch_size + + count = batch_size.to_i + count if count.positive? + end + end + end + end +end diff --git a/spec/lib/appsignal/hooks/activejob_spec.rb b/spec/lib/appsignal/hooks/activejob_spec.rb index 8eb008761..7be7510f8 100644 --- a/spec/lib/appsignal/hooks/activejob_spec.rb +++ b/spec/lib/appsignal/hooks/activejob_spec.rb @@ -154,6 +154,10 @@ def perform last_transaction.complete expect(root_span.kind).to eq(:consumer) + expect(root_span.attributes["messaging.system"]).to eq("active_job") + expect(root_span.attributes["messaging.operation.name"]).to eq("perform") + expect(root_span.attributes["messaging.operation.type"]).to eq("process") + expect(root_span.attributes["messaging.destination.name"]).to eq("default") expect(scope_of(root_span)).to eq(["appsignal-ruby/active_job", Appsignal::VERSION]) expect(root_span.attributes["appsignal.namespace"]).to eq("background") expect(root_span.attributes["appsignal.action_name"]).to eq("ActiveJobTestJob#perform") @@ -603,6 +607,10 @@ def enqueue_within_transaction expect(producer.name).to eq("enqueue.active_job (enqueue ActiveJobTestJob job)") expect(scope_of(producer)).to eq(["appsignal-ruby/active_job", Appsignal::VERSION]) expect(producer.kind).to eq(:producer) + expect(producer.attributes["messaging.system"]).to eq("active_job") + expect(producer.attributes["messaging.operation.name"]).to eq("enqueue") + expect(producer.attributes["messaging.operation.type"]).to eq("send") + expect(producer.attributes["messaging.destination.name"]).to eq("default") expect(producer.parent_span_id).to eq(root_span.span_id) # The serialized job carries that span's context, so the performed job diff --git a/spec/lib/appsignal/integrations/delayed_job_plugin_spec.rb b/spec/lib/appsignal/integrations/delayed_job_plugin_spec.rb index 7b0474335..b7e482a5c 100644 --- a/spec/lib/appsignal/integrations/delayed_job_plugin_spec.rb +++ b/spec/lib/appsignal/integrations/delayed_job_plugin_spec.rb @@ -40,7 +40,7 @@ def perform_job(job) transaction = http_request_transaction set_current_transaction(transaction) - Delayed::Job.enqueue(DelayedTestJob.new) + Delayed::Job.enqueue(DelayedTestJob.new, :queue => "dj-queue") event = transaction.to_h["events"].find { |e| e["name"] == "enqueue.delayed_job" } expect(event).to_not be_nil @@ -52,7 +52,7 @@ def perform_job(job) transaction = http_request_transaction set_current_transaction(transaction) - Delayed::Job.enqueue(DelayedTestJob.new) + Delayed::Job.enqueue(DelayedTestJob.new, :queue => "dj-queue") Appsignal::Transaction.complete_current! # Delayed Job has no envelope to carry trace context, so -- like @@ -62,6 +62,10 @@ def perform_job(job) expect(producer.name).to eq("enqueue.delayed_job (enqueue DelayedTestJob job)") expect(scope_of(producer)).to eq(["appsignal-ruby/delayed_job", Appsignal::VERSION]) expect(producer.kind).to eq(:producer) + expect(producer.attributes["messaging.system"]).to eq("delayed_job") + expect(producer.attributes["messaging.operation.name"]).to eq("enqueue") + expect(producer.attributes["messaging.operation.type"]).to eq("send") + expect(producer.attributes["messaging.destination.name"]).to eq("dj-queue") expect(producer.parent_span_id).to eq(root_span.span_id) end end @@ -174,7 +178,7 @@ def appsignal_name context "with a normal job" do it "wraps it in a background_job transaction", :agent_mode do start_agent - job = Delayed::Job.enqueue(DelayedTestJob.new) + job = Delayed::Job.enqueue(DelayedTestJob.new, :queue => "dj-queue") keep_transactions { perform_job(job) } @@ -188,16 +192,24 @@ def appsignal_name it "wraps it in a consumer span", :collector_mode do start_collector_agent - job = Delayed::Job.enqueue(DelayedTestJob.new) + job = Delayed::Job.enqueue(DelayedTestJob.new, :queue => "dj-queue") perform_job(job) Appsignal::Transaction.complete_current! expect(root_span.kind).to eq(:consumer) + expect(root_span.attributes["messaging.system"]).to eq("delayed_job") + expect(root_span.attributes["messaging.operation.name"]).to eq("perform") + expect(root_span.attributes["messaging.operation.type"]).to eq("process") + expect(root_span.attributes["messaging.destination.name"]).to eq("dj-queue") expect(root_span.attributes["appsignal.action_name"]).to eq("DelayedTestJob#perform") expect(root_span.attributes["appsignal.namespace"]).to eq("background") expect(event_spans.map(&:name)).to include("perform_job.delayed_job") perform_span = event_spans.find { |s| s.name == "perform_job.delayed_job" } + expect(perform_span.attributes["messaging.system"]).to eq("delayed_job") + expect(perform_span.attributes["messaging.operation.name"]).to eq("perform") + expect(perform_span.attributes["messaging.operation.type"]).to eq("process") + expect(perform_span.attributes["messaging.destination.name"]).to eq("dj-queue") expect(scope_of(root_span)).to eq(["appsignal-ruby/delayed_job", Appsignal::VERSION]) expect(scope_of(perform_span)).to eq(["appsignal-ruby/delayed_job", Appsignal::VERSION]) end diff --git a/spec/lib/appsignal/integrations/que_spec.rb b/spec/lib/appsignal/integrations/que_spec.rb index 94f86d8ed..5df212b92 100644 --- a/spec/lib/appsignal/integrations/que_spec.rb +++ b/spec/lib/appsignal/integrations/que_spec.rb @@ -80,11 +80,19 @@ def perform expect { perform }.to change { created_transactions.length }.by(1) expect(root_span.kind).to eq(:consumer) + expect(root_span.attributes["messaging.system"]).to eq("que") + expect(root_span.attributes["messaging.operation.name"]).to eq("perform") + expect(root_span.attributes["messaging.operation.type"]).to eq("process") + expect(root_span.attributes["messaging.destination.name"]).to eq("dfl") expect(root_span.attributes["appsignal.namespace"]) .to eq("background") expect(root_span.attributes["appsignal.action_name"]).to eq("MyQueJob#run") expect(exception_events).to be_empty span = event_spans.find { |s| s.name == "perform_job.que" } + expect(span.attributes["messaging.system"]).to eq("que") + expect(span.attributes["messaging.operation.name"]).to eq("perform") + expect(span.attributes["messaging.operation.type"]).to eq("process") + expect(span.attributes["messaging.destination.name"]).to eq("dfl") expect(span).not_to be_nil expect(span.parent_span_id).to eq(root_span.span_id) expect(span.attributes).not_to have_key("appsignal.body") @@ -500,6 +508,13 @@ def expect_job_arguments_untouched expect(producer.name).to eq("enqueue.que (enqueue MyQueJob job)") expect(scope_of(producer)).to eq(["appsignal-ruby/que", Appsignal::VERSION]) expect(producer.kind).to eq(:producer) + expect(producer.attributes["messaging.system"]).to eq("que") + expect(producer.attributes["messaging.operation.name"]).to eq("enqueue") + expect(producer.attributes["messaging.operation.type"]).to eq("send") + # Que only records the queue on the job itself. An enqueue that does not + # name one gets Que's default, which this integration cannot see, so no + # queue is reported. + expect(producer.attributes).to_not have_key("messaging.destination.name") expect(producer.parent_span_id).to eq(root_span.span_id) if DependencyHelper.que1_present? @@ -688,6 +703,12 @@ def bulk_enqueue(tags: ["user:42"]) producer = producers.first expect(producer.name).to eq("bulk_enqueue.que (bulk enqueue MyQueJob jobs)") expect(producer.kind).to eq(:producer) + expect(producer.attributes["messaging.system"]).to eq("que") + expect(producer.attributes["messaging.operation.name"]).to eq("enqueue") + expect(producer.attributes["messaging.operation.type"]).to eq("send") + # As with a single enqueue, a batch that does not name a queue gets + # Que's default, which this integration cannot see. + expect(producer.attributes).to_not have_key("messaging.destination.name") expect(producer.parent_span_id).to eq(root_span.span_id) # Every job in the batch carries the one producer span's context, plus diff --git a/spec/lib/appsignal/integrations/resque_spec.rb b/spec/lib/appsignal/integrations/resque_spec.rb index 270c31097..638f074e7 100644 --- a/spec/lib/appsignal/integrations/resque_spec.rb +++ b/spec/lib/appsignal/integrations/resque_spec.rb @@ -53,12 +53,20 @@ def perform perform expect(root_span.kind).to eq(:consumer) + expect(root_span.attributes["messaging.system"]).to eq("resque") + expect(root_span.attributes["messaging.operation.name"]).to eq("perform") + expect(root_span.attributes["messaging.operation.type"]).to eq("process") + expect(root_span.attributes["messaging.destination.name"]).to eq("default") expect(root_span.attributes["appsignal.namespace"]).to eq("background") expect(root_span.attributes["appsignal.action_name"]).to eq("ResqueTestJob#perform") expect(exception_events).to be_empty expect(root_span.attributes).to_not have_key("appsignal.tag.metadata_key") expect(root_span.attributes["appsignal.tag.queue"]).to eq(queue) span = event_spans.find { |s| s.name == "perform.resque" } + expect(span.attributes["messaging.system"]).to eq("resque") + expect(span.attributes["messaging.operation.name"]).to eq("perform") + expect(span.attributes["messaging.operation.type"]).to eq("process") + expect(span.attributes["messaging.destination.name"]).to eq("default") expect(span).not_to be_nil expect(span.parent_span_id).to eq(root_span.span_id) expect(scope_of(root_span)).to eq(["appsignal-ruby/resque", Appsignal::VERSION]) @@ -271,6 +279,10 @@ def enqueue expect(producer.name).to eq("enqueue.resque (enqueue ResqueTestJob job)") expect(scope_of(producer)).to eq(["appsignal-ruby/resque", Appsignal::VERSION]) expect(producer.kind).to eq(:producer) + expect(producer.attributes["messaging.system"]).to eq("resque") + expect(producer.attributes["messaging.operation.name"]).to eq("enqueue") + expect(producer.attributes["messaging.operation.type"]).to eq("send") + expect(producer.attributes["messaging.destination.name"]).to eq("default") expect(producer.parent_span_id).to eq(root_span.span_id) # The job carries the producer span's trace context, so the job that diff --git a/spec/lib/appsignal/integrations/shoryuken_client_spec.rb b/spec/lib/appsignal/integrations/shoryuken_client_spec.rb index 58ddc362c..79bbf41b7 100644 --- a/spec/lib/appsignal/integrations/shoryuken_client_spec.rb +++ b/spec/lib/appsignal/integrations/shoryuken_client_spec.rb @@ -61,6 +61,10 @@ def send_message producer = event_span_for("enqueue.shoryuken") expect(producer.name).to eq("enqueue.shoryuken (enqueue on test-queue)") expect(producer.kind).to eq(:producer) + expect(producer.attributes["messaging.system"]).to eq("aws_sqs") + expect(producer.attributes["messaging.operation.name"]).to eq("enqueue") + expect(producer.attributes["messaging.operation.type"]).to eq("send") + expect(producer.attributes["messaging.destination.name"]).to eq("test-queue") # The middleware the hook registered injected the producer span's trace # context onto the real outgoing message, wire-equivalent to OpenTelemetry's diff --git a/spec/lib/appsignal/integrations/shoryuken_spec.rb b/spec/lib/appsignal/integrations/shoryuken_spec.rb index 7560f94e5..64779371d 100644 --- a/spec/lib/appsignal/integrations/shoryuken_spec.rb +++ b/spec/lib/appsignal/integrations/shoryuken_spec.rb @@ -79,6 +79,10 @@ def perform expect { perform }.to change { created_transactions.length }.by(1) expect(root_span.kind).to eq(:consumer) + expect(root_span.attributes["messaging.system"]).to eq("aws_sqs") + expect(root_span.attributes["messaging.operation.name"]).to eq("perform") + expect(root_span.attributes["messaging.operation.type"]).to eq("process") + expect(root_span.attributes["messaging.destination.name"]).to eq("some-funky-queue-name") expect(root_span.attributes["appsignal.namespace"]) .to eq("background") expect(root_span.name).to eq("DemoShoryukenWorker#perform") @@ -86,6 +90,14 @@ def perform .to eq("DemoShoryukenWorker#perform") expect(exception_events).to be_empty span = event_spans.find { |s| s.name == "perform_job.shoryuken" } + expect(span.attributes["messaging.system"]).to eq("aws_sqs") + expect(span.attributes["messaging.operation.name"]).to eq("perform") + expect(span.attributes["messaging.operation.type"]).to eq("process") + expect(span.attributes["messaging.destination.name"]).to eq("some-funky-queue-name") + # A single message is not a batch, so the conventions say a span for + # one must not carry a message count. + expect(root_span.attributes).to_not have_key("messaging.batch.message_count") + expect(span.attributes).to_not have_key("messaging.batch.message_count") expect(span).not_to be_nil expect(span.parent_span_id).to eq(root_span.span_id) expect(span.attributes).not_to have_key("appsignal.body") @@ -349,6 +361,9 @@ def perform ) expect(root_span.attributes["appsignal.tag.batch"]).to eq(true) expect(root_span.attributes["appsignal.tag.queue"]).to eq("some-funky-queue-name") + # This call covers a batch, so it says how many messages are in it. + expect(root_span.attributes["messaging.batch.message_count"]).to eq(2) + expect(span.attributes["messaging.batch.message_count"]).to eq(2) # Earliest/oldest timestamp from messages expect(root_span.attributes["appsignal.tag.SentTimestamp"]) .to eq(sent_timestamp.to_s) @@ -414,6 +429,10 @@ def perform expect(producer.name).to eq("enqueue.shoryuken (enqueue MyShoryukenWorker job)") expect(scope_of(producer)).to eq(["appsignal-ruby/shoryuken", Appsignal::VERSION]) expect(producer.kind).to eq(:producer) + expect(producer.attributes["messaging.system"]).to eq("aws_sqs") + expect(producer.attributes["messaging.operation.name"]).to eq("enqueue") + expect(producer.attributes["messaging.operation.type"]).to eq("send") + expect(producer.attributes["messaging.destination.name"]).to eq("my-queue") expect(producer.parent_span_id).to eq(root_span.span_id) # The message carries the producer span's trace context as an SQS message diff --git a/spec/lib/appsignal/integrations/sidekiq_spec.rb b/spec/lib/appsignal/integrations/sidekiq_spec.rb index 28948b189..59fcb5a5a 100644 --- a/spec/lib/appsignal/integrations/sidekiq_spec.rb +++ b/spec/lib/appsignal/integrations/sidekiq_spec.rb @@ -140,6 +140,12 @@ def perform perform expect(root_span.kind).to eq(:consumer) + expect(root_span.attributes["messaging.system"]).to eq("sidekiq") + expect(root_span.attributes["messaging.operation.name"]).to eq("perform") + expect(root_span.attributes["messaging.operation.type"]).to eq("process") + # This transaction stands in for a job that could not be processed, + # so there is no job and no queue to name. + expect(root_span.attributes).to_not have_key("messaging.destination.name") expect(scope_of(root_span)).to eq(["appsignal-ruby/sidekiq", Appsignal::VERSION]) expect(root_span.attributes["appsignal.action_name"]) .to eq("SidekiqInternal") @@ -327,7 +333,9 @@ def perform describe Appsignal::Integrations::SidekiqClientMiddleware do let(:plugin) { described_class.new } - let(:job) { { "class" => "TestClass", "args" => [] } } + # Sidekiq fills the queue in before it calls the client middleware, so the + # job hash the middleware is given always names one. + let(:job) { { "class" => "TestClass", "args" => [], "queue" => "default" } } def enqueue plugin.call("TestClass", job, "default", nil) { :enqueued } @@ -362,6 +370,10 @@ def enqueue producer = event_span_for("enqueue.sidekiq") expect(producer.name).to eq("enqueue.sidekiq (enqueue TestClass job)") expect(producer.kind).to eq(:producer) + expect(producer.attributes["messaging.system"]).to eq("sidekiq") + expect(producer.attributes["messaging.operation.name"]).to eq("enqueue") + expect(producer.attributes["messaging.operation.type"]).to eq("send") + expect(producer.attributes["messaging.destination.name"]).to eq("default") expect(producer.parent_span_id).to eq(root_span.span_id) expect(scope_of(producer)).to eq(["appsignal-ruby/sidekiq", Appsignal::VERSION]) @@ -933,6 +945,10 @@ def perform expect(root_span.attributes["appsignal.tag.request_id"]).to eq(jid) expect(event_spans.size).to eq(1) span = event_spans.find { |s| s.name == "perform_job.sidekiq" } + expect(span.attributes["messaging.system"]).to eq("sidekiq") + expect(span.attributes["messaging.operation.name"]).to eq("perform") + expect(span.attributes["messaging.operation.type"]).to eq("process") + expect(span.attributes["messaging.destination.name"]).to eq("default") expect(span).not_to be_nil expect(span.parent_span_id).to eq(root_span.span_id) expect(span.attributes).not_to have_key("appsignal.body") diff --git a/spec/lib/appsignal/opentelemetry/messaging_spec.rb b/spec/lib/appsignal/opentelemetry/messaging_spec.rb new file mode 100644 index 000000000..068837c5e --- /dev/null +++ b/spec/lib/appsignal/opentelemetry/messaging_spec.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +describe Appsignal::OpenTelemetry::Messaging do + describe ".enqueue_attributes" do + # Enqueuing a job is one of the five kinds of operation the conventions + # define: it sends a message. + it "describes a job being enqueued" do + expect(described_class.enqueue_attributes("sidekiq")).to eq( + "messaging.system" => "sidekiq", + "messaging.operation.name" => "enqueue", + "messaging.operation.type" => "send" + ) + end + + it "names the queue the job is being put on" do + expect( + described_class.enqueue_attributes("sidekiq", :destination => "mailers") + ).to include("messaging.destination.name" => "mailers") + end + end + + describe ".perform_attributes" do + # Performing a job processes a message, which is another of the five. + it "describes a job being performed" do + expect(described_class.perform_attributes("resque")).to eq( + "messaging.system" => "resque", + "messaging.operation.name" => "perform", + "messaging.operation.type" => "process" + ) + end + + it "names the queue the job came off" do + expect( + described_class.perform_attributes("resque", :destination => "mailers") + ).to include("messaging.destination.name" => "mailers") + end + end + + describe "a span that covers a batch" do + it "says how many messages the batch holds" do + expect( + described_class.perform_attributes("aws_sqs", :batch_size => 3) + ).to include("messaging.batch.message_count" => 3) + end + + # The conventions say a span describing a single message must not carry a + # count, so only a batch gets one. + it "leaves out the count when the span is not a batch" do + expect(described_class.perform_attributes("aws_sqs")).to_not have_key( + "messaging.batch.message_count" + ) + end + + it "leaves out a count of nothing" do + expect( + described_class.perform_attributes("aws_sqs", :batch_size => 0) + ).to_not have_key("messaging.batch.message_count") + end + end + + # Not every job library records a queue, and not every job is on one, so the + # attribute is left out rather than sent empty. + it "leaves out a queue it was not given" do + expect(described_class.perform_attributes("resque")).to_not have_key( + "messaging.destination.name" + ) + expect( + described_class.perform_attributes("resque", :destination => "") + ).to_not have_key("messaging.destination.name") + end +end From cfdf2f4483df285917c460be5955a11c1e170b96 Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Mon, 3 Aug 2026 14:21:49 +0200 Subject: [PATCH 46/69] Mark render events as templating work Template rendering was the last kind of work the trace timeline could not place, so an Action View or ViewComponent render was shown under "Other". OpenTelemetry has no semantic convention for template rendering, and its own Action View instrumentation sets no convention attribute either, so there is nothing to follow. The timeline does read an `appsignal.group` attribute before it looks at any convention, so these spans say which group they belong to directly. --- .../active_support_notifications.rb | 13 +++++- .../instrument_shared_examples.rb | 44 +++++++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/lib/appsignal/integrations/active_support_notifications.rb b/lib/appsignal/integrations/active_support_notifications.rb index 8d447f916..1cc1f8375 100644 --- a/lib/appsignal/integrations/active_support_notifications.rb +++ b/lib/appsignal/integrations/active_support_notifications.rb @@ -28,6 +28,12 @@ class << self "search.elasticsearch" ].freeze + # Template rendering has no semantic convention to describe it, so + # these events say which group they belong to directly. The trace + # timeline reads `appsignal.group` before it looks at any convention + # attribute, and "render" is the group it shows as "Templating". + RENDER_ATTRIBUTES = { "appsignal.group" => "render" }.freeze + # OpenTelemetry attributes to add to an event's span, by event name. # These name the kind of work an event represents, which is what the # trace timeline reads to tell one kind of span from another. @@ -43,7 +49,12 @@ class << self "db.operation.name" => "search" }.freeze, "perform.active_job" => - Appsignal::OpenTelemetry::Messaging.perform_attributes("active_job").freeze + Appsignal::OpenTelemetry::Messaging.perform_attributes("active_job").freeze, + "render_template.action_view" => RENDER_ATTRIBUTES, + "render_partial.action_view" => RENDER_ATTRIBUTES, + "render_collection.action_view" => RENDER_ATTRIBUTES, + "render_layout.action_view" => RENDER_ATTRIBUTES, + "render.view_component" => RENDER_ATTRIBUTES }.freeze # Events a dedicated AppSignal integration already records with richer 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 77919a295..a3b81a63a 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 @@ -187,6 +187,50 @@ def perform end end + describe "a template render event" do + def perform + as.instrument("render_template.action_view", :identifier => "/app/views/a.erb") { "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" + # The title comes from the Action View formatter, which only registers + # when Rails is loaded. These shared examples also run under gemfiles + # that have ActiveSupport without Rails, where the event has no title, + # so match any String rather than one particular value. + expect(transaction).to include_event( + "name" => "render_template.action_view", + "title" => kind_of(String) + ) + # The render group only means something to the trace timeline, so + # nothing is recorded for it here. + expect(transaction).to_not include_tags("appsignal.group" => "render") + end + + it "in collector mode", :collector_mode do + start_collector_agent + transaction = http_request_transaction + set_current_transaction(transaction) + as.notifier = notifier + + expect(perform).to eq "value" + Appsignal::Transaction.complete_current! + + span = event_span_for("render_template.action_view") + expect(span).not_to be_nil + expect(span.parent_span_id).to eq(root_span.span_id) + # There is no OpenTelemetry convention for template rendering, so the + # span says which group it belongs to directly. + expect(span.attributes["appsignal.group"]).to eq("render") + expect(scope_of(span)).to eq(["appsignal-ruby/action_view", Appsignal::VERSION]) + end + end + describe "an event with no registered formatter" do def perform as.instrument("no-registered.formatter", :key => "something") { "value" } From 8059652c9e4333eace76b940ab8ec2d11e93efb8 Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Mon, 3 Aug 2026 14:22:02 +0200 Subject: [PATCH 47/69] Record the error type on spans that failed The semantic conventions ask for an `error.type` attribute on a span whose operation ended with an error, and for it to be left unset otherwise. The value has to have low cardinality, so it is the exception's class name rather than its message. This is an attribute of the span itself. The `exception.type` the gem already reports is an attribute of the exception event, which is a separate thing. An error that only passes through an instrumented event is never set on the transaction, and the event's span has closed by the time the transaction would record it. It is therefore read where the event is recorded. When one span collects more than one error the last one wins, because the attribute holds a single value. --- .../active_support_notifications.rb | 18 ++++++++ .../integrations/mongo_ruby_driver.rb | 22 +++++++--- lib/appsignal/opentelemetry.rb | 1 + lib/appsignal/opentelemetry/error_type.rb | 37 ++++++++++++++++ lib/appsignal/transaction.rb | 16 +++++++ .../transaction/opentelemetry_backend.rb | 6 +++ .../instrument_shared_examples.rb | 3 ++ .../integrations/mongo_ruby_driver_spec.rb | 17 ++++--- .../opentelemetry/error_type_spec.rb | 32 ++++++++++++++ .../transaction/opentelemetry_backend_spec.rb | 44 ++++++++++++++++++- spec/lib/appsignal_spec.rb | 37 ++++++++++++++++ 11 files changed, 219 insertions(+), 14 deletions(-) create mode 100644 lib/appsignal/opentelemetry/error_type.rb create mode 100644 spec/lib/appsignal/opentelemetry/error_type_spec.rb diff --git a/lib/appsignal/integrations/active_support_notifications.rb b/lib/appsignal/integrations/active_support_notifications.rb index 1cc1f8375..d3b0a9cff 100644 --- a/lib/appsignal/integrations/active_support_notifications.rb +++ b/lib/appsignal/integrations/active_support_notifications.rb @@ -103,6 +103,7 @@ def finish_event(name, payload = {}) attributes = EVENT_ATTRIBUTES[name.to_s] transaction.add_opentelemetry_attributes(attributes) if attributes transaction.add_opentelemetry_attributes(payload_attributes(name, payload)) + record_error_type(transaction, payload) transaction.finish_event( name.to_s, title, @@ -144,6 +145,23 @@ def job_queue_name(payload) job.queue_name if job.respond_to?(:queue_name) end + # Says what kind of failure ended the event, which the OpenTelemetry + # semantic conventions ask for on a span whose operation failed. + # + # ActiveSupport puts the exception in the payload when the instrumented + # block raised, and it does so before it hands control to any of the + # paths this integration hooks into. So the failure is readable here and + # there is nothing to rescue, whether the event was reported through a + # block or through a `start` and `finish` pair. + def record_error_type(transaction, payload) + error = payload[:exception_object] + return unless error + + transaction.add_opentelemetry_attributes( + Appsignal::OpenTelemetry::ErrorType.attributes_for(error.class.name) + ) + end + # Events starting with a bang are internal to Rails; suppressed events # are recorded by a dedicated integration instead. Both `start_event` # and `finish_event` gate on this so the event stack stays balanced. diff --git a/lib/appsignal/integrations/mongo_ruby_driver.rb b/lib/appsignal/integrations/mongo_ruby_driver.rb index 2911a4672..8c7d78c75 100644 --- a/lib/appsignal/integrations/mongo_ruby_driver.rb +++ b/lib/appsignal/integrations/mongo_ruby_driver.rb @@ -65,15 +65,20 @@ def finish(result, event) store = transaction.store("mongo_driver") command = store.delete(event.request_id) || {} - # MongoDB's own code for the error, which the conventions ask for - # whenever the database reported one. The driver reports a failure by - # calling us rather than by raising, so this is the only place it can be - # read. Set before the event is finished, so it lands on the query's own - # span. + # Say what kind of failure ended the query, which the OpenTelemetry + # semantic conventions ask for on a span whose operation failed. The + # driver reports a failure by calling us rather than by raising, so this + # is the only place it can be read. Set before the event is finished, so + # it lands on the query's own span. # # Only a failure event carries a failure, which is what tells the two # apart here. if event.respond_to?(:failure) + transaction.add_opentelemetry_attributes( + Appsignal::OpenTelemetry::ErrorType.attributes_for(error_name(event.failure)) + ) + # MongoDB's own code for the error, which the conventions ask for + # whenever the database reported one. transaction.add_opentelemetry_attributes( { "db.response.status_code" => error_code(event.failure) }.compact ) @@ -120,6 +125,13 @@ def error_code(failure) failure["code"]&.to_s end + + # MongoDB names an error in the `codeName` field of the error document it + # replies with. A failure the driver never got a document for, such as a + # connection that dropped, has no name. + def error_name(failure) + failure["codeName"] if failure.respond_to?(:[]) + end end end end diff --git a/lib/appsignal/opentelemetry.rb b/lib/appsignal/opentelemetry.rb index c01f6b32c..8655c0c6a 100644 --- a/lib/appsignal/opentelemetry.rb +++ b/lib/appsignal/opentelemetry.rb @@ -2,6 +2,7 @@ require "appsignal/opentelemetry/attributes" require "appsignal/opentelemetry/dependencies" +require "appsignal/opentelemetry/error_type" require "appsignal/opentelemetry/http_client_request" require "appsignal/opentelemetry/http_method" require "appsignal/opentelemetry/http_response" diff --git a/lib/appsignal/opentelemetry/error_type.rb b/lib/appsignal/opentelemetry/error_type.rb new file mode 100644 index 000000000..59a0ff023 --- /dev/null +++ b/lib/appsignal/opentelemetry/error_type.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module Appsignal + module OpenTelemetry + # @!visibility private + # + # Builds the OpenTelemetry attribute that describes what kind of failure + # ended an operation. + # + # The semantic conventions ask for `error.type` on a span whose operation + # failed, and for it to be left unset on one that succeeded. The value has to + # have low cardinality, which for an exception means its class name rather + # than its message. A failure we have no name for becomes `_OTHER`, which is + # the fallback the conventions define. + module ErrorType + ATTRIBUTE = "error.type" + + # The value the semantic conventions use for a failure the + # instrumentation has no name for. + OTHER = "_OTHER" + + class << self + # The attributes describing the given failure, as a Hash to pass to + # `add_opentelemetry_attributes`. Takes the name of the failure: an + # exception's class name, or the error code a datastore reported. + # + # An anonymous exception class has no name, and a datastore does not + # always report a code, so a missing name falls back to `_OTHER` rather + # than leaving the span with no `error.type` at all. + def attributes_for(name) + value = name.to_s + { ATTRIBUTE => value.empty? ? OTHER : value } + end + end + end + end +end diff --git a/lib/appsignal/transaction.rb b/lib/appsignal/transaction.rb index cb0fb6f15..1b08ae358 100644 --- a/lib/appsignal/transaction.rb +++ b/lib/appsignal/transaction.rb @@ -952,6 +952,22 @@ def instrument( # rubocop:disable Metrics/ParameterLists :opentelemetry_scope => opentelemetry_scope ) yield if block_given? + rescue Exception => error + # The block raised, so the operation this event describes failed. Say what + # kind of failure it was, which the OpenTelemetry semantic conventions ask + # for. This runs before the `ensure` below finishes the event, so the + # attribute lands on the event's own span. The error itself is not reported + # here; whatever catches it decides that. + # + # A paused transaction never started an event span, so there would be no + # span of this event's to describe. + unless paused? + add_opentelemetry_attributes( + Appsignal::OpenTelemetry::ErrorType.attributes_for(error.class.name) + ) + end + + raise ensure finish_event(name, title, body, body_format) end diff --git a/lib/appsignal/transaction/opentelemetry_backend.rb b/lib/appsignal/transaction/opentelemetry_backend.rb index b921a8050..494871aab 100644 --- a/lib/appsignal/transaction/opentelemetry_backend.rb +++ b/lib/appsignal/transaction/opentelemetry_backend.rb @@ -321,6 +321,12 @@ def set_error(class_name, message, backtrace, causes, _root_cause_missing) end span.add_event("exception", :attributes => attributes) + # `error.type` is what the semantic conventions read to tell what kind of + # failure ended the operation. It is an attribute of the span, unlike the + # `exception.type` above, which is an attribute of the exception event. + # When a span collects more than one error the last one wins, because a + # span can only say one thing here. + span.add_attributes(Appsignal::OpenTelemetry::ErrorType.attributes_for(class_name)) span.status = ::OpenTelemetry::Trace::Status.error end 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 a3b81a63a..a11b486e9 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 @@ -41,6 +41,7 @@ def perform # The scope is derived from the event group (the part after the last dot). expect(scope_of(span)).to eq(["appsignal-ruby/active_record", Appsignal::VERSION]) expect(span.attributes).not_to have_key("appsignal.body") + expect(span.attributes).not_to have_key("error.type") end end @@ -414,6 +415,8 @@ def perform expect(span.kind).to eq(:client) expect(span.attributes["db.query.text"]).to eq("SQL") expect(span.attributes["db.system.name"]).to eq("other_sql") + # The block raised, so the operation the span describes failed. + expect(span.attributes["error.type"]).to eq("ExampleException") end end diff --git a/spec/lib/appsignal/integrations/mongo_ruby_driver_spec.rb b/spec/lib/appsignal/integrations/mongo_ruby_driver_spec.rb index 330f57c6b..f4e3c23ab 100644 --- a/spec/lib/appsignal/integrations/mongo_ruby_driver_spec.rb +++ b/spec/lib/appsignal/integrations/mongo_ruby_driver_spec.rb @@ -26,8 +26,8 @@ def command_succeeded_event( ) end - # `failure` is the error document MongoDB replied with, which carries its - # own code for the error in the `code` field. + # `failure` is the error document MongoDB replied with, which names the error + # in its `codeName` field. def command_failed_event( # rubocop:disable Metrics/ParameterLists started_event, request_id: 1, command_name: "find", database_name: "test", duration: 0.9919, @@ -103,7 +103,7 @@ def perform # This command names no collection, so none is reported. expect(span.attributes).not_to have_key("db.collection.name") expect(span.attributes["appsignal.body"]).to eq("{\"foo\":\"?\"}") - expect(span.attributes).not_to have_key("db.response.status_code") + expect(span.attributes).not_to have_key("error.type") snapshot = metric_snapshot("mongodb_query_duration") expect(snapshot).not_to be_nil @@ -194,13 +194,14 @@ def perform expect(scope_of(span)).to eq(["appsignal-ruby/mongo", Appsignal::VERSION]) expect(span.attributes["db.system.name"]).to eq("mongodb") expect(span.attributes["appsignal.body"]).to eq("{\"foo\":\"?\"}") - # The query failed, and MongoDB reported its own code for the error in - # the document it replied with. + # The query failed, and MongoDB named the error in the document it + # replied with, along with its own code for it. + expect(span.attributes["error.type"]).to eq("NamespaceNotFound") expect(span.attributes["db.response.status_code"]).to eq("26") end end - describe "instrumenting a query that failed without an error code" do + describe "instrumenting a query that failed without a named error" do let(:started_event) { command_started_event(:request_id => 2) } let(:failed_event) do command_failed_event(started_event, :request_id => 2, :failure => {}) @@ -235,7 +236,9 @@ def perform span = event_span_for("query.mongodb") expect(span).not_to be_nil # A dropped connection is a failure MongoDB never replied to, so there - # is no code to report. + # is no error name or code to report. The span says what it can with + # the fallback the semantic conventions define. + expect(span.attributes["error.type"]).to eq("_OTHER") expect(span.attributes).to_not have_key("db.response.status_code") end end diff --git a/spec/lib/appsignal/opentelemetry/error_type_spec.rb b/spec/lib/appsignal/opentelemetry/error_type_spec.rb new file mode 100644 index 000000000..2bd5d7b0b --- /dev/null +++ b/spec/lib/appsignal/opentelemetry/error_type_spec.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +describe Appsignal::OpenTelemetry::ErrorType do + describe ".attributes_for" do + it "uses an exception class name as it is" do + expect(described_class.attributes_for("RuntimeError")).to eq( + "error.type" => "RuntimeError" + ) + end + + it "uses a datastore's error code as it is" do + expect(described_class.attributes_for("NamespaceNotFound")).to eq( + "error.type" => "NamespaceNotFound" + ) + end + + # An anonymous exception class has no name, and a datastore does not always + # report a code, so there has to be something to fall back to. + it "reports a failure without a name as _OTHER" do + expect(described_class.attributes_for(nil)).to eq("error.type" => "_OTHER") + expect(described_class.attributes_for("")).to eq("error.type" => "_OTHER") + end + + it "reports an anonymous exception class as _OTHER" do + error_class = Class.new(StandardError) + + expect(described_class.attributes_for(error_class.name)).to eq( + "error.type" => "_OTHER" + ) + end + end +end diff --git a/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb b/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb index 63b4bec9d..192e44c4a 100644 --- a/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb +++ b/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb @@ -903,9 +903,12 @@ def root_span_of(backend) describe "#set_error" do def exception_event(backend) backend.complete + root_span_of(backend).events.find { |e| e.name == "exception" } + end + + def root_span_of(backend) backend_span_id = backend.instance_variable_get(:@span).context.span_id - root = span_exporter.finished_spans.find { |s| s.span_id == backend_span_id } - root.events.find { |e| e.name == "exception" } + span_exporter.finished_spans.find { |s| s.span_id == backend_span_id } end it "records an exception span-event on the root span" do @@ -929,6 +932,43 @@ def exception_event(backend) expect(root.status.code).to eq(::OpenTelemetry::Trace::Status::ERROR) end + it "sets error.type on the span to the exception class" do + backend = create_backend + backend.set_error("RuntimeError", "boom", ["line 1"], [], false) + backend.complete + + expect(root_span_of(backend).attributes["error.type"]).to eq("RuntimeError") + end + + it "keeps the last error.type when a span collects more than one error" do + backend = create_backend + backend.set_error("RuntimeError", "first", ["line 1"], [], false) + backend.set_error("ArgumentError", "second", ["line 2"], [], false) + backend.complete + + expect(root_span_of(backend).attributes["error.type"]).to eq("ArgumentError") + end + + it "sets error.type on the span that is current when called" do + backend = create_backend + backend.start_event + backend.set_error("RuntimeError", "boom", ["line 1"], [], false) + backend.finish_event("sql.query", "title", "body", Appsignal::EventFormatter::DEFAULT) + backend.complete + + event_span = span_exporter.finished_spans.find { |s| s.name == "sql.query (title)" } + + expect(event_span.attributes["error.type"]).to eq("RuntimeError") + expect(root_span_of(backend).attributes).not_to have_key("error.type") + end + + it "does not set error.type when no error is set" do + backend = create_backend + backend.complete + + expect(root_span_of(backend).attributes).not_to have_key("error.type") + end + it "omits exception.stacktrace content when there is no backtrace" do backend = create_backend backend.set_error("RuntimeError", "boom", nil, [], false) diff --git a/spec/lib/appsignal_spec.rb b/spec/lib/appsignal_spec.rb index 2c9e10962..5976bbbc3 100644 --- a/spec/lib/appsignal_spec.rb +++ b/spec/lib/appsignal_spec.rb @@ -3265,6 +3265,7 @@ def perform expect(span.attributes["appsignal.body"]).to eq("body") expect(span.attributes).not_to have_key("db.query.text") expect(span.attributes).not_to have_key("db.system.name") + expect(span.attributes).not_to have_key("error.type") end end @@ -3295,6 +3296,39 @@ def perform expect(span.name).to eq("name (title)") expect(span.attributes).not_to have_key("appsignal.category") expect(span.attributes["appsignal.body"]).to eq("body") + # The block raised, so the operation the span describes failed. + expect(span.attributes["error.type"]).to eq("ExampleException") + end + end + + describe "when an error is raised in the block of an ignored event" do + def perform + expect do + Appsignal.ignore_instrumentation_events do + Appsignal.instrument("name", "title", "body") { raise ExampleException, "foo" } + end + end.to raise_error(ExampleException, "foo") + end + + it "in agent mode", :agent_mode do + start_agent + set_current_transaction(transaction) + perform + + expect(transaction).to_not include_event("name" => "name") + end + + it "in collector mode", :collector_mode do + start_collector_agent + set_current_transaction(transaction) + perform + Appsignal::Transaction.complete_current! + + # No event span was started, so there is no span of this event's to + # describe. The failure must not end up on the transaction's own span + # instead. + expect(event_spans).to be_empty + expect(root_span.attributes).not_to have_key("error.type") end end @@ -3325,6 +3359,9 @@ def perform expect(span.name).to eq("name (title)") expect(span.attributes).not_to have_key("appsignal.category") expect(span.attributes["appsignal.body"]).to eq("body") + # Throwing a symbol is control flow rather than a failure, so there is + # no kind of failure to report. + expect(span.attributes).not_to have_key("error.type") end end end From 618eb0623cad588595ed1d72643326fd54228fe6 Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Mon, 3 Aug 2026 19:41:59 +0200 Subject: [PATCH 48/69] Fix the negated include_event matcher The `include_event` matcher fills in a default for every key the caller leaves out. In the negated form that made the expectation pass almost regardless, because an event ruled out by name alone was only matched if its title was empty too, and nearly every event has a title. A spec saying an event was not recorded was saying nothing, and would have gone on passing if that event appeared. The negated form matches on the given keys alone. The Faraday spec was the one leaning on this: its claim that Net::HTTP is not recorded a second time under Faraday now fails if the suppression is taken away. Iterating a Rack response body's Enumerator turns out to record an event that a spec said it did not. It reads the body through the same instrumented `each`, so it is recorded exactly as passing a block is, and both that spec and its collector-mode counterpart now say so. --- spec/lib/appsignal/rack/body_wrapper_spec.rb | 22 +++++++++----------- spec/support/matchers/transaction.rb | 8 ++++++- 2 files changed, 17 insertions(+), 13 deletions(-) diff --git a/spec/lib/appsignal/rack/body_wrapper_spec.rb b/spec/lib/appsignal/rack/body_wrapper_spec.rb index 5cbbbd02c..56a38f36a 100644 --- a/spec/lib/appsignal/rack/body_wrapper_spec.rb +++ b/spec/lib/appsignal/rack/body_wrapper_spec.rb @@ -120,30 +120,28 @@ def perform expect { |b| enum.each(&b) }.to yield_successive_args("a", "b", "c") end + # Iterating the returned Enumerator reads the body through the same + # instrumented `each`, so it is recorded exactly as passing a block is. it "in agent mode", :agent_mode do start_agent perform - expect(transaction).to_not include_event("name" => "process_response_body.rack") + 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 - 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 names the span after its category alone, without - # a parenthesized title; the "#each" one never does. - titleless_event = event_spans_for("process_response_body.rack").find do |span| - span.name == "process_response_body.rack" - end - expect(titleless_event).to be_nil + expect_collector_event( + "process_response_body.rack", + "Process Rack response body (#each)" + ) end end diff --git a/spec/support/matchers/transaction.rb b/spec/support/matchers/transaction.rb index ec02c1fba..0d26b6b15 100644 --- a/spec/support/matchers/transaction.rb +++ b/spec/support/matchers/transaction.rb @@ -108,7 +108,13 @@ def define_transaction_sample_matcher_for(matcher_key, value_key = matcher_key) match_when_negated(:notify_expectation_failures => true) do |transaction| events = transaction.to_h["events"] if event - expect(events).to_not include(format_event(event)) + # Match on the given keys alone, rather than through `format_event`. Every + # key the caller leaves out is given a default there, and an event that + # differs from that default in any of them does not match. So an event + # ruled out by name alone still matched nothing as soon as it had a title, + # which nearly every event has, and the expectation passed while the event + # it ruled out was there all along. + expect(events).to_not include(hash_including(event.transform_keys(&:to_s))) else expect(events).to be_empty end From eb12d0236674da6da7c0d660af5c212eb4377fdd Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Tue, 4 Aug 2026 11:07:16 +0200 Subject: [PATCH 49/69] Test Excon suppression under Faraday again Faraday runs on a downstream HTTP client that AppSignal may instrument too, and it suppresses that client so the request is recorded once. Excon is one of the two clients this applies to, and it suppresses through a different integration than Net::HTTP does, so the pairing needs its own test. Nothing covered the two of them together after Excon moved to its own gemfile. Excon is back in the Faraday gemfile and the pairing is tested in both modes. Removing Faraday's suppression now fails four examples rather than two. --- gemfiles/faraday-2.gemfile | 2 + .../appsignal/integrations/faraday_spec.rb | 61 +++++++++++++++++-- 2 files changed, 58 insertions(+), 5 deletions(-) diff --git a/gemfiles/faraday-2.gemfile b/gemfiles/faraday-2.gemfile index 27a42cbd5..00a571da9 100644 --- a/gemfiles/faraday-2.gemfile +++ b/gemfiles/faraday-2.gemfile @@ -1,5 +1,7 @@ source "https://rubygems.org" +gem "excon" gem "faraday", "~> 2.0" +gem "faraday-excon" gemspec :path => "../" diff --git a/spec/lib/appsignal/integrations/faraday_spec.rb b/spec/lib/appsignal/integrations/faraday_spec.rb index 354b1ab68..d7ca79c15 100644 --- a/spec/lib/appsignal/integrations/faraday_spec.rb +++ b/spec/lib/appsignal/integrations/faraday_spec.rb @@ -65,11 +65,62 @@ def perform end end - # 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. + # Excon is the other adapter AppSignal instruments itself, so it is the other + # adapter that has to be suppressed. Net::HTTP above suppresses through its + # own integration; Excon suppresses through a different one, so both are + # worth a test. + describe "a request over the Excon adapter", :if => DependencyHelper.excon_present? do + before { Appsignal::Hooks::ExconHook.new.install } + + def perform + stub_request(:get, "http://www.example.com/") + connection = Faraday.new("http://www.example.com") do |faraday| + faraday.adapter :excon + end + connection.get("/") + 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.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 + + it "in collector mode", :collector_mode do + start_collector_agent + transaction = http_request_transaction + set_current_transaction(transaction) + perform + Appsignal::Transaction.complete_current! + + # Excon is suppressed, so there's no nested excon span. + expect(event_span("request.excon")).to be_nil + + # Faraday writes the wire traceparent. Excon's own inject middleware + # still runs, but with no Excon event span of its own it can only write + # the same client span Faraday already wrote. + faraday_span = event_span("request.faraday") + expect(faraday_span).not_to be_nil + expect(injected_traceparent("http://www.example.com/")) + .to eq("00-#{faraday_span.hex_trace_id}-#{faraday_span.hex_span_id}-01") + end + end + + # With an adapter AppSignal does not instrument at all (here Faraday's test + # adapter), our inject middleware is the only thing writing context, so the + # request carries the `request.faraday` client span's traceparent -- proving + # the middleware runs and injects inside that event's span. This is the path + # that gives Faraday propagation for adapters AppSignal doesn't instrument + # directly. it "injects the Faraday client context on a non-Net::HTTP adapter", :collector_mode do start_collector_agent transaction = http_request_transaction From 8ae58234ab20b33166b536f98033b8095ee432f3 Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Tue, 4 Aug 2026 11:17:06 +0200 Subject: [PATCH 50/69] Mark Excon spans as client spans In collector mode a span says what kind of operation it covers, and an outgoing HTTP request is a client span. It also says which instrumentation recorded it, so an Excon request is attributed to `appsignal-ruby/excon` rather than to the gem as a whole. The rebuilt Excon integration carries both again. --- lib/appsignal/integrations/excon.rb | 7 +- spec/lib/appsignal/integrations/excon_spec.rb | 113 +++++++++++++++--- 2 files changed, 104 insertions(+), 16 deletions(-) diff --git a/lib/appsignal/integrations/excon.rb b/lib/appsignal/integrations/excon.rb index 05f4e3bbe..1075eba99 100644 --- a/lib/appsignal/integrations/excon.rb +++ b/lib/appsignal/integrations/excon.rb @@ -40,7 +40,12 @@ def request(params = {}) # only the sending. title = ExconIntegration.title_for(data.merge(params)) - Appsignal.instrument("request.excon", title) do + Appsignal.instrument( + "request.excon", + title, + :opentelemetry_kind => :client, + :opentelemetry_scope => ["appsignal-ruby/excon", Appsignal::VERSION] + ) do if Appsignal::Transaction.current? # Excon retries a request, and follows a redirect, by calling this # method again from inside the request it is retrying or following. diff --git a/spec/lib/appsignal/integrations/excon_spec.rb b/spec/lib/appsignal/integrations/excon_spec.rb index 23e4acf5f..00d20f998 100644 --- a/spec/lib/appsignal/integrations/excon_spec.rb +++ b/spec/lib/appsignal/integrations/excon_spec.rb @@ -9,10 +9,6 @@ before { Appsignal::Hooks::ExconHook.new.install } let(:transaction) { http_request_transaction } - before do - start_agent - set_current_transaction(transaction) - end def event_names transaction.to_h["events"].map { |event| event["name"] } @@ -22,13 +18,25 @@ def event_duration(name) transaction.to_h["events"].find { |event| event["name"] == name }["duration"] end + # The single span an Excon request records. + def excon_span + event_span_for("request.excon") + end + + # How long a span lasted, in milliseconds. Spans carry nanoseconds. + def span_duration(span) + (span.end_timestamp - span.start_timestamp) / 1_000_000.0 + end + describe "a request that succeeds" do def perform stub_request(:get, "http://www.example.com/") Excon.get("http://www.example.com/") end - it "records the request as one event" do + it "in agent mode", :agent_mode do + start_agent + set_current_transaction(transaction) perform expect(event_names).to eq(["request.excon"]) @@ -39,11 +47,28 @@ def perform ) end - it "returns the response to the caller" do - expect(perform.status).to eq(200) + it "in collector mode", :collector_mode do + start_collector_agent + set_current_transaction(transaction) + perform + Appsignal::Transaction.complete_current! + + expect(event_spans.size).to eq(1) + span = excon_span + expect(span.name).to eq("request.excon (GET http://www.example.com)") + expect(span.kind).to eq(:client) + expect(span.parent_span_id).to eq(root_span.span_id) + expect(scope_of(span)).to eq(["appsignal-ruby/excon", Appsignal::VERSION]) end end + it_in_both_modes "returns the response to the caller" do + set_current_transaction(transaction) + stub_request(:get, "http://www.example.com/") + + expect(Excon.get("http://www.example.com/").status).to eq(200) + end + # Excon runs its middleware stack twice for a request: once on the way out to # send it, and again on the way back to read the response. Instrumenting the # connection puts both passes inside the event, so the wait for the remote @@ -68,11 +93,22 @@ def perform ) end - it "is recorded on the event" do + it "in agent mode", :agent_mode do + start_agent + set_current_transaction(transaction) perform expect(event_duration("request.excon")).to be >= 100 end + + it "in collector mode", :collector_mode do + start_collector_agent + set_current_transaction(transaction) + perform + Appsignal::Transaction.complete_current! + + expect(span_duration(excon_span)).to be >= 100 + end end describe "a request that fails" do @@ -82,11 +118,23 @@ def perform .to raise_error(Excon::Error::Timeout) end - it "records the request as one event, and lets the error through" do + it "in agent mode", :agent_mode do + start_agent + set_current_transaction(transaction) perform expect(event_names).to eq(["request.excon"]) end + + it "in collector mode", :collector_mode do + start_collector_agent + set_current_transaction(transaction) + perform + Appsignal::Transaction.complete_current! + + expect(event_spans.size).to eq(1) + expect(excon_span.kind).to eq(:client) + end end # An outer HTTP client integration, such as Faraday, records a request @@ -99,11 +147,23 @@ def perform end end - it "records no event, and still returns the response" do - expect(perform.status).to eq(200) + it "in agent mode", :agent_mode do + start_agent + set_current_transaction(transaction) + expect(perform.status).to eq(200) expect(event_names).to be_empty end + + it "in collector mode", :collector_mode do + start_collector_agent + set_current_transaction(transaction) + + expect(perform.status).to eq(200) + Appsignal::Transaction.complete_current! + + expect(event_spans).to be_empty + end end # Excon retries a request when it is marked idempotent and fails with a @@ -122,11 +182,22 @@ def perform end.to raise_error(Excon::Error::Timeout) end - it "records every attempt as one event" do + it "in agent mode", :agent_mode do + start_agent + set_current_transaction(transaction) perform expect(event_names).to eq(["request.excon"]) end + + it "in collector mode", :collector_mode do + start_collector_agent + set_current_transaction(transaction) + perform + Appsignal::Transaction.complete_current! + + expect(event_spans.size).to eq(1) + end end # Excon follows a redirect the same way, by making the request again from @@ -146,17 +217,29 @@ def perform ) end - it "records every hop as one event" do + # The event is named after the request that was made, not the location it + # ended up at. + it "in agent mode", :agent_mode do + start_agent + set_current_transaction(transaction) perform expect(event_names).to eq(["request.excon"]) - # The event is titled after the request that was made, not the location - # it ended up at. expect(transaction).to include_event( "name" => "request.excon", "title" => "GET http://www.example.com" ) end + + it "in collector mode", :collector_mode do + start_collector_agent + set_current_transaction(transaction) + perform + Appsignal::Transaction.complete_current! + + expect(event_spans.size).to eq(1) + expect(excon_span.name).to eq("request.excon (GET http://www.example.com)") + end end end end From ea8fdfa72209b99247a514bfb37b3f0a48b7cb9f Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Tue, 4 Aug 2026 11:20:03 +0200 Subject: [PATCH 51/69] Inject trace context into Excon requests In collector mode an outgoing HTTP request carries the current trace context, so the service being called continues the same trace. Excon exposes the request's headers to a middleware, so the context is written there. What the context is taken from has changed. It used to reflect the event span of one of Excon's instrumentor notifications, and now reflects the one span open around the whole request. A retried request shows the difference, because every attempt shares that span and so carries the same context. --- lib/appsignal/hooks/excon.rb | 20 +++++++++++++ .../excon/appsignal_middleware.rb | 10 +++---- spec/lib/appsignal/hooks/excon_spec.rb | 26 +++++++++++++++++ spec/lib/appsignal/integrations/excon_spec.rb | 29 +++++++++++++++++++ 4 files changed, 80 insertions(+), 5 deletions(-) diff --git a/lib/appsignal/hooks/excon.rb b/lib/appsignal/hooks/excon.rb index f3af85f05..57a0d1b88 100644 --- a/lib/appsignal/hooks/excon.rb +++ b/lib/appsignal/hooks/excon.rb @@ -12,15 +12,35 @@ def dependencies_present? def install require "appsignal/integrations/excon" + require "appsignal/integrations/excon/appsignal_middleware" # Instrument the request at the connection, rather than by registering # AppSignal as Excon's instrumentor. An instrumentor is told about a # request in pieces, none of which covers the wait for the response, and # there is only room for one of them, so registering ours would replace # any the application set up itself. ::Excon::Connection.prepend Appsignal::Integrations::ExconIntegration + install_middleware Appsignal::Environment.report_enabled("excon") end + + private + + # Trace context is written onto the outgoing request by a middleware, + # because that is where Excon exposes the request's headers. + # + # Insert it just before the Mock middleware, the innermost one, where the + # response is produced. That way it runs before the request is sent. + # Appending to the end would place it after Mock, which short-circuits the + # chain before reaching it. + def install_middleware + middlewares = ::Excon.defaults[:middlewares].dup + return if middlewares.include?(Appsignal::Integrations::ExconMiddleware) + + index = middlewares.index(::Excon::Middleware::Mock) || middlewares.length + middlewares.insert(index, Appsignal::Integrations::ExconMiddleware) + ::Excon.defaults[:middlewares] = middlewares + end end end end diff --git a/lib/appsignal/integrations/excon/appsignal_middleware.rb b/lib/appsignal/integrations/excon/appsignal_middleware.rb index 32b736834..0c9237099 100644 --- a/lib/appsignal/integrations/excon/appsignal_middleware.rb +++ b/lib/appsignal/integrations/excon/appsignal_middleware.rb @@ -3,16 +3,16 @@ 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. + # the called service joins this trace. The integration on the connection + # records the span; this middleware only injects. # # @!visibility private class ExconMiddleware < ::Excon::Middleware::Base def request_call(datum) datum[:headers] ||= {} - # Inject from whatever span is current. The instrumentor's event span is - # active during the request, so the written `traceparent` reflects the - # Excon client event. No-op outside collector mode. + # Inject from whatever span is current. The connection's client span is + # open around the whole request, so the written `traceparent` reflects + # the Excon client event. No-op outside collector mode. Appsignal::OpenTelemetry.inject_context(datum[:headers]) super end diff --git a/spec/lib/appsignal/hooks/excon_spec.rb b/spec/lib/appsignal/hooks/excon_spec.rb index 6a68ec00e..4e2a1138c 100644 --- a/spec/lib/appsignal/hooks/excon_spec.rb +++ b/spec/lib/appsignal/hooks/excon_spec.rb @@ -6,6 +6,17 @@ before do stub_const("Excon", Module.new) stub_const("Excon::Connection", Class.new) + stub_const("Excon::Middleware", Module.new) + stub_const("Excon::Middleware::Base", Class.new do + def initialize(stack = nil) + @stack = stack + end + end) + stub_const("Excon::Middleware::Mock", Class.new(Excon::Middleware::Base)) + # Mock is the innermost default middleware; the hook inserts ours before it. + Excon.singleton_class.define_method(:defaults) do + @defaults ||= { :middlewares => [Excon::Middleware::Mock] } + end Appsignal::Hooks::ExconHook.new.install end @@ -26,6 +37,21 @@ expect(Excon::Connection.ancestors) .to include(Appsignal::Integrations::ExconIntegration) end + + it "adds the AppSignal middleware to Excon, before the Mock middleware" do + middlewares = Excon.defaults[:middlewares] + expect(middlewares).to include(Appsignal::Integrations::ExconMiddleware) + expect(middlewares.index(Appsignal::Integrations::ExconMiddleware)) + .to be < middlewares.index(Excon::Middleware::Mock) + end + + it "does not add the middleware twice when installed again" do + Appsignal::Hooks::ExconHook.new.install + + expect( + Excon.defaults[:middlewares].count(Appsignal::Integrations::ExconMiddleware) + ).to eq(1) + end end end diff --git a/spec/lib/appsignal/integrations/excon_spec.rb b/spec/lib/appsignal/integrations/excon_spec.rb index 00d20f998..d6d377a7c 100644 --- a/spec/lib/appsignal/integrations/excon_spec.rb +++ b/spec/lib/appsignal/integrations/excon_spec.rb @@ -28,6 +28,25 @@ def span_duration(span) (span.end_timestamp - span.start_timestamp) / 1_000_000.0 end + # Reads the `traceparent` header off the last recorded outgoing request to + # `url`. Returns nil when nothing wrote one. + def injected_traceparent(url) + traceparent = nil + # The block is a predicate, so it has to match whatever it reads. It is + # only here to get at the headers of the requests that were made. + matcher = a_request(:get, url).with do |request| + traceparent = request.headers["Traceparent"] + true + end + expect(matcher).to have_been_made.at_least_once + traceparent + end + + # The W3C traceparent that names `span` as the parent, sampled. + def traceparent_for(span) + "00-#{span.hex_trace_id}-#{span.hex_span_id}-01" + end + describe "a request that succeeds" do def perform stub_request(:get, "http://www.example.com/") @@ -45,6 +64,8 @@ def perform "title" => "GET http://www.example.com", "body" => "" ) + # Trace context is only written in collector mode. + expect(injected_traceparent("http://www.example.com/")).to be_nil end it "in collector mode", :collector_mode do @@ -59,6 +80,10 @@ def perform expect(span.kind).to eq(:client) expect(span.parent_span_id).to eq(root_span.span_id) expect(scope_of(span)).to eq(["appsignal-ruby/excon", Appsignal::VERSION]) + + # The called service joins this trace, as a child of the client span. + expect(injected_traceparent("http://www.example.com/")) + .to eq(traceparent_for(span)) end end @@ -197,6 +222,10 @@ def perform Appsignal::Transaction.complete_current! expect(event_spans.size).to eq(1) + # Every attempt shares the one span, so every attempt carries the same + # trace context. + expect(injected_traceparent("http://www.example.com/")) + .to eq(traceparent_for(excon_span)) end end From 5db9a4867ed2c6bed4c0cfdab4e5c24ec1264ba4 Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Tue, 4 Aug 2026 11:36:02 +0200 Subject: [PATCH 52/69] Describe Excon requests on their span In collector mode a span carries the attributes the semantic conventions ask for, and for an outgoing HTTP request those are the request method, the host and port being called, the URL, and the response status. Excon could not describe a request this way before. Everything except the status came from one of Excon's instrumentor notifications and the status came from another, so the two never met on the same span, and the span carrying the status carried nothing else. A request is one span now, so all of it lands together. A failed request is described too, with the kind of error that ended it. A pipelined request is the one case where Excon returns before its response is read, so it has no status to describe. It hands back the request data rather than a response. --- lib/appsignal/integrations/excon.rb | 62 ++++++++++++++----- spec/lib/appsignal/integrations/excon_spec.rb | 62 ++++++++++++++++++- 2 files changed, 107 insertions(+), 17 deletions(-) diff --git a/lib/appsignal/integrations/excon.rb b/lib/appsignal/integrations/excon.rb index 1075eba99..d932ee765 100644 --- a/lib/appsignal/integrations/excon.rb +++ b/lib/appsignal/integrations/excon.rb @@ -4,16 +4,20 @@ module Appsignal module Integrations # @!visibility private module ExconIntegration + # The method of a request. Excon defaults it to GET further down, so the + # same default is applied here. + def self.method_for(datum) + datum[:method] || :get + end + # The title of the event, built the way the Net::HTTP integration builds # its own: the request method and where the request went, without the # path, so paths stay out of event titles. # # Excon splits a request's data between the connection it is made on and - # the call that makes it, so both are read to build this. Excon defaults - # the method to GET itself, so the same default is applied here. + # the call that makes it, so both are read to build this. def self.title_for(datum) - method = (datum[:method] || :get).to_s.upcase - "#{method} #{datum[:scheme]}://#{datum[:host]}" + "#{method_for(datum).to_s.upcase} #{datum[:scheme]}://#{datum[:host]}" end def request(params = {}) @@ -38,23 +42,51 @@ def request(params = {}) # A pipelined request is the exception to this method being the whole of # a request. It returns before the response is read, so its event covers # only the sending. - title = ExconIntegration.title_for(data.merge(params)) + datum = data.merge(params) Appsignal.instrument( "request.excon", - title, + ExconIntegration.title_for(datum), :opentelemetry_kind => :client, :opentelemetry_scope => ["appsignal-ruby/excon", Appsignal::VERSION] ) do - if Appsignal::Transaction.current? - # Excon retries a request, and follows a redirect, by calling this - # method again from inside the request it is retrying or following. - # Suppressing those means they count towards this event rather than - # becoming events of their own, so one request stays one event. - Appsignal::Transaction.current.suppress_http_client_events { super } - else - super - end + # Describes the span as an outgoing HTTP request. Together with the + # CLIENT kind, this is what the trace timeline reads to recognize it + # as one. + Appsignal::Transaction.current.add_opentelemetry_attributes( + Appsignal::OpenTelemetry::HttpClientRequest.attributes_for( + :method => ExconIntegration.method_for(datum), + :scheme => datum[:scheme], + :host => datum[:host], + :port => datum[:port], + :path => datum[:path] + ) + ) + + response = + if Appsignal::Transaction.current? + # Excon retries a request, and follows a redirect, by calling this + # method again from inside the request it is retrying or + # following. Suppressing those means they count towards this event + # rather than becoming events of their own, so one request stays + # one event. + Appsignal::Transaction.current.suppress_http_client_events { super } + else + super + end + + # Describes the response on the same span as the request, which the + # semantic conventions ask for whenever one was received. + # + # A pipelined request returns the request data rather than a response, + # because its response has not been read yet, so there is no status to + # report for it. + Appsignal::Transaction.current.add_opentelemetry_attributes( + Appsignal::OpenTelemetry::HttpResponse.attributes_for( + response.respond_to?(:status) ? response.status : nil + ) + ) + response end end end diff --git a/spec/lib/appsignal/integrations/excon_spec.rb b/spec/lib/appsignal/integrations/excon_spec.rb index d6d377a7c..aa2803824 100644 --- a/spec/lib/appsignal/integrations/excon_spec.rb +++ b/spec/lib/appsignal/integrations/excon_spec.rb @@ -81,6 +81,18 @@ def perform expect(span.parent_span_id).to eq(root_span.span_id) expect(scope_of(span)).to eq(["appsignal-ruby/excon", Appsignal::VERSION]) + # The request and the response are described on the same span. Excon's + # own instrumentor could only report the status on a span of its own, + # which carried nothing else. + expect(span.attributes["http.request.method"]).to eq("GET") + # Excon hands us the method as a lowercase Symbol, so the canonical form + # is recorded and the original kept alongside it. + expect(span.attributes["http.request.method_original"]).to eq("get") + expect(span.attributes["server.address"]).to eq("www.example.com") + expect(span.attributes["server.port"]).to eq(80) + expect(span.attributes["url.full"]).to eq("http://www.example.com/") + expect(span.attributes["http.response.status_code"]).to eq(200) + # The called service joins this trace, as a child of the client span. expect(injected_traceparent("http://www.example.com/")) .to eq(traceparent_for(span)) @@ -158,7 +170,15 @@ def perform Appsignal::Transaction.complete_current! expect(event_spans.size).to eq(1) - expect(excon_span.kind).to eq(:client) + span = excon_span + expect(span.kind).to eq(:client) + # Says what kind of failure ended the request, which the conventions ask + # for on a span whose operation failed. Recorded because the whole + # request now runs inside one instrumented block, which sets this when + # the block raises. + expect(span.attributes["error.type"]).to eq("Excon::Error::Timeout") + # No response was received, so there is no status to describe. + expect(span.attributes).to_not have_key("http.response.status_code") end end @@ -267,7 +287,45 @@ def perform Appsignal::Transaction.complete_current! expect(event_spans.size).to eq(1) - expect(excon_span.name).to eq("request.excon (GET http://www.example.com)") + span = excon_span + expect(span.name).to eq("request.excon (GET http://www.example.com)") + # The span describes the request that was made and the response that + # came back, so the URL is the one asked for and the status is the one + # the last hop answered with. + expect(span.attributes["url.full"]).to eq("http://www.example.com/") + expect(span.attributes["http.response.status_code"]).to eq(200) + end + end + + # A pipelined request is the one case where the event does not cover a whole + # request. Excon returns from the call once the request has been sent, and + # the response is read later, so there is no response to describe. It hands + # back the request data rather than a response. + describe "a pipelined request" do + def perform + stub_request(:get, "http://www.example.com/") + Excon.new("http://www.example.com/") + .request(:method => :get, :pipeline => true) + end + + it "in agent mode", :agent_mode do + start_agent + set_current_transaction(transaction) + + expect(perform).to be_kind_of(Hash) + expect(event_names).to eq(["request.excon"]) + end + + it "in collector mode", :collector_mode do + start_collector_agent + set_current_transaction(transaction) + perform + Appsignal::Transaction.complete_current! + + expect(event_spans.size).to eq(1) + span = excon_span + expect(span.attributes["url.full"]).to eq("http://www.example.com/") + expect(span.attributes).to_not have_key("http.response.status_code") end end end From 7e4da7dcc9273f7f504f448aec4f50861eae642b Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Fri, 7 Aug 2026 16:21:26 +0200 Subject: [PATCH 53/69] Drop the duplicate queue start attribute The queue start event on the transaction's root span carried the queue start time twice, as the event's timestamp and as an attribute of the same name. A span event carries its own time, so the attribute said nothing the event did not. Only the timestamp is set now, which is what the breadcrumb event a little further up the file already does. The specs asserted the attribute and nothing about the timestamp, even where an example's own name said it was about the time the queue started. They assert the timestamp now. --- lib/appsignal/transaction/opentelemetry_backend.rb | 6 ++++-- spec/lib/appsignal/integrations/shoryuken_spec.rb | 6 ++++-- spec/lib/appsignal/integrations/sidekiq_spec.rb | 11 +++++++---- spec/lib/appsignal/rack/abstract_middleware_spec.rb | 4 ++-- spec/lib/appsignal/rack/event_handler_spec.rb | 3 ++- .../transaction/opentelemetry_backend_spec.rb | 3 ++- spec/lib/appsignal/transaction_spec.rb | 3 ++- 7 files changed, 23 insertions(+), 13 deletions(-) diff --git a/lib/appsignal/transaction/opentelemetry_backend.rb b/lib/appsignal/transaction/opentelemetry_backend.rb index 494871aab..858bc57f5 100644 --- a/lib/appsignal/transaction/opentelemetry_backend.rb +++ b/lib/appsignal/transaction/opentelemetry_backend.rb @@ -207,14 +207,16 @@ def set_namespace(namespace) # `appsignal.queue_start` event on the root span (per-trace timeline) and, # at completion, a `transaction_queue_duration` metric (the aggregate # graph). Like the agent, we record the delta and never shift span timing. + # + # The queue start time becomes the event's timestamp (events carry their + # own time), so it is not duplicated as an attribute. def set_queue_start(start) return unless start && start > QUEUE_START_MIN @queue_start = start @span.add_event( "appsignal.queue_start", - :timestamp => Time.at(start / 1000.0), - :attributes => { "appsignal.queue_start" => start } + :timestamp => Time.at(start / 1000.0) ) end diff --git a/spec/lib/appsignal/integrations/shoryuken_spec.rb b/spec/lib/appsignal/integrations/shoryuken_spec.rb index 64779371d..1e1d9d642 100644 --- a/spec/lib/appsignal/integrations/shoryuken_spec.rb +++ b/spec/lib/appsignal/integrations/shoryuken_spec.rb @@ -110,7 +110,8 @@ def perform 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(queue_event.timestamp) + .to eq((Time.at(sent_timestamp / 1000.0).to_r * 1_000_000_000).to_i) expect(last_transaction).to be_completed end end @@ -368,7 +369,8 @@ def perform 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) + expect(queue_event.timestamp) + .to eq((Time.at(sent_timestamp / 1000.0).to_r * 1_000_000_000).to_i) # A batch carries messages from multiple traces, so it is not linked back. expect(Array(root_span.links)).to be_empty diff --git a/spec/lib/appsignal/integrations/sidekiq_spec.rb b/spec/lib/appsignal/integrations/sidekiq_spec.rb index 59fcb5a5a..a6b7be961 100644 --- a/spec/lib/appsignal/integrations/sidekiq_spec.rb +++ b/spec/lib/appsignal/integrations/sidekiq_spec.rb @@ -1061,8 +1061,9 @@ def perform 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(queue_event.timestamp).to eq( + (Time.at(Time.parse("2001-01-01 10:00:00UTC").to_i).to_r * 1_000_000_000).to_i + ) expect(event_spans.size).to eq(1) span = event_spans.find { |s| s.name == "perform_job.sidekiq" } expect(span).not_to be_nil @@ -1230,7 +1231,8 @@ def perform .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) + expect(queue_event.timestamp) + .to eq((Time.at(time.to_i).to_r * 1_000_000_000).to_i) # The job is enqueued without an active transaction here, so no # enqueue event/producer span is recorded -- only the perform events. expect(event_spans.map(&:name)).to match_array(expected_perform_events) @@ -1284,7 +1286,8 @@ def perform 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(queue_event.timestamp) + .to eq((Time.at(time.to_i).to_r * 1_000_000_000).to_i) expect(JSON.parse(root_span.attributes["appsignal.function.parameters"])) .to eq([expected_args]) sidekiq_span = event_spans.find { |s| s.name == "perform_job.sidekiq" } diff --git a/spec/lib/appsignal/rack/abstract_middleware_spec.rb b/spec/lib/appsignal/rack/abstract_middleware_spec.rb index c6e3ae61c..cf94905a6 100644 --- a/spec/lib/appsignal/rack/abstract_middleware_spec.rb +++ b/spec/lib/appsignal/rack/abstract_middleware_spec.rb @@ -531,8 +531,8 @@ def perform 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) + expect(queue_event.timestamp) + .to eq((Time.at(queue_start_time.to_i / 1000.0).to_r * 1_000_000_000).to_i) end end end diff --git a/spec/lib/appsignal/rack/event_handler_spec.rb b/spec/lib/appsignal/rack/event_handler_spec.rb index 3eaadacf2..c494fa555 100644 --- a/spec/lib/appsignal/rack/event_handler_spec.rb +++ b/spec/lib/appsignal/rack/event_handler_spec.rb @@ -229,7 +229,8 @@ def perform 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) + expect(queue_event.timestamp) + .to eq((Time.at(queue_start_time.to_i / 1000.0).to_r * 1_000_000_000).to_i) event = event_span_for("process_request.rack") expect(event).not_to be_nil expect(event.parent_span_id).to eq(root_span.span_id) diff --git a/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb b/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb index 192e44c4a..00793e3a7 100644 --- a/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb +++ b/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb @@ -453,7 +453,8 @@ def event_span_named(name) 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) + expect(event.timestamp) + .to eq((Time.at(1_700_000_000_000 / 1000.0).to_r * 1_000_000_000).to_i) end it "emits the queue duration metric in two series on completion" do diff --git a/spec/lib/appsignal/transaction_spec.rb b/spec/lib/appsignal/transaction_spec.rb index a758452e4..a9950b629 100644 --- a/spec/lib/appsignal/transaction_spec.rb +++ b/spec/lib/appsignal/transaction_spec.rb @@ -3233,7 +3233,8 @@ def perform 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) + expect(event.timestamp) + .to eq((Time.at(queue_start / 1000.0).to_r * 1_000_000_000).to_i) # The "http_request" namespace is emitted as "web". snapshot = metric_snapshot("transaction_queue_duration") From 51434c840389d3ce030a98acd8efc17675573423 Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Fri, 7 Aug 2026 16:21:26 +0200 Subject: [PATCH 54/69] Report a signalled runner script as a failure The integration runner checked a finished script's exit code to decide whether it had failed. A process killed by a signal has no exit code, so reading one raised a NoMethodError on nil. That hid both what happened to the script and the output the check prints to explain it. Ask the status whether it succeeded instead, which answers for a signalled process too, and name the status in the failure. --- spec/integration/runner.rb | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/spec/integration/runner.rb b/spec/integration/runner.rb index b337ba392..18bf1c08d 100644 --- a/spec/integration/runner.rb +++ b/spec/integration/runner.rb @@ -85,9 +85,12 @@ def run read_output @finished = true - return if @status.exitstatus.zero? + # Asked of the status rather than the exit code, because a process killed by + # a signal has no exit code. Reading one would raise a NoMethodError on nil + # and hide both the signal and the output below. + return if @status.success? - raise "Runner '#{@script_file}' exited with status #{@status.exitstatus}.\n" \ + raise "Runner '#{@script_file}' did not exit successfully (#{@status}).\n" \ "Output:\n#{@output}" ensure FileUtils.remove_entry(@working_dir) if @working_dir && File.exist?(@working_dir) From 176186e4c0abff7a535317f38886b4e6a54a5ac2 Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Fri, 7 Aug 2026 12:07:45 +0200 Subject: [PATCH 55/69] Remove an event formatter that never ran The ViewComponent formatter was registered for two event names, `render.view_component` and `!render.view_component`. The second could never be reached, because an event name starting with an exclamation mark is internal to Rails and this gem skips those before the registry is consulted. Nothing else can reach it either: only two places look a formatter up by name, one skips the exclamation mark names and the other builds its name from a dry-monitor event, which cannot start with one. Its only test asserted that the registration existed, so it proved nothing beyond itself. Both are gone. --- .../event_formatter/view_component/render_formatter.rb | 4 ---- .../event_formatter/view_component/render_formatter_spec.rb | 6 +----- 2 files changed, 1 insertion(+), 9 deletions(-) diff --git a/lib/appsignal/event_formatter/view_component/render_formatter.rb b/lib/appsignal/event_formatter/view_component/render_formatter.rb index 0fbb02b35..f3075b788 100644 --- a/lib/appsignal/event_formatter/view_component/render_formatter.rb +++ b/lib/appsignal/event_formatter/view_component/render_formatter.rb @@ -24,8 +24,4 @@ def root_path "render.view_component", Appsignal::EventFormatter::ViewComponent::RenderFormatter ) - Appsignal::EventFormatter.register( - "!render.view_component", - Appsignal::EventFormatter::ViewComponent::RenderFormatter - ) end diff --git a/spec/lib/appsignal/event_formatter/view_component/render_formatter_spec.rb b/spec/lib/appsignal/event_formatter/view_component/render_formatter_spec.rb index 51bc2bad5..fe0bda75a 100644 --- a/spec/lib/appsignal/event_formatter/view_component/render_formatter_spec.rb +++ b/spec/lib/appsignal/event_formatter/view_component/render_formatter_spec.rb @@ -6,11 +6,9 @@ let(:formatter) { klass.new } before { allow(Rails.root).to receive(:to_s).and_return("/var/www/app/20130101") } - it "registers render.view_component and (deprecated) !render.view_component" do + it "registers render.view_component" do expect(Appsignal::EventFormatter.registered?("render.view_component", klass)).to be_truthy - expect(Appsignal::EventFormatter.registered?("!render.view_component", - klass)).to be_truthy end describe "#format" do @@ -33,8 +31,6 @@ it "does not register the event formatter" do expect(Appsignal::EventFormatter.registered?("render.view_component", klass)).to be_falsy - expect(Appsignal::EventFormatter.registered?("!render.view_component", - klass)).to be_falsy end end end From 36d2442c99134f7a803584421cb56fe80b565d88 Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Fri, 7 Aug 2026 12:09:03 +0200 Subject: [PATCH 56/69] Let a formatter describe an event with no title An event formatter says what an event's title and body should be, so implementing `format` is mandatory and a class without it is refused at registration. A formatter can now say other things about an event, such as what kind of work it is, and some events have something to say that way but no title or body to give. The base class implements `format` and returns nothing, which a formatter with no title or body can inherit. A formatter that does not inherit from the base class still has to implement it, which is the case the check was written for. --- lib/appsignal/event_formatter.rb | 9 +++++++++ spec/lib/appsignal/event_formatter_spec.rb | 12 ++++++++++++ 2 files changed, 21 insertions(+) diff --git a/lib/appsignal/event_formatter.rb b/lib/appsignal/event_formatter.rb index 2f9167965..0da357fee 100644 --- a/lib/appsignal/event_formatter.rb +++ b/lib/appsignal/event_formatter.rb @@ -123,6 +123,15 @@ def logger end end + # The title and the body to show for an event, as an array. A formatter + # that is registered to describe an event in some other way, rather than to + # name it, does not have to implement this. + # + # @!visibility private + def format(_payload) + nil + end + # @return [Integer] # @api public DEFAULT = 0 diff --git a/spec/lib/appsignal/event_formatter_spec.rb b/spec/lib/appsignal/event_formatter_spec.rb index 7eb9e44b7..b59a36e9c 100644 --- a/spec/lib/appsignal/event_formatter_spec.rb +++ b/spec/lib/appsignal/event_formatter_spec.rb @@ -17,6 +17,9 @@ class MockFormatterDouble < MockFormatter class MissingFormatMockFormatter end +class UntitledMockFormatter < Appsignal::EventFormatter +end + class IncorrectFormatMockFormatter < Appsignal::EventFormatter def format end @@ -98,6 +101,15 @@ def format(_payload) end end + context "when the formatter does not implement format" do + it "registers the formatter, which gives the event no title or body" do + klass.register("mock.untitled", UntitledMockFormatter) + + expect(klass.registered?("mock.untitled")).to be_truthy + expect(klass.format("mock.untitled", {})).to be_nil + end + end + context "when there is an error initializing the formatter" do it "does not register the formatter and logs an error" do logs = capture_logs do From e16b525759b490b8c2b96656c69848d6e58e6ec0 Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Fri, 7 Aug 2026 12:13:36 +0200 Subject: [PATCH 57/69] Let a formatter declare its span kind Two places record an event that another library reported, and both looked up what kind of work the event was by name. The ActiveSupport::Notifications integration kept a list of three event names that are outgoing datastore calls, and the dry-monitor integration checked whether the event was called `sql`. The two said the same thing about the same kind of work in two files, because ROM reports its queries through dry-monitor while Rails reports its own through ActiveSupport::Notifications, and both lists had to be edited whenever a formatter was added. The event formatter already exists to give a named event its own treatment, so it declares the kind. Both integrations ask the registry, and both lists are gone. --- lib/appsignal/event_formatter.rb | 29 ++++++++++++++ .../active_record/sql_formatter.rb | 5 +++ .../elastic_search/search_formatter.rb | 5 +++ .../event_formatter/rom/sql_formatter.rb | 5 +++ .../event_formatter/sequel/sql_formatter.rb | 5 +++ .../active_support_notifications.rb | 26 ++----------- lib/appsignal/integrations/dry_monitor.rb | 11 +++--- .../active_record/sql_formatter_spec.rb | 6 +++ .../elastic_search/search_formatter_spec.rb | 6 +++ .../event_formatter/rom/sql_formatter_spec.rb | 6 +++ .../sequel/sql_formatter_spec.rb | 6 +++ spec/lib/appsignal/event_formatter_spec.rb | 38 +++++++++++++++++++ 12 files changed, 121 insertions(+), 27 deletions(-) diff --git a/lib/appsignal/event_formatter.rb b/lib/appsignal/event_formatter.rb index 0da357fee..bced21d58 100644 --- a/lib/appsignal/event_formatter.rb +++ b/lib/appsignal/event_formatter.rb @@ -102,8 +102,37 @@ def format(name, payload) formatter&.format(payload) end + # The OpenTelemetry span kind for an event, which its formatter can + # declare. An event with no formatter, or whose formatter declares + # nothing, has no kind of its own and falls back to the default. + # + # A formatter written against the documented interface only implements + # `format`, so ask whether this one answers to the method at all rather + # than assuming every formatter does. + # + # @!visibility private + def opentelemetry_kind(name) + formatter = formatter_for(name) + return unless formatter.respond_to?(:opentelemetry_kind) + + formatter.opentelemetry_kind + end + private + # The formatter registered for an event name. + # + # A formatter is registered under whatever key was given to `register`, + # which is a String for every formatter in this gem. An event can be + # instrumented under a Symbol name, so fall back to the String form of it. + # + # `format` does not do this, on purpose. It has always looked a name up + # exactly as given, so making it match a Symbol name would start giving a + # title to events that have never had one. + def formatter_for(name) + formatters[name] || formatters[name.to_s] + end + def initialize_formatter(name, formatter) format_method = formatter.instance_method(:format) if !format_method || format_method.arity != 1 diff --git a/lib/appsignal/event_formatter/active_record/sql_formatter.rb b/lib/appsignal/event_formatter/active_record/sql_formatter.rb index 9601b314e..884c4073b 100644 --- a/lib/appsignal/event_formatter/active_record/sql_formatter.rb +++ b/lib/appsignal/event_formatter/active_record/sql_formatter.rb @@ -5,6 +5,11 @@ class EventFormatter # @!visibility private module ActiveRecord class SqlFormatter < Appsignal::EventFormatter + # A query is an outgoing call to a datastore. + def opentelemetry_kind + :client + end + def format(payload) [payload[:name], payload[:sql], SQL_BODY_FORMAT] end diff --git a/lib/appsignal/event_formatter/elastic_search/search_formatter.rb b/lib/appsignal/event_formatter/elastic_search/search_formatter.rb index 68c2b5efc..817a6d2ee 100644 --- a/lib/appsignal/event_formatter/elastic_search/search_formatter.rb +++ b/lib/appsignal/event_formatter/elastic_search/search_formatter.rb @@ -5,6 +5,11 @@ class EventFormatter # @!visibility private module ElasticSearch class SearchFormatter < Appsignal::EventFormatter + # A query is an outgoing call to a datastore. + def opentelemetry_kind + :client + end + def format(payload) [ "#{payload[:name]}: #{payload[:klass]}", diff --git a/lib/appsignal/event_formatter/rom/sql_formatter.rb b/lib/appsignal/event_formatter/rom/sql_formatter.rb index 537aa2ea6..f81333793 100644 --- a/lib/appsignal/event_formatter/rom/sql_formatter.rb +++ b/lib/appsignal/event_formatter/rom/sql_formatter.rb @@ -5,6 +5,11 @@ class EventFormatter # @!visibility private module Rom class SqlFormatter < Appsignal::EventFormatter + # A query is an outgoing call to a datastore. + def opentelemetry_kind + :client + end + # dry-monitor reports an event under an id rather than a name, so the # first value here names the event. Naming it after ROM keeps every ROM # query in one group. diff --git a/lib/appsignal/event_formatter/sequel/sql_formatter.rb b/lib/appsignal/event_formatter/sequel/sql_formatter.rb index 8f06341d8..3f1c71b6f 100644 --- a/lib/appsignal/event_formatter/sequel/sql_formatter.rb +++ b/lib/appsignal/event_formatter/sequel/sql_formatter.rb @@ -10,6 +10,11 @@ module Sequel # formatter the sequel-rails events are recorded without the SQL query # that's being executed. class SqlFormatter < Appsignal::EventFormatter + # A query is an outgoing call to a datastore. + def opentelemetry_kind + :client + end + def format(payload) [payload[:name].to_s, payload[:sql], SQL_BODY_FORMAT] end diff --git a/lib/appsignal/integrations/active_support_notifications.rb b/lib/appsignal/integrations/active_support_notifications.rb index d3b0a9cff..ba609df6c 100644 --- a/lib/appsignal/integrations/active_support_notifications.rb +++ b/lib/appsignal/integrations/active_support_notifications.rb @@ -7,27 +7,6 @@ module ActiveSupportNotificationsIntegration class << self BANG = "!" - # 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. - # - # `sql.sequel` is emitted by the sequel-rails gem through - # ActiveSupport::Notifications, so it reaches us here rather than through - # the dedicated Sequel hook (which already tags its own query events as - # CLIENT). Including it keeps a Sequel query CLIENT regardless of which - # path records it. - # - # `search.elasticsearch` is a query sent to an Elasticsearch cluster, so - # it is a client call for the same reason a SQL query is. - CLIENT_EVENT_NAMES = [ - "sql.active_record", - "sql.sequel", - "search.elasticsearch" - ].freeze - # Template rendering has no semantic convention to describe it, so # these events say which group they belong to directly. The trace # timeline reads `appsignal.group` before it looks at any convention @@ -70,8 +49,11 @@ class << self def start_event(name) return unless record_event?(name) + # The event's formatter says what kind of work the event is, such as + # a SQL query being an outgoing call to a database. Span kind is + # immutable, so it has to be set here at event start. Appsignal::Transaction.current.start_event( - :opentelemetry_kind => CLIENT_EVENT_NAMES.include?(name.to_s) ? :client : nil, + :opentelemetry_kind => Appsignal::EventFormatter.opentelemetry_kind(name), :opentelemetry_scope => scope_for(name) ) end diff --git a/lib/appsignal/integrations/dry_monitor.rb b/lib/appsignal/integrations/dry_monitor.rb index 4f276454c..1432c1f72 100644 --- a/lib/appsignal/integrations/dry_monitor.rb +++ b/lib/appsignal/integrations/dry_monitor.rb @@ -4,18 +4,19 @@ 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. + # The event's formatter says what kind of work the event is, such as ROM + # reporting a SQL query as a dry-monitor `"sql"` event. Span kind is + # immutable, so it has to be set here at event start. def instrument(event_id, payload = {}, &block) + name = "#{event_id}.dry" + Appsignal::Transaction.current.start_event( - :opentelemetry_kind => event_id.to_s == "sql" ? :client : nil, + :opentelemetry_kind => Appsignal::EventFormatter.opentelemetry_kind(name), :opentelemetry_scope => ["appsignal-ruby/dry_monitor", Appsignal::VERSION] ) super ensure - name = "#{event_id}.dry" event_name, body, body_format = Appsignal::EventFormatter.format(name, payload) # dry-monitor reports an event under an id, such as `sql`, rather than diff --git a/spec/lib/appsignal/event_formatter/active_record/sql_formatter_spec.rb b/spec/lib/appsignal/event_formatter/active_record/sql_formatter_spec.rb index be7ab6089..287c4f70c 100644 --- a/spec/lib/appsignal/event_formatter/active_record/sql_formatter_spec.rb +++ b/spec/lib/appsignal/event_formatter/active_record/sql_formatter_spec.rb @@ -6,6 +6,12 @@ expect(Appsignal::EventFormatter.registered?("sql.active_record", klass)).to be_truthy end + describe "#opentelemetry_kind" do + subject { formatter.opentelemetry_kind } + + it { is_expected.to eq :client } + end + describe "#format" do let(:payload) do { diff --git a/spec/lib/appsignal/event_formatter/elastic_search/search_formatter_spec.rb b/spec/lib/appsignal/event_formatter/elastic_search/search_formatter_spec.rb index d3913e341..57a5ab95e 100644 --- a/spec/lib/appsignal/event_formatter/elastic_search/search_formatter_spec.rb +++ b/spec/lib/appsignal/event_formatter/elastic_search/search_formatter_spec.rb @@ -8,6 +8,12 @@ ).to be_truthy end + describe "#opentelemetry_kind" do + subject { formatter.opentelemetry_kind } + + it { is_expected.to eq :client } + end + describe "#format" do let(:payload) do { diff --git a/spec/lib/appsignal/event_formatter/rom/sql_formatter_spec.rb b/spec/lib/appsignal/event_formatter/rom/sql_formatter_spec.rb index cf512f163..c416b4622 100644 --- a/spec/lib/appsignal/event_formatter/rom/sql_formatter_spec.rb +++ b/spec/lib/appsignal/event_formatter/rom/sql_formatter_spec.rb @@ -8,6 +8,12 @@ expect(Appsignal::EventFormatter.registered?("sql.dry", klass)).to be_truthy end + describe "#opentelemetry_kind" do + subject { formatter.opentelemetry_kind } + + it { is_expected.to eq :client } + end + describe "#format" do subject { formatter.format(payload) } diff --git a/spec/lib/appsignal/event_formatter/sequel/sql_formatter_spec.rb b/spec/lib/appsignal/event_formatter/sequel/sql_formatter_spec.rb index db2cd78cf..7cd756bb3 100644 --- a/spec/lib/appsignal/event_formatter/sequel/sql_formatter_spec.rb +++ b/spec/lib/appsignal/event_formatter/sequel/sql_formatter_spec.rb @@ -6,6 +6,12 @@ expect(Appsignal::EventFormatter.registered?("sql.sequel", klass)).to be_truthy end + describe "#opentelemetry_kind" do + subject { formatter.opentelemetry_kind } + + it { is_expected.to eq :client } + end + describe "#format" do before do stub_const( diff --git a/spec/lib/appsignal/event_formatter_spec.rb b/spec/lib/appsignal/event_formatter_spec.rb index b59a36e9c..9f1090871 100644 --- a/spec/lib/appsignal/event_formatter_spec.rb +++ b/spec/lib/appsignal/event_formatter_spec.rb @@ -20,6 +20,12 @@ class MissingFormatMockFormatter class UntitledMockFormatter < Appsignal::EventFormatter end +class ClientMockFormatter < Appsignal::EventFormatter + def opentelemetry_kind + :client + end +end + class IncorrectFormatMockFormatter < Appsignal::EventFormatter def format end @@ -230,6 +236,38 @@ def format(_payload) end end + describe ".opentelemetry_kind" do + context "when the formatter declares a kind" do + it "returns it" do + klass.register("mock.client", ClientMockFormatter) + expect(klass.opentelemetry_kind("mock.client")).to eq(:client) + end + end + + context "when the formatter declares no kind" do + it "returns nil" do + klass.register("mock", MockFormatter) + expect(klass.opentelemetry_kind("mock")).to be_nil + end + end + + # An event can be instrumented under a Symbol name, while every formatter + # is registered under a String one. + context "when the event name is a Symbol" do + it "returns the kind the formatter registered under the String declares" do + klass.register("mock.symbol_kind", ClientMockFormatter) + + expect(klass.opentelemetry_kind(:"mock.symbol_kind")).to eq(:client) + end + end + + context "when no formatter with the name is registered" do + it "returns nil" do + expect(klass.opentelemetry_kind("nonsense")).to be_nil + end + end + end + describe ".format" do context "when no formatter with the name is registered" do it "returns nil" do From bf0b2654778c25179d226dd7f3ad87d3298f2ac7 Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Fri, 7 Aug 2026 12:21:28 +0200 Subject: [PATCH 58/69] Let a formatter describe its own span The ActiveSupport::Notifications integration held a table of OpenTelemetry attributes by event name, and a second lookup for the attributes it has to read out of an event's payload. Both described events belonging to Rails components the integration otherwise knows nothing about. Each formatter describes its own event now. The render events only have a title inside a Rails application, because the title is the template's path made relative to the application's root. Whether there is an application to take a root from is answered by the formatter as it formats an event, rather than by the code that registers it. Answering it at registration time was wrong, because AppSignal can be required before Rails is, and the answer was then wrong for as long as the process ran. --- lib/appsignal/event_formatter.rb | 12 ++++ .../action_view/render_formatter.rb | 56 +++++++++------ .../active_job/perform_formatter.rb | 35 ++++++++++ .../elastic_search/search_formatter.rb | 24 ++++++- .../view_component/render_formatter.rb | 27 ++++++-- .../active_support_notifications.rb | 68 +------------------ lib/appsignal/opentelemetry.rb | 1 + lib/appsignal/opentelemetry/rendering.rb | 29 ++++++++ .../action_view/render_formatter_spec.rb | 35 ++++++++-- .../active_job/perform_formatter_spec.rb | 45 ++++++++++++ .../elastic_search/search_formatter_spec.rb | 38 +++++++++++ .../view_component/render_formatter_spec.rb | 34 +++++++++- spec/lib/appsignal/event_formatter_spec.rb | 38 +++++++++++ 13 files changed, 341 insertions(+), 101 deletions(-) create mode 100644 lib/appsignal/event_formatter/active_job/perform_formatter.rb create mode 100644 lib/appsignal/opentelemetry/rendering.rb create mode 100644 spec/lib/appsignal/event_formatter/active_job/perform_formatter_spec.rb diff --git a/lib/appsignal/event_formatter.rb b/lib/appsignal/event_formatter.rb index bced21d58..c8167a090 100644 --- a/lib/appsignal/event_formatter.rb +++ b/lib/appsignal/event_formatter.rb @@ -118,6 +118,18 @@ def opentelemetry_kind(name) formatter.opentelemetry_kind end + # The OpenTelemetry attributes describing an event, which its formatter + # can build from the event's payload. An event with no formatter, or + # whose formatter describes nothing, gets no attributes of its own. + # + # @!visibility private + def opentelemetry_attributes(name, payload) + formatter = formatter_for(name) + return unless formatter.respond_to?(:opentelemetry_attributes) + + formatter.opentelemetry_attributes(payload) + end + private # The formatter registered for an event name. diff --git a/lib/appsignal/event_formatter/action_view/render_formatter.rb b/lib/appsignal/event_formatter/action_view/render_formatter.rb index 94dc29b9b..871512c27 100644 --- a/lib/appsignal/event_formatter/action_view/render_formatter.rb +++ b/lib/appsignal/event_formatter/action_view/render_formatter.rb @@ -7,13 +7,27 @@ module ActionView class RenderFormatter < Appsignal::EventFormatter BLANK = "" + def opentelemetry_attributes(_payload) + Appsignal::OpenTelemetry::Rendering.attributes + end + def format(payload) - return nil unless payload[:identifier] + # The title is the template's path made relative to the application's + # root, so a template rendered outside an application gets no title. + return unless payload[:identifier] && root_path [payload[:identifier].sub(root_path, BLANK), nil] end + # The application's root, which a template's path is made relative to. + # + # Whether there is an application is decided here, when the event is + # formatted, rather than when this file is loaded. AppSignal can be + # required before Rails is, and deciding it at load time would leave + # every template render in the application without a title. def root_path + return unless defined?(Rails) + @root_path ||= "#{Rails.root}/" end end @@ -21,24 +35,22 @@ def root_path end end -if defined?(Rails) - Appsignal::EventFormatter.register( - "render_partial.action_view", - Appsignal::EventFormatter::ActionView::RenderFormatter - ) - Appsignal::EventFormatter.register( - "render_template.action_view", - Appsignal::EventFormatter::ActionView::RenderFormatter - ) - # Action View reports the template's path for these two as well, so they are - # titled the same way. A collection reports the partial it rendered for each - # item, and a layout reports itself. - Appsignal::EventFormatter.register( - "render_collection.action_view", - Appsignal::EventFormatter::ActionView::RenderFormatter - ) - Appsignal::EventFormatter.register( - "render_layout.action_view", - Appsignal::EventFormatter::ActionView::RenderFormatter - ) -end +Appsignal::EventFormatter.register( + "render_partial.action_view", + Appsignal::EventFormatter::ActionView::RenderFormatter +) +Appsignal::EventFormatter.register( + "render_template.action_view", + Appsignal::EventFormatter::ActionView::RenderFormatter +) +# Action View reports the template's path for these two as well, so they are +# titled the same way. A collection reports the partial it rendered for each +# item, and a layout reports itself. +Appsignal::EventFormatter.register( + "render_collection.action_view", + Appsignal::EventFormatter::ActionView::RenderFormatter +) +Appsignal::EventFormatter.register( + "render_layout.action_view", + Appsignal::EventFormatter::ActionView::RenderFormatter +) diff --git a/lib/appsignal/event_formatter/active_job/perform_formatter.rb b/lib/appsignal/event_formatter/active_job/perform_formatter.rb new file mode 100644 index 000000000..125967b03 --- /dev/null +++ b/lib/appsignal/event_formatter/active_job/perform_formatter.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module Appsignal + class EventFormatter + # @!visibility private + module ActiveJob + # Active Job reports the job it is running as a `perform.active_job` + # notification. The job's own transaction already carries its title, so + # this formatter only describes the event as the work of performing a + # job. + class PerformFormatter < Appsignal::EventFormatter + PERFORM_ATTRIBUTES = + Appsignal::OpenTelemetry::Messaging.perform_attributes("active_job").freeze + + def opentelemetry_attributes(payload) + PERFORM_ATTRIBUTES.merge( + { "messaging.destination.name" => queue_name(payload) }.compact + ) + end + + # The queue the job being performed is on, which the notification + # carries as the job itself. + def queue_name(payload) + job = payload[:job] + job.queue_name if job.respond_to?(:queue_name) + end + end + end + end +end + +Appsignal::EventFormatter.register( + "perform.active_job", + Appsignal::EventFormatter::ActiveJob::PerformFormatter +) diff --git a/lib/appsignal/event_formatter/elastic_search/search_formatter.rb b/lib/appsignal/event_formatter/elastic_search/search_formatter.rb index 817a6d2ee..b89200ddd 100644 --- a/lib/appsignal/event_formatter/elastic_search/search_formatter.rb +++ b/lib/appsignal/event_formatter/elastic_search/search_formatter.rb @@ -5,11 +5,21 @@ class EventFormatter # @!visibility private module ElasticSearch class SearchFormatter < Appsignal::EventFormatter - # A query is an outgoing call to a datastore. + # A search is an outgoing call to an Elasticsearch cluster. def opentelemetry_kind :client end + def opentelemetry_attributes(payload) + { + "db.system.name" => "elasticsearch", + # This notification is only emitted for a search, so that is the + # operation every one of these spans describes. + "db.operation.name" => "search", + "db.collection.name" => search_index(payload) + }.compact + end + def format(payload) [ "#{payload[:name]}: #{payload[:klass]}", @@ -17,6 +27,18 @@ def format(payload) ] end + # The index a search ran against, which the notification carries in the + # search it describes. A search that names more than one index, or none + # at all, is left without this attribute rather than described with a + # value that is not an index name. + def search_index(payload) + search = payload[:search] + return unless search.respond_to?(:[]) + + index = search[:index] + index if index.is_a?(String) + end + def sanitized_search(search) return unless search.is_a?(Hash) diff --git a/lib/appsignal/event_formatter/view_component/render_formatter.rb b/lib/appsignal/event_formatter/view_component/render_formatter.rb index f3075b788..89d53253d 100644 --- a/lib/appsignal/event_formatter/view_component/render_formatter.rb +++ b/lib/appsignal/event_formatter/view_component/render_formatter.rb @@ -7,11 +7,28 @@ module ViewComponent class RenderFormatter < Appsignal::EventFormatter BLANK = "" + def opentelemetry_attributes(_payload) + Appsignal::OpenTelemetry::Rendering.attributes + end + def format(payload) + # The body is the component's path made relative to the application's + # root, so a component rendered outside an application gets no title + # and no body. + return unless root_path + [payload[:name], payload[:identifier].sub(root_path, BLANK)] end + # The application's root, which a component's path is made relative to. + # + # Whether there is an application is decided here, when the event is + # formatted, rather than when this file is loaded. AppSignal can be + # required before Rails is, and deciding it at load time would leave + # every component render in the application without a title. def root_path + return unless defined?(Rails) + @root_path ||= "#{Rails.root}/" end end @@ -19,9 +36,7 @@ def root_path end end -if defined?(Rails) - Appsignal::EventFormatter.register( - "render.view_component", - Appsignal::EventFormatter::ViewComponent::RenderFormatter - ) -end +Appsignal::EventFormatter.register( + "render.view_component", + Appsignal::EventFormatter::ViewComponent::RenderFormatter +) diff --git a/lib/appsignal/integrations/active_support_notifications.rb b/lib/appsignal/integrations/active_support_notifications.rb index ba609df6c..6108dacaa 100644 --- a/lib/appsignal/integrations/active_support_notifications.rb +++ b/lib/appsignal/integrations/active_support_notifications.rb @@ -7,35 +7,6 @@ module ActiveSupportNotificationsIntegration class << self BANG = "!" - # Template rendering has no semantic convention to describe it, so - # these events say which group they belong to directly. The trace - # timeline reads `appsignal.group` before it looks at any convention - # attribute, and "render" is the group it shows as "Templating". - RENDER_ATTRIBUTES = { "appsignal.group" => "render" }.freeze - - # OpenTelemetry attributes to add to an event's span, by event name. - # These name the kind of work an event represents, which is what the - # trace timeline reads to tell one kind of span from another. - # - # SQL events are not listed here: their span already gets - # `db.system.name` from the SQL body format, which also tells the - # collector to sanitize the query. - EVENT_ATTRIBUTES = { - "search.elasticsearch" => { - "db.system.name" => "elasticsearch", - # This notification is only emitted for a search, so that is the - # operation every one of these spans describes. - "db.operation.name" => "search" - }.freeze, - "perform.active_job" => - Appsignal::OpenTelemetry::Messaging.perform_attributes("active_job").freeze, - "render_template.action_view" => RENDER_ATTRIBUTES, - "render_partial.action_view" => RENDER_ATTRIBUTES, - "render_collection.action_view" => RENDER_ATTRIBUTES, - "render_layout.action_view" => RENDER_ATTRIBUTES, - "render.view_component" => RENDER_ATTRIBUTES - }.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 @@ -82,9 +53,9 @@ def finish_event(name, payload = {}) transaction = Appsignal::Transaction.current # Set while the event's span is still open, so the attributes land on # the event rather than on the transaction. - attributes = EVENT_ATTRIBUTES[name.to_s] - transaction.add_opentelemetry_attributes(attributes) if attributes - transaction.add_opentelemetry_attributes(payload_attributes(name, payload)) + transaction.add_opentelemetry_attributes( + Appsignal::EventFormatter.opentelemetry_attributes(name, payload) + ) record_error_type(transaction, payload) transaction.finish_event( name.to_s, @@ -94,39 +65,6 @@ def finish_event(name, payload = {}) ) end - # Attributes whose value has to be read from the event's payload, so they - # cannot live in the static map above. An event with nothing to read gets - # no attributes. - def payload_attributes(name, payload) - case name.to_s - when "search.elasticsearch" - { "db.collection.name" => search_index(payload) }.compact - when "perform.active_job" - { "messaging.destination.name" => job_queue_name(payload) }.compact - else - {} - end - end - - # The index a search ran against, which the notification carries in the - # search it describes. A search that names more than one index, or none - # at all, is left without this attribute rather than described with a - # value that is not an index name. - def search_index(payload) - search = payload[:search] - return unless search.respond_to?(:[]) - - index = search[:index] - index if index.is_a?(String) - end - - # The queue the job being performed is on, which the notification carries - # as the job itself. - def job_queue_name(payload) - job = payload[:job] - job.queue_name if job.respond_to?(:queue_name) - end - # Says what kind of failure ended the event, which the OpenTelemetry # semantic conventions ask for on a span whose operation failed. # diff --git a/lib/appsignal/opentelemetry.rb b/lib/appsignal/opentelemetry.rb index 8655c0c6a..b1b42fa64 100644 --- a/lib/appsignal/opentelemetry.rb +++ b/lib/appsignal/opentelemetry.rb @@ -8,6 +8,7 @@ require "appsignal/opentelemetry/http_response" require "appsignal/opentelemetry/http_server_request" require "appsignal/opentelemetry/messaging" +require "appsignal/opentelemetry/rendering" module Appsignal # @!visibility private diff --git a/lib/appsignal/opentelemetry/rendering.rb b/lib/appsignal/opentelemetry/rendering.rb new file mode 100644 index 000000000..e01b96dbd --- /dev/null +++ b/lib/appsignal/opentelemetry/rendering.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Appsignal + module OpenTelemetry + # @!visibility private + # + # Builds the OpenTelemetry attributes that describe an event as template + # rendering. + # + # Rendering a template has no semantic convention to describe it, so these + # events say which group they belong to directly. The trace timeline reads + # `appsignal.group` before it looks at any convention attribute, and + # "render" is the group it shows as "Templating". + module Rendering + GROUP_ATTRIBUTE = "appsignal.group" + GROUP = "render" + + ATTRIBUTES = { GROUP_ATTRIBUTE => GROUP }.freeze + + class << self + # The attributes describing an event as template rendering, as a Hash + # to pass to `add_opentelemetry_attributes`. + def attributes + ATTRIBUTES + end + end + end + end +end diff --git a/spec/lib/appsignal/event_formatter/action_view/render_formatter_spec.rb b/spec/lib/appsignal/event_formatter/action_view/render_formatter_spec.rb index 52c4590ae..1d016dc2f 100644 --- a/spec/lib/appsignal/event_formatter/action_view/render_formatter_spec.rb +++ b/spec/lib/appsignal/event_formatter/action_view/render_formatter_spec.rb @@ -19,6 +19,12 @@ klass)).to be_truthy end + describe "#opentelemetry_attributes" do + subject { formatter.opentelemetry_attributes({}) } + + it { is_expected.to eq("appsignal.group" => "render") } + end + describe "#root_path" do subject { formatter.root_path } @@ -53,15 +59,34 @@ end else context "when not in a Rails app" do - it "does not register the event formatter" do + let(:formatter) { klass.new } + + it "registers every event that renders a template" do expect(Appsignal::EventFormatter.registered?("render_partial.action_view", - klass)).to be_falsy + klass)).to be_truthy expect(Appsignal::EventFormatter.registered?("render_template.action_view", - klass)).to be_falsy + klass)).to be_truthy expect(Appsignal::EventFormatter.registered?("render_collection.action_view", - klass)).to be_falsy + klass)).to be_truthy expect(Appsignal::EventFormatter.registered?("render_layout.action_view", - klass)).to be_falsy + klass)).to be_truthy + end + + describe "#opentelemetry_attributes" do + subject { formatter.opentelemetry_attributes({}) } + + it "still says the event is template rendering" do + is_expected.to eq("appsignal.group" => "render") + end + end + + describe "#format" do + subject { formatter.format(payload) } + + let(:payload) { { :identifier => "/var/www/app/20130101/app/views/home/index/html.erb" } } + + # There is no application root to make the template's path relative to. + it { is_expected.to be_nil } end end end diff --git a/spec/lib/appsignal/event_formatter/active_job/perform_formatter_spec.rb b/spec/lib/appsignal/event_formatter/active_job/perform_formatter_spec.rb new file mode 100644 index 000000000..6d8c8ef51 --- /dev/null +++ b/spec/lib/appsignal/event_formatter/active_job/perform_formatter_spec.rb @@ -0,0 +1,45 @@ +describe Appsignal::EventFormatter::ActiveJob::PerformFormatter do + let(:klass) { described_class } + let(:formatter) { klass.new } + + it "registers perform.active_job" do + expect(Appsignal::EventFormatter.registered?("perform.active_job", klass)).to be_truthy + end + + describe "#opentelemetry_attributes" do + subject { formatter.opentelemetry_attributes(payload) } + + context "with a job that names its queue" do + let(:job) { double(:queue_name => "default") } + let(:payload) { { :job => job } } + + it "describes performing a job on that queue" do + is_expected.to eq( + "messaging.system" => "active_job", + "messaging.operation.name" => "perform", + "messaging.operation.type" => "process", + "messaging.destination.name" => "default" + ) + end + end + + context "without a job" do + let(:payload) { {} } + + it "describes performing a job without naming a queue" do + is_expected.to eq( + "messaging.system" => "active_job", + "messaging.operation.name" => "perform", + "messaging.operation.type" => "process" + ) + end + end + end + + describe "#format" do + subject { formatter.format(:job => double(:queue_name => "default")) } + + # The job's own transaction carries its title, so this event needs none. + it { is_expected.to be_nil } + end +end diff --git a/spec/lib/appsignal/event_formatter/elastic_search/search_formatter_spec.rb b/spec/lib/appsignal/event_formatter/elastic_search/search_formatter_spec.rb index 57a5ab95e..aa5f8ddcb 100644 --- a/spec/lib/appsignal/event_formatter/elastic_search/search_formatter_spec.rb +++ b/spec/lib/appsignal/event_formatter/elastic_search/search_formatter_spec.rb @@ -14,6 +14,44 @@ it { is_expected.to eq :client } end + describe "#opentelemetry_attributes" do + subject { formatter.opentelemetry_attributes(payload) } + + context "with a search naming one index" do + let(:payload) { { :search => { :index => "users" } } } + + it "names the index it searched" do + is_expected.to eq( + "db.system.name" => "elasticsearch", + "db.operation.name" => "search", + "db.collection.name" => "users" + ) + end + end + + context "with a search naming more than one index" do + let(:payload) { { :search => { :index => ["users", "accounts"] } } } + + it "names no index rather than a value that is not one" do + is_expected.to eq( + "db.system.name" => "elasticsearch", + "db.operation.name" => "search" + ) + end + end + + context "without a search" do + let(:payload) { {} } + + it "still describes the span as an Elasticsearch search" do + is_expected.to eq( + "db.system.name" => "elasticsearch", + "db.operation.name" => "search" + ) + end + end + end + describe "#format" do let(:payload) do { diff --git a/spec/lib/appsignal/event_formatter/view_component/render_formatter_spec.rb b/spec/lib/appsignal/event_formatter/view_component/render_formatter_spec.rb index fe0bda75a..a95afda74 100644 --- a/spec/lib/appsignal/event_formatter/view_component/render_formatter_spec.rb +++ b/spec/lib/appsignal/event_formatter/view_component/render_formatter_spec.rb @@ -11,6 +11,12 @@ klass)).to be_truthy end + describe "#opentelemetry_attributes" do + subject { formatter.opentelemetry_attributes({}) } + + it { is_expected.to eq("appsignal.group" => "render") } + end + describe "#format" do subject { formatter.format(payload) } @@ -28,9 +34,33 @@ end else context "when not in a Rails app" do - it "does not register the event formatter" do + let(:formatter) { klass.new } + + it "registers render.view_component" do expect(Appsignal::EventFormatter.registered?("render.view_component", - klass)).to be_falsy + klass)).to be_truthy + end + + describe "#opentelemetry_attributes" do + subject { formatter.opentelemetry_attributes({}) } + + it "still says the event is template rendering" do + is_expected.to eq("appsignal.group" => "render") + end + end + + describe "#format" do + subject { formatter.format(payload) } + + let(:payload) do + { + :name => "WhateverComponent", + :identifier => "/var/www/app/20130101/app/components/whatever_component.rb" + } + end + + # There is no application root to make the component's path relative to. + it { is_expected.to be_nil } end end end diff --git a/spec/lib/appsignal/event_formatter_spec.rb b/spec/lib/appsignal/event_formatter_spec.rb index 9f1090871..f1d63f405 100644 --- a/spec/lib/appsignal/event_formatter_spec.rb +++ b/spec/lib/appsignal/event_formatter_spec.rb @@ -26,6 +26,12 @@ def opentelemetry_kind end end +class DescribedMockFormatter < Appsignal::EventFormatter + def opentelemetry_attributes(payload) + { "mock.attribute" => payload[:value] } + end +end + class IncorrectFormatMockFormatter < Appsignal::EventFormatter def format end @@ -268,6 +274,38 @@ def format(_payload) end end + describe ".opentelemetry_attributes" do + context "when the formatter describes the event" do + it "returns the attributes it reads from the payload" do + klass.register("mock.described", DescribedMockFormatter) + expect(klass.opentelemetry_attributes("mock.described", :value => "read")) + .to eq("mock.attribute" => "read") + end + end + + context "when the formatter describes nothing" do + it "returns nil" do + klass.register("mock", MockFormatter) + expect(klass.opentelemetry_attributes("mock", {})).to be_nil + end + end + + context "when the event name is a Symbol" do + it "returns what the formatter registered under the String describes" do + klass.register("mock.symbol_described", DescribedMockFormatter) + + expect(klass.opentelemetry_attributes(:"mock.symbol_described", :value => "read")) + .to eq("mock.attribute" => "read") + end + end + + context "when no formatter with the name is registered" do + it "returns nil" do + expect(klass.opentelemetry_attributes("nonsense", {})).to be_nil + end + end + end + describe ".format" do context "when no formatter with the name is registered" do it "returns nil" do From efa753699c3063b12478ff09ec2b366fb3a7bf4b Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Fri, 7 Aug 2026 12:29:16 +0200 Subject: [PATCH 59/69] Let a formatter defer to another integration Two events are recorded by a dedicated AppSignal integration that describes them better than the generic paths can. The ActiveSupport::Notifications integration held their names in a list, so it had to know about two integrations it has nothing else to do with. They are claimed through the registry now, the same way every other treatment of a named event is, and both generic paths ask it. Each event is claimed by the integration that records it, as that integration installs. The claim exists to stop the same work being recorded twice, so it lasts exactly as long as the integration that would otherwise record it twice. An application that turns Active Job or Faraday instrumentation off now gets the notification that library reports itself, where before it got no event at all. --- ...notifications-for-disabled-integrations.md | 18 ++++++++ lib/appsignal/event_formatter.rb | 13 ++++++ .../event_formatter/recorded_elsewhere.rb | 17 +++++++ lib/appsignal/hooks/active_job.rb | 14 ++++++ lib/appsignal/hooks/faraday.rb | 12 +++++ .../active_support_notifications.rb | 20 +++------ lib/appsignal/integrations/dry_monitor.rb | 39 +++++++++------- .../recorded_elsewhere_spec.rb | 26 +++++++++++ spec/lib/appsignal/event_formatter_spec.rb | 36 +++++++++++++++ .../instrument_shared_examples.rb | 22 ++++++++- spec/lib/appsignal/hooks/activejob_spec.rb | 34 ++++++++++++++ spec/lib/appsignal/hooks/dry_monitor_spec.rb | 45 +++++++++++++++++++ .../appsignal/integrations/faraday_spec.rb | 30 +++++++++++++ 13 files changed, 292 insertions(+), 34 deletions(-) create mode 100644 .changesets/report-notifications-for-disabled-integrations.md create mode 100644 lib/appsignal/event_formatter/recorded_elsewhere.rb create mode 100644 spec/lib/appsignal/event_formatter/recorded_elsewhere_spec.rb diff --git a/.changesets/report-notifications-for-disabled-integrations.md b/.changesets/report-notifications-for-disabled-integrations.md new file mode 100644 index 000000000..7c46b4648 --- /dev/null +++ b/.changesets/report-notifications-for-disabled-integrations.md @@ -0,0 +1,18 @@ +--- +bump: patch +type: change +--- + +Report Active Job's and Faraday's own instrumentation events again when +AppSignal's instrumentation for those libraries is turned off. + +AppSignal records an enqueued Active Job, and a Faraday request, as events of +its own. Active Job and Faraday each report the same work through +ActiveSupport::Notifications as well, so since version 4.9.0 AppSignal has +ignored those notifications to avoid recording the same work twice. + +It ignored them even when there was nothing to record twice. An application +that sets `instrument_active_job` or `instrument_faraday` to `false` got no +event for that work at all, not even the one the library reported itself. Those +notifications are now ignored only while AppSignal is recording the work +itself. diff --git a/lib/appsignal/event_formatter.rb b/lib/appsignal/event_formatter.rb index c8167a090..a09a93010 100644 --- a/lib/appsignal/event_formatter.rb +++ b/lib/appsignal/event_formatter.rb @@ -130,6 +130,19 @@ def opentelemetry_attributes(name, payload) formatter.opentelemetry_attributes(payload) end + # Whether the generic instrumentation paths should record an event, which + # its formatter can answer. An event a dedicated integration already + # records says no, so that it is not recorded a second time. An event + # with no formatter, or whose formatter says nothing, is recorded. + # + # @!visibility private + def record?(name) + formatter = formatter_for(name) + return true unless formatter.respond_to?(:record?) + + formatter.record? + end + private # The formatter registered for an event name. diff --git a/lib/appsignal/event_formatter/recorded_elsewhere.rb b/lib/appsignal/event_formatter/recorded_elsewhere.rb new file mode 100644 index 000000000..fcf191f85 --- /dev/null +++ b/lib/appsignal/event_formatter/recorded_elsewhere.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Appsignal + class EventFormatter + # Registered for an event that a dedicated AppSignal integration already + # records with richer semantics. The generic instrumentation paths ask the + # registry whether to record an event, so registering this for an event + # name is how that integration claims it. + # + # @!visibility private + class RecordedElsewhere < Appsignal::EventFormatter + def record? + false + end + end + end +end diff --git a/lib/appsignal/hooks/active_job.rb b/lib/appsignal/hooks/active_job.rb index b909b1746..66844e271 100644 --- a/lib/appsignal/hooks/active_job.rb +++ b/lib/appsignal/hooks/active_job.rb @@ -27,6 +27,20 @@ def dependencies_present? end def install + # This integration records the enqueue itself, as a producer event that + # also injects trace context, and Active Job's own + # `enqueue.active_job` notification fires nested inside it. Claim the + # event so that the generic notification paths leave it alone. + # + # Claimed on install, so that the event is only claimed while this + # integration is recording it. With Active Job instrumentation turned + # off there is nothing for the notification to duplicate, so it is left + # to be recorded like any other. + Appsignal::EventFormatter.register( + "enqueue.active_job", + Appsignal::EventFormatter::RecordedElsewhere + ) + ActiveSupport.on_load(:active_job) do ::ActiveJob::Base .extend ::Appsignal::Hooks::ActiveJobHook::ActiveJobClassInstrumentation diff --git a/lib/appsignal/hooks/faraday.rb b/lib/appsignal/hooks/faraday.rb index fa5b4ead3..9503c9d65 100644 --- a/lib/appsignal/hooks/faraday.rb +++ b/lib/appsignal/hooks/faraday.rb @@ -14,6 +14,18 @@ def install require "appsignal/integrations/faraday" ::Faraday::RackBuilder.prepend(Appsignal::Integrations::FaradayRackBuilderPatch) + # This integration records the request itself, so Faraday's own + # instrumentation middleware would report the same work again as a + # `request.faraday` notification. Claim it so the generic notification + # paths leave it alone. + # + # Claimed on install, so the claim lasts exactly as long as this + # integration is recording the work. + Appsignal::EventFormatter.register( + "request.faraday", + Appsignal::EventFormatter::RecordedElsewhere + ) + Appsignal::Environment.report_enabled("faraday") end end diff --git a/lib/appsignal/integrations/active_support_notifications.rb b/lib/appsignal/integrations/active_support_notifications.rb index 6108dacaa..f7de40c7c 100644 --- a/lib/appsignal/integrations/active_support_notifications.rb +++ b/lib/appsignal/integrations/active_support_notifications.rb @@ -7,16 +7,6 @@ module ActiveSupportNotificationsIntegration class << self BANG = "!" - # 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) @@ -82,12 +72,12 @@ def record_error_type(transaction, payload) ) end - # Events starting with a bang are internal to Rails; suppressed events - # are recorded by a dedicated integration instead. Both `start_event` - # and `finish_event` gate on this so the event stack stays balanced. + # Events starting with a bang are internal to Rails. An event that the + # registry says a dedicated integration records is not recorded again + # here. Both `start_event` and `finish_event` gate on this so the event + # stack stays balanced. def record_event?(name) - name = name.to_s - name[0] != BANG && !SUPPRESSED_EVENT_NAMES.include?(name) + name.to_s[0] != BANG && Appsignal::EventFormatter.record?(name) end end diff --git a/lib/appsignal/integrations/dry_monitor.rb b/lib/appsignal/integrations/dry_monitor.rb index 1432c1f72..0b5a8d324 100644 --- a/lib/appsignal/integrations/dry_monitor.rb +++ b/lib/appsignal/integrations/dry_monitor.rb @@ -9,26 +9,31 @@ module DryMonitorIntegration # immutable, so it has to be set here at event start. def instrument(event_id, payload = {}, &block) name = "#{event_id}.dry" + # An event a dedicated integration already records is not recorded a + # second time here. + return super unless Appsignal::EventFormatter.record?(name) - Appsignal::Transaction.current.start_event( - :opentelemetry_kind => Appsignal::EventFormatter.opentelemetry_kind(name), - :opentelemetry_scope => ["appsignal-ruby/dry_monitor", Appsignal::VERSION] - ) + begin + Appsignal::Transaction.current.start_event( + :opentelemetry_kind => Appsignal::EventFormatter.opentelemetry_kind(name), + :opentelemetry_scope => ["appsignal-ruby/dry_monitor", Appsignal::VERSION] + ) - super - ensure - event_name, body, body_format = Appsignal::EventFormatter.format(name, payload) + super + ensure + event_name, body, body_format = Appsignal::EventFormatter.format(name, payload) - # dry-monitor reports an event under an id, such as `sql`, rather than - # a name. A formatter names the event it knows about, and an event - # without one is named after its id in the dry-monitor group. Either - # way the name has a group, which is what an event is listed under. - Appsignal::Transaction.current.finish_event( - event_name || name, - nil, - body, - body_format - ) + # dry-monitor reports an event under an id, such as `sql`, rather than + # a name. A formatter names the event it knows about, and an event + # without one is named after its id in the dry-monitor group. Either + # way the name has a group, which is what an event is listed under. + Appsignal::Transaction.current.finish_event( + event_name || name, + nil, + body, + body_format + ) + end end end end diff --git a/spec/lib/appsignal/event_formatter/recorded_elsewhere_spec.rb b/spec/lib/appsignal/event_formatter/recorded_elsewhere_spec.rb new file mode 100644 index 000000000..25a9736a4 --- /dev/null +++ b/spec/lib/appsignal/event_formatter/recorded_elsewhere_spec.rb @@ -0,0 +1,26 @@ +describe Appsignal::EventFormatter::RecordedElsewhere do + let(:klass) { described_class } + let(:formatter) { klass.new } + + # Which events are claimed with this, and when, is up to the integration that + # records each of them. Those are covered by that integration's own specs. + describe "#record?" do + subject { formatter.record? } + + it { is_expected.to be(false) } + end + + it "keeps the generic paths from recording the event it is registered for" do + Appsignal::EventFormatter.register("mock.claimed", klass) + + expect(Appsignal::EventFormatter.record?("mock.claimed")).to be(false) + ensure + Appsignal::EventFormatter.unregister("mock.claimed", klass) + end + + describe "#format" do + subject { formatter.format(:name => "claimed") } + + it { is_expected.to be_nil } + end +end diff --git a/spec/lib/appsignal/event_formatter_spec.rb b/spec/lib/appsignal/event_formatter_spec.rb index f1d63f405..319659f88 100644 --- a/spec/lib/appsignal/event_formatter_spec.rb +++ b/spec/lib/appsignal/event_formatter_spec.rb @@ -32,6 +32,12 @@ def opentelemetry_attributes(payload) end end +class UnrecordedMockFormatter < Appsignal::EventFormatter + def record? + false + end +end + class IncorrectFormatMockFormatter < Appsignal::EventFormatter def format end @@ -306,6 +312,36 @@ def format(_payload) end end + describe ".record?" do + context "when the formatter says another integration records the event" do + it "returns false" do + klass.register("mock.elsewhere", UnrecordedMockFormatter) + expect(klass.record?("mock.elsewhere")).to be(false) + end + end + + context "when the formatter says nothing about it" do + it "returns true" do + klass.register("mock", MockFormatter) + expect(klass.record?("mock")).to be(true) + end + end + + context "when the event name is a Symbol" do + it "returns what the formatter registered under the String says" do + klass.register("mock.symbol_claimed", UnrecordedMockFormatter) + + expect(klass.record?(:"mock.symbol_claimed")).to be(false) + end + end + + context "when no formatter with the name is registered" do + it "returns true" do + expect(klass.record?("nonsense")).to be(true) + end + end + end + describe ".format" do context "when no formatter with the name is registered" do it "returns nil" do diff --git a/spec/lib/appsignal/hooks/active_support_notifications/instrument_shared_examples.rb b/spec/lib/appsignal/hooks/active_support_notifications/instrument_shared_examples.rb index a11b486e9..02f41572b 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 @@ -344,9 +344,27 @@ def perform end end - describe "a suppressed event, recorded by a dedicated integration" do + describe "an event claimed by a dedicated integration" do + # An integration claims its event as it installs, so which events are + # claimed depends on which integrations are running. Claim one here, so + # that this covers the generic path leaving a claimed event alone whichever + # integrations this gemfile happens to have. + before do + Appsignal::EventFormatter.register( + "claimed.example", + Appsignal::EventFormatter::RecordedElsewhere + ) + end + + after do + Appsignal::EventFormatter.unregister( + "claimed.example", + Appsignal::EventFormatter::RecordedElsewhere + ) + end + def perform - as.instrument("request.faraday", :method => :get) { "value" } + as.instrument("claimed.example", :method => :get) { "value" } end it "in agent mode", :agent_mode do diff --git a/spec/lib/appsignal/hooks/activejob_spec.rb b/spec/lib/appsignal/hooks/activejob_spec.rb index 7be7510f8..664aaa3a2 100644 --- a/spec/lib/appsignal/hooks/activejob_spec.rb +++ b/spec/lib/appsignal/hooks/activejob_spec.rb @@ -36,6 +36,40 @@ path, _line_number = ActiveJob::Base.method(:execute).source_location expect(path).to end_with("/lib/appsignal/hooks/active_job.rb") end + + context "when claiming the enqueue.active_job event" do + # The event formatter registry is shared by the whole test suite, so + # put back whatever this example changes. + around do |example| + formatters = Appsignal::EventFormatter.formatters.dup + formatter_classes = Appsignal::EventFormatter.formatter_classes.dup + example.run + ensure + Appsignal::EventFormatter.formatters.replace(formatters) + Appsignal::EventFormatter.formatter_classes.replace(formatter_classes) + end + + before do + Appsignal::EventFormatter.unregister( + "enqueue.active_job", + Appsignal::EventFormatter::RecordedElsewhere + ) + end + + # This integration records the enqueue itself, so the generic + # notification paths must not record it a second time. Installing is + # what claims it, and this hook only installs when Active Job + # instrumentation is enabled, which the tests above cover. With it + # disabled nothing records the enqueue twice, so the notification is + # left to be recorded like any other. + it "claims the event" do + expect(Appsignal::EventFormatter.record?("enqueue.active_job")).to be(true) + + described_class.new.install + + expect(Appsignal::EventFormatter.record?("enqueue.active_job")).to be(false) + end + end end end diff --git a/spec/lib/appsignal/hooks/dry_monitor_spec.rb b/spec/lib/appsignal/hooks/dry_monitor_spec.rb index 0b5f4977c..7652f21b6 100644 --- a/spec/lib/appsignal/hooks/dry_monitor_spec.rb +++ b/spec/lib/appsignal/hooks/dry_monitor_spec.rb @@ -84,6 +84,51 @@ def perform end end + describe "an event that another integration records" do + let(:event_id) { :claimed } + let(:payload) { { :name => "claimed" } } + + before do + Appsignal::EventFormatter.register( + "claimed.dry", + Appsignal::EventFormatter::RecordedElsewhere + ) + end + + after do + Appsignal::EventFormatter.unregister( + "claimed.dry", + Appsignal::EventFormatter::RecordedElsewhere + ) + end + + def perform + notifications.instrument(event_id, payload) { "block value" } + end + + it "in agent mode", :agent_mode do + start_agent + transaction = http_request_transaction + set_current_transaction(transaction) + + expect(perform).to eq("block value") + + expect(transaction.to_h["events"]).to be_empty + end + + it "in collector mode", :collector_mode do + start_collector_agent + transaction = http_request_transaction + set_current_transaction(transaction) + + expect(perform).to eq("block value") + + Appsignal::Transaction.complete_current! + + expect(event_spans).to be_empty + end + end + describe "an unregistered formatter event" do let(:event_id) { :foo } let(:payload) { { :name => "foo" } } diff --git a/spec/lib/appsignal/integrations/faraday_spec.rb b/spec/lib/appsignal/integrations/faraday_spec.rb index d7ca79c15..b2cade038 100644 --- a/spec/lib/appsignal/integrations/faraday_spec.rb +++ b/spec/lib/appsignal/integrations/faraday_spec.rb @@ -10,6 +10,36 @@ describe "Faraday integration" do before { Appsignal::Hooks::FaradayHook.new.install } + describe "claiming the request.faraday event" do + # The event formatter registry is shared by the whole test suite, so put + # back whatever this example changes. + around do |example| + formatters = Appsignal::EventFormatter.formatters.dup + formatter_classes = Appsignal::EventFormatter.formatter_classes.dup + example.run + ensure + Appsignal::EventFormatter.formatters.replace(formatters) + Appsignal::EventFormatter.formatter_classes.replace(formatter_classes) + end + + # This integration records the request itself, so Faraday's own + # notification must not record it a second time. Installing is what + # claims it, and this hook only installs when Faraday instrumentation is + # enabled. With it disabled nothing records the request twice, so the + # notification is left to be recorded like any other. + it "is claimed on install" do + Appsignal::EventFormatter.unregister( + "request.faraday", + Appsignal::EventFormatter::RecordedElsewhere + ) + expect(Appsignal::EventFormatter.record?("request.faraday")).to be(true) + + Appsignal::Hooks::FaradayHook.new.install + + expect(Appsignal::EventFormatter.record?("request.faraday")).to be(false) + end + end + # The common case: the default adapter is Net::HTTP, which AppSignal also # instruments. Faraday suppresses it, so the request is recorded once -- as # the `request.faraday` event, which also writes the `traceparent`. From 9ed4c09010df94348514dd7fa1d4577298011098 Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Fri, 7 Aug 2026 17:00:42 +0200 Subject: [PATCH 60/69] Let a formatter declare its instrumentation scope The instrumentation scope names the library an event was recorded for. Both paths that record an event another library reported derived it from where the event arrived, which is right until the two differ. They differ for ROM, which reports its queries over dry-monitor. That is a notification bus rather than something that queries a database, so every ROM query was attributed to dry-monitor, which says nothing about what the query was. A formatter can name the library, and both paths use that when there is one. Anything that declares nothing keeps the scope its path derives. --- lib/appsignal/event_formatter.rb | 15 +++++++ .../event_formatter/rom/sql_formatter.rb | 7 ++++ .../active_support_notifications.rb | 11 ++++-- lib/appsignal/integrations/dry_monitor.rb | 13 +++++-- .../event_formatter/rom/sql_formatter_spec.rb | 8 ++++ spec/lib/appsignal/event_formatter_spec.rb | 39 +++++++++++++++++++ spec/lib/appsignal/hooks/dry_monitor_spec.rb | 7 +++- 7 files changed, 93 insertions(+), 7 deletions(-) diff --git a/lib/appsignal/event_formatter.rb b/lib/appsignal/event_formatter.rb index a09a93010..024d0bb6c 100644 --- a/lib/appsignal/event_formatter.rb +++ b/lib/appsignal/event_formatter.rb @@ -130,6 +130,21 @@ def opentelemetry_attributes(name, payload) formatter.opentelemetry_attributes(payload) end + # The OpenTelemetry instrumentation scope for an event, which its + # formatter can declare. It names the library the instrumentation is for, + # which is not always the library the event arrived through. + # + # An event with no formatter, or whose formatter declares nothing, is + # left to the scope the recording path derives for it. + # + # @!visibility private + def opentelemetry_scope(name) + formatter = formatter_for(name) + return unless formatter.respond_to?(:opentelemetry_scope) + + formatter.opentelemetry_scope + end + # Whether the generic instrumentation paths should record an event, which # its formatter can answer. An event a dedicated integration already # records says no, so that it is not recorded a second time. An event diff --git a/lib/appsignal/event_formatter/rom/sql_formatter.rb b/lib/appsignal/event_formatter/rom/sql_formatter.rb index f81333793..8e06147bb 100644 --- a/lib/appsignal/event_formatter/rom/sql_formatter.rb +++ b/lib/appsignal/event_formatter/rom/sql_formatter.rb @@ -5,6 +5,13 @@ class EventFormatter # @!visibility private module Rom class SqlFormatter < Appsignal::EventFormatter + # These events arrive over dry-monitor, which is a notification bus + # rather than a library that queries a database. ROM is what emits + # them, so that is what the scope names. + def opentelemetry_scope + ["appsignal-ruby/rom", Appsignal::VERSION] + end + # A query is an outgoing call to a datastore. def opentelemetry_kind :client diff --git a/lib/appsignal/integrations/active_support_notifications.rb b/lib/appsignal/integrations/active_support_notifications.rb index f7de40c7c..c03cc1e62 100644 --- a/lib/appsignal/integrations/active_support_notifications.rb +++ b/lib/appsignal/integrations/active_support_notifications.rb @@ -11,11 +11,16 @@ def start_event(name) return unless record_event?(name) # The event's formatter says what kind of work the event is, such as - # a SQL query being an outgoing call to a database. Span kind is - # immutable, so it has to be set here at event start. + # a SQL query being an outgoing call to a database, and can name the + # library the instrumentation is for. Both are immutable once the + # span exists, so they have to be set here at event start. + # + # A formatter that names no library leaves the scope to be derived + # from the event name, which is right for everything Rails reports. Appsignal::Transaction.current.start_event( :opentelemetry_kind => Appsignal::EventFormatter.opentelemetry_kind(name), - :opentelemetry_scope => scope_for(name) + :opentelemetry_scope => + Appsignal::EventFormatter.opentelemetry_scope(name) || scope_for(name) ) end diff --git a/lib/appsignal/integrations/dry_monitor.rb b/lib/appsignal/integrations/dry_monitor.rb index 0b5a8d324..d0f05eaa9 100644 --- a/lib/appsignal/integrations/dry_monitor.rb +++ b/lib/appsignal/integrations/dry_monitor.rb @@ -5,8 +5,13 @@ module Integrations # @!visibility private module DryMonitorIntegration # The event's formatter says what kind of work the event is, such as ROM - # reporting a SQL query as a dry-monitor `"sql"` event. Span kind is - # immutable, so it has to be set here at event start. + # reporting a SQL query as a dry-monitor `"sql"` event, and which library + # the instrumentation is for. Both are immutable once the span exists, so + # they have to be set here at event start. + # + # dry-monitor is a notification bus, so an event arriving over it is not + # necessarily dry-monitor's own work. A formatter that knows better says + # so; anything else is attributed to dry-monitor. def instrument(event_id, payload = {}, &block) name = "#{event_id}.dry" # An event a dedicated integration already records is not recorded a @@ -16,7 +21,9 @@ def instrument(event_id, payload = {}, &block) begin Appsignal::Transaction.current.start_event( :opentelemetry_kind => Appsignal::EventFormatter.opentelemetry_kind(name), - :opentelemetry_scope => ["appsignal-ruby/dry_monitor", Appsignal::VERSION] + :opentelemetry_scope => + Appsignal::EventFormatter.opentelemetry_scope(name) || + ["appsignal-ruby/dry_monitor", Appsignal::VERSION] ) super diff --git a/spec/lib/appsignal/event_formatter/rom/sql_formatter_spec.rb b/spec/lib/appsignal/event_formatter/rom/sql_formatter_spec.rb index c416b4622..6677dbf9c 100644 --- a/spec/lib/appsignal/event_formatter/rom/sql_formatter_spec.rb +++ b/spec/lib/appsignal/event_formatter/rom/sql_formatter_spec.rb @@ -14,6 +14,14 @@ it { is_expected.to eq :client } end + describe "#opentelemetry_scope" do + subject { formatter.opentelemetry_scope } + + # These events arrive over dry-monitor, but ROM is what emits them, and the + # scope names the library the instrumentation is for. + it { is_expected.to eq ["appsignal-ruby/rom", Appsignal::VERSION] } + end + describe "#format" do subject { formatter.format(payload) } diff --git a/spec/lib/appsignal/event_formatter_spec.rb b/spec/lib/appsignal/event_formatter_spec.rb index 319659f88..ae477fd5c 100644 --- a/spec/lib/appsignal/event_formatter_spec.rb +++ b/spec/lib/appsignal/event_formatter_spec.rb @@ -38,6 +38,12 @@ def record? end end +class ScopedMockFormatter < Appsignal::EventFormatter + def opentelemetry_scope + ["appsignal-ruby/mock", "1.2.3"] + end +end + class IncorrectFormatMockFormatter < Appsignal::EventFormatter def format end @@ -312,6 +318,39 @@ def format(_payload) end end + describe ".opentelemetry_scope" do + context "when the formatter declares a scope" do + it "returns it" do + klass.register("mock.scoped", ScopedMockFormatter) + + expect(klass.opentelemetry_scope("mock.scoped")).to eq(["appsignal-ruby/mock", "1.2.3"]) + end + end + + context "when the formatter declares no scope" do + it "returns nil" do + klass.register("mock", MockFormatter) + + expect(klass.opentelemetry_scope("mock")).to be_nil + end + end + + context "when the event name is a Symbol" do + it "returns what the formatter registered under the String declares" do + klass.register("mock.symbol_scoped", ScopedMockFormatter) + + expect(klass.opentelemetry_scope(:"mock.symbol_scoped")) + .to eq(["appsignal-ruby/mock", "1.2.3"]) + end + end + + context "when no formatter with the name is registered" do + it "returns nil" do + expect(klass.opentelemetry_scope("nonsense")).to be_nil + end + end + end + describe ".record?" do context "when the formatter says another integration records the event" do it "returns false" do diff --git a/spec/lib/appsignal/hooks/dry_monitor_spec.rb b/spec/lib/appsignal/hooks/dry_monitor_spec.rb index 7652f21b6..aec6985ea 100644 --- a/spec/lib/appsignal/hooks/dry_monitor_spec.rb +++ b/spec/lib/appsignal/hooks/dry_monitor_spec.rb @@ -79,7 +79,9 @@ def perform expect(attrs["db.query.text"]).to eq("SELECT * FROM users") expect(attrs["db.system.name"]).to eq("other_sql") expect(event_category(span)).to eq("query.rom") - expect(scope_of(span)).to eq(["appsignal-ruby/dry_monitor", Appsignal::VERSION]) + # ROM emits this event, so it is attributed to ROM rather than to the + # bus it arrived over. + expect(scope_of(span)).to eq(["appsignal-ruby/rom", Appsignal::VERSION]) expect(attrs).not_to have_key("appsignal.body") end end @@ -164,6 +166,9 @@ def perform expect(span.parent_span_id).to eq(root_span.span_id) # A non-SQL dry event is not an outgoing call, so it keeps the default kind. expect(span.kind).to eq(:internal) + # No formatter names a library for this event, so it is attributed to + # the bus it arrived over. + expect(scope_of(span)).to eq(["appsignal-ruby/dry_monitor", Appsignal::VERSION]) attrs = span.attributes expect(event_category(span)).to eq("foo.dry") expect(attrs).not_to have_key("appsignal.body") From 9076862677444c7772e6f8fbe1e3d5a646a4849d Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Tue, 11 Aug 2026 16:25:17 +0200 Subject: [PATCH 61/69] Trim the unreleased changesets The changesets explained how each change works and why the old behaviour was wrong. A changelog entry is read by someone deciding whether it is about them, so it needs the symptom, the new behaviour and anything they have to act on, not the mechanism. The collector mode entry also said that no AppSignal data flows through the OpenTelemetry SDK yet, which will not be true when this ships. --- .changesets/add-collector-attribute-options.md | 6 +++--- .changesets/add-collector-mode.md | 15 ++------------- ...ort-notifications-for-disabled-integrations.md | 14 +------------- 3 files changed, 6 insertions(+), 29 deletions(-) diff --git a/.changesets/add-collector-attribute-options.md b/.changesets/add-collector-attribute-options.md index 3e3c0c0ba..e1caa6ae6 100644 --- a/.changesets/add-collector-attribute-options.md +++ b/.changesets/add-collector-attribute-options.md @@ -3,8 +3,8 @@ 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. +Add configuration options that map to OpenTelemetry resource attributes in collector mode: `service_name`, `filter_attributes`, `filter_function_parameters`, `filter_request_query_parameters`, `filter_request_payload`, `response_headers`, `send_function_parameters`, `send_request_query_parameters` and `send_request_payload`. -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. +In collector mode, existing options are passed to the collector as resource attributes as well: `name`, environment, `hostname`, `revision`, `ignore_actions`, `ignore_errors`, `ignore_namespaces`, `request_headers`, `filter_session_data` and `send_session_data`. -Setting any of these options without `collector_endpoint`, or `filter_parameters`/`filter_metadata`/`send_params` with `collector_endpoint`, now logs a warning at startup. +Setting any of these without `collector_endpoint`, or setting `filter_parameters`, `filter_metadata` or `send_params` with it, logs a warning at startup. diff --git a/.changesets/add-collector-mode.md b/.changesets/add-collector-mode.md index 96148e2ab..a3a3c01b1 100644 --- a/.changesets/add-collector-mode.md +++ b/.changesets/add-collector-mode.md @@ -3,17 +3,6 @@ bump: major 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. +Add a `collector_endpoint` configuration option (`APPSIGNAL_COLLECTOR_ENDPOINT` environment variable) that puts the integration in _collector mode_. In collector mode AppSignal reports traces, metrics and logs to an AppSignal Collector, over OTLP/HTTP. -Collector mode requires Ruby 3.1 or newer and the OpenTelemetry gems, which are 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. +Collector mode requires Ruby 3.1 or newer, and the OpenTelemetry gems, which are not installed by default. Add the `appsignal-opentelemetry` gem alongside `appsignal` to install them. When they are missing or too old, AppSignal logs a warning and keeps reporting through its agent. diff --git a/.changesets/report-notifications-for-disabled-integrations.md b/.changesets/report-notifications-for-disabled-integrations.md index 7c46b4648..a71324b5c 100644 --- a/.changesets/report-notifications-for-disabled-integrations.md +++ b/.changesets/report-notifications-for-disabled-integrations.md @@ -3,16 +3,4 @@ bump: patch type: change --- -Report Active Job's and Faraday's own instrumentation events again when -AppSignal's instrumentation for those libraries is turned off. - -AppSignal records an enqueued Active Job, and a Faraday request, as events of -its own. Active Job and Faraday each report the same work through -ActiveSupport::Notifications as well, so since version 4.9.0 AppSignal has -ignored those notifications to avoid recording the same work twice. - -It ignored them even when there was nothing to record twice. An application -that sets `instrument_active_job` or `instrument_faraday` to `false` got no -event for that work at all, not even the one the library reported itself. Those -notifications are now ignored only while AppSignal is recording the work -itself. +Report Active Job's and Faraday's own instrumentation events again when AppSignal's instrumentation for those libraries is turned off. Since version 4.9.0, an application that set `instrument_active_job` or `instrument_faraday` to `false` got no event for that work at all. From b068e499edfe5c2430484d4626d51714e3fd6c3b Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Tue, 18 Aug 2026 17:11:50 +0200 Subject: [PATCH 62/69] Match monitor's arguments in monitor_and_stop `monitor_and_stop` accepted `opentelemetry_scope` but not `opentelemetry_context`, `opentelemetry_kind` or `opentelemetry_relationship`. It creates its transaction by calling `monitor`, which accepts all four, so those three were out of reach for anyone using this helper. Being deprecated is no reason to offer less than the method it delegates to, so it accepts all four and passes them on. --- lib/appsignal/helpers/instrumentation.rb | 21 ++++++++++++- sig/appsignal.rbi | 22 +++++++++++-- sig/appsignal.rbs | 30 ++++++++++++++++-- spec/lib/appsignal_spec.rb | 40 ++++++++++++++++++++++++ 4 files changed, 108 insertions(+), 5 deletions(-) diff --git a/lib/appsignal/helpers/instrumentation.rb b/lib/appsignal/helpers/instrumentation.rb index 937c23b7f..dfbc30dc0 100644 --- a/lib/appsignal/helpers/instrumentation.rb +++ b/lib/appsignal/helpers/instrumentation.rb @@ -193,6 +193,14 @@ def monitor( # rubocop:disable Metrics/ParameterLists # within the block with {#set_action}. # This will not update the active transaction's action if # {.monitor} is called when another transaction is already active. + # @param opentelemetry_kind [Symbol] In collector mode, the OpenTelemetry + # span kind: one of `:server`, `:consumer`, `:producer` or `:internal`. + # Defaults to `:server`. + # @param opentelemetry_relationship [Symbol] In collector mode, how an + # incoming `opentelemetry_context` relates to this transaction's span: + # one of `:parent`, `:link`, `:both` or `:none`. Defaults to `:parent`. + # @param opentelemetry_context In collector mode, an incoming OpenTelemetry + # trace context to relate this transaction's span to. # @param opentelemetry_scope [Array(String, String)] In collector mode, the # OpenTelemetry instrumentation scope to record this transaction's spans # under, given as a `[name, version]` pair. Defaults to the AppSignal @@ -204,7 +212,15 @@ def monitor( # rubocop:disable Metrics/ParameterLists # @return [Object, nil] The value of the given block is returned. # # @see monitor - def monitor_and_stop(action:, namespace: nil, opentelemetry_scope: nil, &block) + def monitor_and_stop( # rubocop:disable Metrics/ParameterLists + action:, + namespace: nil, + opentelemetry_context: nil, + opentelemetry_scope: nil, + opentelemetry_kind: nil, + opentelemetry_relationship: nil, + &block + ) Appsignal::Utils::StdoutAndLoggerMessage.warning \ "The `Appsignal.monitor_and_stop` helper is deprecated. " \ "Use the `Appsignal.monitor` along with our `enable_at_exit_hook` " \ @@ -213,7 +229,10 @@ def monitor_and_stop(action:, namespace: nil, opentelemetry_scope: nil, &block) monitor( :namespace => namespace, :action => action, + :opentelemetry_context => opentelemetry_context, :opentelemetry_scope => opentelemetry_scope, + :opentelemetry_kind => opentelemetry_kind, + :opentelemetry_relationship => opentelemetry_relationship, &block ) ensure diff --git a/sig/appsignal.rbi b/sig/appsignal.rbi index d41400d96..e97064ebd 100644 --- a/sig/appsignal.rbi +++ b/sig/appsignal.rbi @@ -424,6 +424,12 @@ module Appsignal # # _@param_ `action` — The action name for the transaction. The action name is required to be set for the transaction to be reported. The argument can be set to `nil` or `:set_later` if the action is set within the block with {#set_action}. This will not update the active transaction's action if {.monitor} is called when another transaction is already active. # + # _@param_ `opentelemetry_kind` — In collector mode, the OpenTelemetry span kind: one of `:server`, `:consumer`, `:producer` or `:internal`. Defaults to `:server`. + # + # _@param_ `opentelemetry_relationship` — In collector mode, how an incoming `opentelemetry_context` relates to this transaction's span: one of `:parent`, `:link`, `:both` or `:none`. Defaults to `:parent`. + # + # _@param_ `opentelemetry_context` — In collector mode, an incoming OpenTelemetry trace context to relate this transaction's span to. + # # _@param_ `opentelemetry_scope` — In collector mode, the OpenTelemetry instrumentation scope to record this transaction's spans under, given as a `[name, version]` pair. Defaults to the AppSignal scope. # # _@return_ — The value of the given block is returned. @@ -433,11 +439,14 @@ module Appsignal params( action: T.any(String, Symbol, NilClass), namespace: T.nilable(T.any(String, Symbol)), + opentelemetry_context: T.untyped, opentelemetry_scope: T.nilable([String, String]), + opentelemetry_kind: T.nilable(Symbol), + opentelemetry_relationship: T.nilable(Symbol), block: T.proc.returns(Object) ).returns(T.nilable(Object)) end - def self.monitor_and_stop(action:, namespace: nil, opentelemetry_scope: nil, &block); end + def self.monitor_and_stop(action:, namespace: nil, opentelemetry_context: nil, opentelemetry_scope: nil, opentelemetry_kind: nil, opentelemetry_relationship: nil, &block); end # Send an error to AppSignal regardless of the context. # @@ -2323,6 +2332,12 @@ module Appsignal # # _@param_ `action` — The action name for the transaction. The action name is required to be set for the transaction to be reported. The argument can be set to `nil` or `:set_later` if the action is set within the block with {#set_action}. This will not update the active transaction's action if {.monitor} is called when another transaction is already active. # + # _@param_ `opentelemetry_kind` — In collector mode, the OpenTelemetry span kind: one of `:server`, `:consumer`, `:producer` or `:internal`. Defaults to `:server`. + # + # _@param_ `opentelemetry_relationship` — In collector mode, how an incoming `opentelemetry_context` relates to this transaction's span: one of `:parent`, `:link`, `:both` or `:none`. Defaults to `:parent`. + # + # _@param_ `opentelemetry_context` — In collector mode, an incoming OpenTelemetry trace context to relate this transaction's span to. + # # _@param_ `opentelemetry_scope` — In collector mode, the OpenTelemetry instrumentation scope to record this transaction's spans under, given as a `[name, version]` pair. Defaults to the AppSignal scope. # # _@return_ — The value of the given block is returned. @@ -2332,11 +2347,14 @@ module Appsignal params( action: T.any(String, Symbol, NilClass), namespace: T.nilable(T.any(String, Symbol)), + opentelemetry_context: T.untyped, opentelemetry_scope: T.nilable([String, String]), + opentelemetry_kind: T.nilable(Symbol), + opentelemetry_relationship: T.nilable(Symbol), block: T.proc.returns(Object) ).returns(T.nilable(Object)) end - def monitor_and_stop(action:, namespace: nil, opentelemetry_scope: nil, &block); end + def monitor_and_stop(action:, namespace: nil, opentelemetry_context: nil, opentelemetry_scope: nil, opentelemetry_kind: nil, opentelemetry_relationship: nil, &block); end # Send an error to AppSignal regardless of the context. # diff --git a/sig/appsignal.rbs b/sig/appsignal.rbs index f81f2f9ee..6ad802732 100644 --- a/sig/appsignal.rbs +++ b/sig/appsignal.rbs @@ -378,12 +378,25 @@ module Appsignal # # _@param_ `action` — The action name for the transaction. The action name is required to be set for the transaction to be reported. The argument can be set to `nil` or `:set_later` if the action is set within the block with {#set_action}. This will not update the active transaction's action if {.monitor} is called when another transaction is already active. # + # _@param_ `opentelemetry_kind` — In collector mode, the OpenTelemetry span kind: one of `:server`, `:consumer`, `:producer` or `:internal`. Defaults to `:server`. + # + # _@param_ `opentelemetry_relationship` — In collector mode, how an incoming `opentelemetry_context` relates to this transaction's span: one of `:parent`, `:link`, `:both` or `:none`. Defaults to `:parent`. + # + # _@param_ `opentelemetry_context` — In collector mode, an incoming OpenTelemetry trace context to relate this transaction's span to. + # # _@param_ `opentelemetry_scope` — In collector mode, the OpenTelemetry instrumentation scope to record this transaction's spans under, given as a `[name, version]` pair. Defaults to the AppSignal scope. # # _@return_ — The value of the given block is returned. # # _@see_ `monitor` - def self.monitor_and_stop: (action: (String | Symbol | NilClass), ?namespace: (String | Symbol)?, ?opentelemetry_scope: [String, String]?) ?{ () -> Object } -> Object? + def self.monitor_and_stop: ( + action: (String | Symbol | NilClass), + ?namespace: (String | Symbol)?, + ?opentelemetry_context: untyped, + ?opentelemetry_scope: [String, String]?, + ?opentelemetry_kind: Symbol?, + ?opentelemetry_relationship: Symbol? + ) ?{ () -> Object } -> Object? # Send an error to AppSignal regardless of the context. # @@ -2138,12 +2151,25 @@ module Appsignal # # _@param_ `action` — The action name for the transaction. The action name is required to be set for the transaction to be reported. The argument can be set to `nil` or `:set_later` if the action is set within the block with {#set_action}. This will not update the active transaction's action if {.monitor} is called when another transaction is already active. # + # _@param_ `opentelemetry_kind` — In collector mode, the OpenTelemetry span kind: one of `:server`, `:consumer`, `:producer` or `:internal`. Defaults to `:server`. + # + # _@param_ `opentelemetry_relationship` — In collector mode, how an incoming `opentelemetry_context` relates to this transaction's span: one of `:parent`, `:link`, `:both` or `:none`. Defaults to `:parent`. + # + # _@param_ `opentelemetry_context` — In collector mode, an incoming OpenTelemetry trace context to relate this transaction's span to. + # # _@param_ `opentelemetry_scope` — In collector mode, the OpenTelemetry instrumentation scope to record this transaction's spans under, given as a `[name, version]` pair. Defaults to the AppSignal scope. # # _@return_ — The value of the given block is returned. # # _@see_ `monitor` - def monitor_and_stop: (action: (String | Symbol | NilClass), ?namespace: (String | Symbol)?, ?opentelemetry_scope: [String, String]?) ?{ () -> Object } -> Object? + def monitor_and_stop: ( + action: (String | Symbol | NilClass), + ?namespace: (String | Symbol)?, + ?opentelemetry_context: untyped, + ?opentelemetry_scope: [String, String]?, + ?opentelemetry_kind: Symbol?, + ?opentelemetry_relationship: Symbol? + ) ?{ () -> Object } -> Object? # Send an error to AppSignal regardless of the context. # diff --git a/spec/lib/appsignal_spec.rb b/spec/lib/appsignal_spec.rb index 5976bbbc3..d51e9c292 100644 --- a/spec/lib/appsignal_spec.rb +++ b/spec/lib/appsignal_spec.rb @@ -1538,6 +1538,46 @@ def perform end end.to yield_control end + + describe "OpenTelemetry attributes" do + it "threads the OpenTelemetry attributes to the created transaction" do + allow(Appsignal).to receive(:stop) + + otel_context = "some-otel-context" + expect(Appsignal::Transaction).to receive(:create).with( + Appsignal::Transaction::BACKGROUND_JOB, + :opentelemetry_context => otel_context, + :opentelemetry_scope => ["appsignal-ruby/custom", "1.2.3"], + :opentelemetry_kind => :consumer, + :opentelemetry_relationship => :both + ).and_call_original + + silence do + Appsignal.monitor_and_stop( + :action => "MyAction", + :namespace => Appsignal::Transaction::BACKGROUND_JOB, + :opentelemetry_context => otel_context, + :opentelemetry_scope => ["appsignal-ruby/custom", "1.2.3"], + :opentelemetry_kind => :consumer, + :opentelemetry_relationship => :both + ) + end + end + + it "uses the given opentelemetry_kind for the span", :collector_mode do + start_collector_agent + allow(Appsignal).to receive(:stop) + + silence do + Appsignal.monitor_and_stop( + :action => "MyAction", + :opentelemetry_kind => :consumer + ) + end + + expect(root_span.kind).to eq(:consumer) + end + end end describe ".tag_request" do From 1e3cd2c68c0515b0893bcbbdae64772b10357d3e Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Wed, 19 Aug 2026 15:34:35 +0200 Subject: [PATCH 63/69] Default instrument_sql to a client span `Appsignal.instrument_sql` accepted `opentelemetry_scope` but not `opentelemetry_kind`. It delegates to `Appsignal.instrument`, which accepts both, so the span kind was out of reach for anyone using this helper and its spans got OpenTelemetry's default kind of `:internal`. Every other path in the gem that records a SQL span already produces a client span, because a query is an outgoing call to a datastore, so the public helper was the only SQL path that did not. It accepts `opentelemetry_kind` and defaults it to `:client`. The default lives in the signature rather than being substituted when the argument is forwarded. A Ruby keyword default applies only when the argument is omitted, so an explicit `nil` still forwards `nil` and lands on the OpenTelemetry default. That keeps `nil` meaning "unspecified", which is what it means everywhere else in this codebase. --- lib/appsignal/helpers/instrumentation.rb | 14 +++++- sig/appsignal.rbi | 10 ++++- sig/appsignal.rbs | 6 +++ .../integration/collector_mode_traces_spec.rb | 4 ++ spec/lib/appsignal_spec.rb | 43 +++++++++++++++++++ 5 files changed, 74 insertions(+), 3 deletions(-) diff --git a/lib/appsignal/helpers/instrumentation.rb b/lib/appsignal/helpers/instrumentation.rb index dfbc30dc0..46e9e84b9 100644 --- a/lib/appsignal/helpers/instrumentation.rb +++ b/lib/appsignal/helpers/instrumentation.rb @@ -1027,6 +1027,10 @@ def instrument( # rubocop:disable Metrics/ParameterLists # naming guide listed under "See also". # @param title [String, nil] Human readable name of the event. # @param body [String, nil] SQL query that's being executed. + # @param opentelemetry_kind [Symbol] In collector mode, the OpenTelemetry + # span kind for the event's span. Defaults to `:client`, because a query + # is an outgoing call to a datastore. Pass `:internal` for a query that + # is not an outgoing call. # @param opentelemetry_scope [Array(String, String)] In collector mode, the # OpenTelemetry instrumentation scope to record the event's span under, # given as a `[name, version]` pair. Defaults to the AppSignal scope. @@ -1039,12 +1043,20 @@ def instrument( # rubocop:disable Metrics/ParameterLists # AppSignal custom instrumentation guide # @see https://docs.appsignal.com/api/event-names.html # AppSignal event naming guide - def instrument_sql(name, title = nil, body = nil, opentelemetry_scope: nil, &block) + def instrument_sql( + name, + title = nil, + body = nil, + opentelemetry_kind: :client, + opentelemetry_scope: nil, + &block + ) instrument( name, title, body, Appsignal::EventFormatter::SQL_BODY_FORMAT, + :opentelemetry_kind => opentelemetry_kind, :opentelemetry_scope => opentelemetry_scope, &block ) diff --git a/sig/appsignal.rbi b/sig/appsignal.rbi index e97064ebd..d58c4fe5d 100644 --- a/sig/appsignal.rbi +++ b/sig/appsignal.rbi @@ -1062,6 +1062,8 @@ module Appsignal # # _@param_ `body` — SQL query that's being executed. # + # _@param_ `opentelemetry_kind` — In collector mode, the OpenTelemetry span kind for the event's span. Defaults to `:client`, because a query is an outgoing call to a datastore. Pass `:internal` for a query that is not an outgoing call. + # # _@param_ `opentelemetry_scope` — In collector mode, the OpenTelemetry instrumentation scope to record the event's span under, given as a `[name, version]` pair. Defaults to the AppSignal scope. # # _@return_ — Returns the block's return value. @@ -1092,11 +1094,12 @@ module Appsignal name: String, title: T.nilable(String), body: T.nilable(String), + opentelemetry_kind: Symbol, opentelemetry_scope: T.nilable([String, String]), block: T.untyped ).returns(Object) end - def self.instrument_sql(name, title = nil, body = nil, opentelemetry_scope: nil, &block); end + def self.instrument_sql(name, title = nil, body = nil, opentelemetry_kind: :client, opentelemetry_scope: nil, &block); end # Convenience method for ignoring instrumentation events in a block of # code. @@ -2970,6 +2973,8 @@ module Appsignal # # _@param_ `body` — SQL query that's being executed. # + # _@param_ `opentelemetry_kind` — In collector mode, the OpenTelemetry span kind for the event's span. Defaults to `:client`, because a query is an outgoing call to a datastore. Pass `:internal` for a query that is not an outgoing call. + # # _@param_ `opentelemetry_scope` — In collector mode, the OpenTelemetry instrumentation scope to record the event's span under, given as a `[name, version]` pair. Defaults to the AppSignal scope. # # _@return_ — Returns the block's return value. @@ -3000,11 +3005,12 @@ module Appsignal name: String, title: T.nilable(String), body: T.nilable(String), + opentelemetry_kind: Symbol, opentelemetry_scope: T.nilable([String, String]), block: T.untyped ).returns(Object) end - def instrument_sql(name, title = nil, body = nil, opentelemetry_scope: nil, &block); end + def instrument_sql(name, title = nil, body = nil, opentelemetry_kind: :client, opentelemetry_scope: nil, &block); end # Convenience method for ignoring instrumentation events in a block of # code. diff --git a/sig/appsignal.rbs b/sig/appsignal.rbs index 6ad802732..3bcd28158 100644 --- a/sig/appsignal.rbs +++ b/sig/appsignal.rbs @@ -985,6 +985,8 @@ module Appsignal # # _@param_ `body` — SQL query that's being executed. # + # _@param_ `opentelemetry_kind` — In collector mode, the OpenTelemetry span kind for the event's span. Defaults to `:client`, because a query is an outgoing call to a datastore. Pass `:internal` for a query that is not an outgoing call. + # # _@param_ `opentelemetry_scope` — In collector mode, the OpenTelemetry instrumentation scope to record the event's span under, given as a `[name, version]` pair. Defaults to the AppSignal scope. # # _@return_ — Returns the block's return value. @@ -1014,6 +1016,7 @@ module Appsignal String name, ?String? title, ?String? body, + ?opentelemetry_kind: Symbol, ?opentelemetry_scope: [String, String]? ) -> Object @@ -2758,6 +2761,8 @@ module Appsignal # # _@param_ `body` — SQL query that's being executed. # + # _@param_ `opentelemetry_kind` — In collector mode, the OpenTelemetry span kind for the event's span. Defaults to `:client`, because a query is an outgoing call to a datastore. Pass `:internal` for a query that is not an outgoing call. + # # _@param_ `opentelemetry_scope` — In collector mode, the OpenTelemetry instrumentation scope to record the event's span under, given as a `[name, version]` pair. Defaults to the AppSignal scope. # # _@return_ — Returns the block's return value. @@ -2787,6 +2792,7 @@ module Appsignal String name, ?String? title, ?String? body, + ?opentelemetry_kind: Symbol, ?opentelemetry_scope: [String, String]? ) -> Object diff --git a/spec/integration/collector_mode_traces_spec.rb b/spec/integration/collector_mode_traces_spec.rb index ec5650a86..32e341903 100644 --- a/spec/integration/collector_mode_traces_spec.rb +++ b/spec/integration/collector_mode_traces_spec.rb @@ -45,6 +45,10 @@ expect(attribute_value(sql, "db.query.text")).to eq("SELECT * FROM users") expect(attribute_value(sql, "db.system.name")).to eq("other_sql") + # A query is an outgoing call to a datastore, so its span is a client + # span. The runner does not pass a span kind, so this is the default. + expect(sql.kind).to eq(:SPAN_KIND_CLIENT) + # Allocation counts: the transaction total on the root span and a per-event # count on each event span. The values are real allocations, so assert the # wiring (present and non-negative) rather than exact counts. The monitored diff --git a/spec/lib/appsignal_spec.rb b/spec/lib/appsignal_spec.rb index d51e9c292..9f1bde867 100644 --- a/spec/lib/appsignal_spec.rb +++ b/spec/lib/appsignal_spec.rb @@ -3442,6 +3442,49 @@ def perform expect(span.attributes).not_to have_key("appsignal.body") end end + + describe "the OpenTelemetry span kind" do + it "is client when no kind is given", :collector_mode do + start_collector_agent + set_current_transaction(transaction) + + Appsignal.instrument_sql("name", "title", "body") { :do_nothing } + Appsignal::Transaction.complete_current! + + expect(event_spans.size).to eq(1) + expect(event_spans.first.kind).to eq(:client) + end + + it "is the given kind when one is given", :collector_mode do + start_collector_agent + set_current_transaction(transaction) + + Appsignal.instrument_sql( + "name", "title", "body", + :opentelemetry_kind => :internal + ) { :do_nothing } + Appsignal::Transaction.complete_current! + + expect(event_spans.size).to eq(1) + expect(event_spans.first.kind).to eq(:internal) + end + + it "is internal when the kind is explicitly nil", :collector_mode do + start_collector_agent + set_current_transaction(transaction) + + Appsignal.instrument_sql( + "name", "title", "body", + :opentelemetry_kind => nil + ) { :do_nothing } + Appsignal::Transaction.complete_current! + + # The default only applies when the argument is omitted. An explicit + # `nil` means "unspecified", which uses the OpenTelemetry default. + expect(event_spans.size).to eq(1) + expect(event_spans.first.kind).to eq(:internal) + end + end end describe ".ignore_instrumentation_events" do From 3429061de74aa04f07ddf987f94c9001c7358811 Mon Sep 17 00:00:00 2001 From: Noemi <45180344+unflxw@users.noreply.github.com> Date: Thu, 20 Aug 2026 16:33:28 +0200 Subject: [PATCH 64/69] Suppress native events even when hooks are off (#1579) Active Job and Faraday each record their own event for the enqueue or the request. AppSignal has its own instrumentation for that same work, so it claims Active Job's `enqueue.active_job` event and Faraday's `request.faraday` event, which tells the generic notification paths to leave them alone. That claim happened inside each hook's `install` method, which only runs when the matching config option is on. Turning `instrument_active_job` or `instrument_faraday` off therefore turned the claim off too, so AppSignal's own instrumentation stopped running and the library's own event was reported in its place. A customer who disables one of these integrations does not expect an event for that work at all. Both hook files are already required unconditionally, so the claim sits in the class body and runs as soon as the file loads. It needs no `defined?` guard, because claiming an event only touches AppSignal's own formatter registry and never the `ActiveJob` or `Faraday` constants, so it cannot fail when the library is absent. --- ...notifications-for-disabled-integrations.md | 6 -- lib/appsignal/hooks/active_job.rb | 30 ++++---- lib/appsignal/hooks/faraday.rb | 28 ++++---- spec/lib/appsignal/hooks/activejob_spec.rb | 69 +++++++++++-------- .../appsignal/integrations/faraday_spec.rb | 29 ++++++-- 5 files changed, 94 insertions(+), 68 deletions(-) delete mode 100644 .changesets/report-notifications-for-disabled-integrations.md diff --git a/.changesets/report-notifications-for-disabled-integrations.md b/.changesets/report-notifications-for-disabled-integrations.md deleted file mode 100644 index a71324b5c..000000000 --- a/.changesets/report-notifications-for-disabled-integrations.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -bump: patch -type: change ---- - -Report Active Job's and Faraday's own instrumentation events again when AppSignal's instrumentation for those libraries is turned off. Since version 4.9.0, an application that set `instrument_active_job` or `instrument_faraday` to `false` got no event for that work at all. diff --git a/lib/appsignal/hooks/active_job.rb b/lib/appsignal/hooks/active_job.rb index 66844e271..eda298b4c 100644 --- a/lib/appsignal/hooks/active_job.rb +++ b/lib/appsignal/hooks/active_job.rb @@ -6,6 +6,22 @@ class Hooks class ActiveJobHook < Appsignal::Hooks::Hook register :active_job + # This integration records the enqueue itself, as a producer event that + # also injects trace context, and Active Job's own `enqueue.active_job` + # notification fires nested inside it. Claim the event so that the + # generic notification paths leave it alone. + # + # Claimed here, when this file is required, rather than in `install`. + # `install` only runs when Active Job instrumentation is turned on, but + # a customer who turns it off does not want to see the native + # notification reported instead. This call does not touch any + # `ActiveJob` constant, so it is safe to run even when the library is + # not present. + Appsignal::EventFormatter.register( + "enqueue.active_job", + Appsignal::EventFormatter::RecordedElsewhere + ) + def self.version_7_1_or_higher? @version_7_1_or_higher ||= if dependencies_present? @@ -27,20 +43,6 @@ def dependencies_present? end def install - # This integration records the enqueue itself, as a producer event that - # also injects trace context, and Active Job's own - # `enqueue.active_job` notification fires nested inside it. Claim the - # event so that the generic notification paths leave it alone. - # - # Claimed on install, so that the event is only claimed while this - # integration is recording it. With Active Job instrumentation turned - # off there is nothing for the notification to duplicate, so it is left - # to be recorded like any other. - Appsignal::EventFormatter.register( - "enqueue.active_job", - Appsignal::EventFormatter::RecordedElsewhere - ) - ActiveSupport.on_load(:active_job) do ::ActiveJob::Base .extend ::Appsignal::Hooks::ActiveJobHook::ActiveJobClassInstrumentation diff --git a/lib/appsignal/hooks/faraday.rb b/lib/appsignal/hooks/faraday.rb index 9503c9d65..def83f5ac 100644 --- a/lib/appsignal/hooks/faraday.rb +++ b/lib/appsignal/hooks/faraday.rb @@ -6,6 +6,22 @@ class Hooks class FaradayHook < Appsignal::Hooks::Hook register :faraday + # This integration records the request itself, so Faraday's own + # instrumentation middleware would report the same work again as a + # `request.faraday` notification. Claim it so the generic notification + # paths leave it alone. + # + # Claimed here, when this file is required, rather than in `install`. + # `install` only runs when Faraday instrumentation is turned on, but a + # customer who turns it off does not want to see the native + # notification reported instead. This call does not touch any + # `Faraday` constant, so it is safe to run even when the library is not + # present. + Appsignal::EventFormatter.register( + "request.faraday", + Appsignal::EventFormatter::RecordedElsewhere + ) + def dependencies_present? defined?(::Faraday) && Appsignal.config && Appsignal.config[:instrument_faraday] end @@ -14,18 +30,6 @@ def install require "appsignal/integrations/faraday" ::Faraday::RackBuilder.prepend(Appsignal::Integrations::FaradayRackBuilderPatch) - # This integration records the request itself, so Faraday's own - # instrumentation middleware would report the same work again as a - # `request.faraday` notification. Claim it so the generic notification - # paths leave it alone. - # - # Claimed on install, so the claim lasts exactly as long as this - # integration is recording the work. - Appsignal::EventFormatter.register( - "request.faraday", - Appsignal::EventFormatter::RecordedElsewhere - ) - Appsignal::Environment.report_enabled("faraday") end end diff --git a/spec/lib/appsignal/hooks/activejob_spec.rb b/spec/lib/appsignal/hooks/activejob_spec.rb index 664aaa3a2..6471f980f 100644 --- a/spec/lib/appsignal/hooks/activejob_spec.rb +++ b/spec/lib/appsignal/hooks/activejob_spec.rb @@ -36,39 +36,50 @@ path, _line_number = ActiveJob::Base.method(:execute).source_location expect(path).to end_with("/lib/appsignal/hooks/active_job.rb") end + end - context "when claiming the enqueue.active_job event" do - # The event formatter registry is shared by the whole test suite, so - # put back whatever this example changes. - around do |example| - formatters = Appsignal::EventFormatter.formatters.dup - formatter_classes = Appsignal::EventFormatter.formatter_classes.dup - example.run - ensure - Appsignal::EventFormatter.formatters.replace(formatters) - Appsignal::EventFormatter.formatter_classes.replace(formatter_classes) - end - - before do - Appsignal::EventFormatter.unregister( - "enqueue.active_job", - Appsignal::EventFormatter::RecordedElsewhere - ) - end + describe "claiming the enqueue.active_job event" do + # The event formatter registry is shared by the whole test suite, so + # put back whatever this example changes. Reloading the hook file + # below also re-registers it in the hooks registry, so put that back + # too. + around do |example| + formatters = Appsignal::EventFormatter.formatters.dup + formatter_classes = Appsignal::EventFormatter.formatter_classes.dup + hooks = Appsignal::Hooks.hooks.dup + example.run + ensure + Appsignal::EventFormatter.formatters.replace(formatters) + Appsignal::EventFormatter.formatter_classes.replace(formatter_classes) + Appsignal::Hooks.hooks.replace(hooks) + end - # This integration records the enqueue itself, so the generic - # notification paths must not record it a second time. Installing is - # what claims it, and this hook only installs when Active Job - # instrumentation is enabled, which the tests above cover. With it - # disabled nothing records the enqueue twice, so the notification is - # left to be recorded like any other. - it "claims the event" do - expect(Appsignal::EventFormatter.record?("enqueue.active_job")).to be(true) + # This integration records the enqueue itself, so the generic + # notification paths must not record it a second time. The hook file + # claims the event as soon as it is required, rather than when + # `install` runs, so the claim holds even when Active Job + # instrumentation ends up disabled and `install` never runs. A + # customer who turns the instrumentation off should not see the + # native notification reported instead. + # + # By this point in the suite, the hook file has already been + # required once, and `require` does not run a file's body again. Force + # the event back to unclaimed, then load the file with `load` instead + # of `require` so its body runs again. That proves the claim comes + # from loading the file, since `install` is never called here. + it "stays claimed when Active Job instrumentation is disabled" do + configure(:options => { :instrument_active_job => false }) + expect(described_class.new.dependencies_present?).to be(false) + + Appsignal::EventFormatter.unregister( + "enqueue.active_job", + Appsignal::EventFormatter::RecordedElsewhere + ) + expect(Appsignal::EventFormatter.record?("enqueue.active_job")).to be(true) - described_class.new.install + load "appsignal/hooks/active_job.rb" - expect(Appsignal::EventFormatter.record?("enqueue.active_job")).to be(false) - end + expect(Appsignal::EventFormatter.record?("enqueue.active_job")).to be(false) end end end diff --git a/spec/lib/appsignal/integrations/faraday_spec.rb b/spec/lib/appsignal/integrations/faraday_spec.rb index b2cade038..83318695e 100644 --- a/spec/lib/appsignal/integrations/faraday_spec.rb +++ b/spec/lib/appsignal/integrations/faraday_spec.rb @@ -12,29 +12,44 @@ describe "claiming the request.faraday event" do # The event formatter registry is shared by the whole test suite, so put - # back whatever this example changes. + # back whatever this example changes. Reloading the hook file below also + # re-registers it in the hooks registry, so put that back too. around do |example| formatters = Appsignal::EventFormatter.formatters.dup formatter_classes = Appsignal::EventFormatter.formatter_classes.dup + hooks = Appsignal::Hooks.hooks.dup example.run ensure Appsignal::EventFormatter.formatters.replace(formatters) Appsignal::EventFormatter.formatter_classes.replace(formatter_classes) + Appsignal::Hooks.hooks.replace(hooks) end # This integration records the request itself, so Faraday's own - # notification must not record it a second time. Installing is what - # claims it, and this hook only installs when Faraday instrumentation is - # enabled. With it disabled nothing records the request twice, so the - # notification is left to be recorded like any other. - it "is claimed on install" do + # notification must not record it a second time. The hook file claims + # the event as soon as it is required, rather than when `install` + # runs, so the claim holds even when Faraday instrumentation ends up + # disabled and `install` never runs. A customer who turns the + # instrumentation off should not see the native notification reported + # instead. + # + # The outer `before` above already installed the hook once, and the + # hook file has already been required, so `require` will not run its + # body again. Force the event back to unclaimed, then load the file + # with `load` instead of `require` so its body runs again. That + # proves the claim comes from loading the file, not from `install`, + # which this example does not call again. + it "stays claimed when Faraday instrumentation is disabled" do + configure(:options => { :instrument_faraday => false }) + expect(Appsignal::Hooks::FaradayHook.new.dependencies_present?).to be(false) + Appsignal::EventFormatter.unregister( "request.faraday", Appsignal::EventFormatter::RecordedElsewhere ) expect(Appsignal::EventFormatter.record?("request.faraday")).to be(true) - Appsignal::Hooks::FaradayHook.new.install + load "appsignal/hooks/faraday.rb" expect(Appsignal::EventFormatter.record?("request.faraday")).to be(false) end From 807d01a18d7e171b6c03e5ac84e203b741c25482 Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Thu, 20 Aug 2026 15:01:33 +0200 Subject: [PATCH 65/69] Stop the sentinel overwriting db.system.name `write_event_body_attributes` wrote the `other_sql` sentinel on every SQL event unconditionally. Both `finish_event` and `record_event` call it after an integration's own attributes are already on the span, so the sentinel always overwrote whatever engine name the integration had set. Nothing in the gem sets `db.system.name` yet, so this had no visible effect, but it would silently undo any integration that starts naming a real engine. The event frame tracks whether `db.system.name` was set during the event, and the sentinel is written only when that never happened. `record_event` has no event frame to track this on, so it always falls back to the sentinel. Presence of the key is not enough to count as set. `add_opentelemetry_attributes` is public API, so a caller can pass an explicit nil, and `Attributes.format` coerces that to an empty string, which would leave the key in place. A shared `named_db_system?` check looks at the formatted value instead, so a blank value still falls back to the sentinel rather than leaving the span with nothing the collector's sanitizer recognises. --- .../transaction/opentelemetry_backend.rb | 45 +++++++++++++++---- .../transaction/opentelemetry_backend_spec.rb | 34 ++++++++++++++ 2 files changed, 71 insertions(+), 8 deletions(-) diff --git a/lib/appsignal/transaction/opentelemetry_backend.rb b/lib/appsignal/transaction/opentelemetry_backend.rb index 858bc57f5..c70e81093 100644 --- a/lib/appsignal/transaction/opentelemetry_backend.rb +++ b/lib/appsignal/transaction/opentelemetry_backend.rb @@ -98,7 +98,13 @@ def warned_keys # (nil when allocation tracking is off), and `child_allocation_count` # accumulates the full allocation counts of the event's finished children, # so the event's own allocations are `full - child_allocation_count`. - EventFrame = Struct.new(:span, :token, :allocation_start, :child_allocation_count) + # `db_system_name_set` tracks whether the event itself already set + # `db.system.name` (through `set_attributes`) before it finishes, so the + # SQL sentinel written at finish only fills in a value that is missing. + EventFrame = Struct.new( + :span, :token, :allocation_start, :child_allocation_count, + :db_system_name_set + ) def initialize( # rubocop:disable Metrics/ParameterLists transaction_id, @@ -156,7 +162,7 @@ def finish_event(name, title, body, body_format) frame = @event_stack.pop write_event_span_name(frame.span, name, title) - write_event_body_attributes(frame.span, body, body_format) + write_event_body_attributes(frame.span, body, body_format, frame.db_system_name_set) write_event_allocation_count(frame) ::OpenTelemetry::Context.detach(frame.token) frame.span.finish @@ -175,7 +181,10 @@ def record_event( # rubocop:disable Metrics/ParameterLists :kind => opentelemetry_kind ) write_event_span_name(span, name, title) - write_event_body_attributes(span, body, body_format) + # A recorded event never opens an event frame, so there is nothing to + # track a mid-event `db.system.name` on; the sentinel is always the + # fallback here. + write_event_body_attributes(span, body, body_format, false) # A recorded event has no start hook, so we never measured its # allocations. We deliberately set no allocation attribute rather than a # misleading zero. Its allocations instead fall into the enclosing @@ -235,9 +244,15 @@ def set_metadata(key, value) # Never the OTel current span, which may belong to another # instrumentation. Values are coerced to the primitives OTLP accepts. def set_attributes(attributes) - current_span.add_attributes( - Appsignal::OpenTelemetry::Attributes.format(attributes) - ) + formatted = Appsignal::OpenTelemetry::Attributes.format(attributes) + # Note on the open event frame, if there is one, that this event + # already named a real `db.system.name`, so `write_event_body_attributes` + # knows not to overwrite it with the SQL sentinel when the event + # finishes. + if named_db_system?(formatted) && (frame = @event_stack.last) + frame.db_system_name_set = true + end + current_span.add_attributes(formatted) end # The collector keeps the request payload, the function parameters and the @@ -743,19 +758,33 @@ def write_event_span_name(span, name, title) span.name = has_title ? "#{name} (#{title})" : name end - def write_event_body_attributes(span, body, body_format) + def write_event_body_attributes(span, body, body_format, db_system_name_set) has_body = !body.to_s.empty? if body_format == Appsignal::EventFormatter::SQL_BODY_FORMAT # Name the datastore whether or not there is a query to record with it. # The semantic conventions require the attribute on every database # span, and a SQL event with nothing in its body is still a SQL event. - span.set_attribute("db.system.name", SQL_DB_SYSTEM) + # Only fall back to the sentinel when nothing set a real engine name + # earlier in the event, so an integration's own `db.system.name` + # always wins over it. + span.set_attribute("db.system.name", SQL_DB_SYSTEM) unless db_system_name_set span.set_attribute("db.query.text", body) if has_body elsif has_body span.set_attribute("appsignal.body", body) end end + + # Whether a formatted attributes hash names a real `db.system.name`, + # as opposed to merely having the key. `Attributes.format` coerces an + # explicit `nil` (or any other non-primitive) to `""`, so the key can + # be present with a blank value. A blank value must not count as set: + # it would block the SQL sentinel the same way a real value should, + # but leave the span with nothing the collector's sanitizer + # recognizes, instead of the sentinel that keeps sanitization on. + def named_db_system?(formatted_attributes) + !formatted_attributes["db.system.name"].to_s.empty? + end end end end diff --git a/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb b/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb index 00793e3a7..0ffcfebdd 100644 --- a/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb +++ b/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb @@ -1522,6 +1522,40 @@ def new_transaction_with_otel_backend(namespace = Appsignal::Transaction::HTTP_R expect(attrs).not_to have_key("appsignal.body") end + # This is the bug the fallback guards against: an integration names the + # real engine mid-event through `set_attributes` (as + # `add_opentelemetry_attributes` does), and the SQL sentinel written at + # `finish_event` must not clobber it on the finished span. + it "keeps an engine name set mid-event over the SQL sentinel" do + backend = create_backend + backend.start_event + backend.set_attributes("db.system.name" => "postgresql") + backend.finish_event("sql.query", "Q", "SELECT 1", + Appsignal::EventFormatter::SQL_BODY_FORMAT) + + attrs = span_exporter.finished_spans + .find { |s| s.name == "sql.query (Q)" }.attributes + expect(attrs["db.system.name"]).to eq("postgresql") + expect(attrs["db.query.text"]).to eq("SELECT 1") + end + + # Attributes.format coerces an explicit nil to "", so the key is + # present on the formatted hash either way. Only a non-blank value + # should count as naming a real engine; a blank one must still fall + # back to the sentinel; otherwise the span would skip the collector's + # sanitizer entirely, unlike other_sql, which the sanitizer recognizes. + it "falls back to the SQL sentinel when set_attributes names a blank engine" do + backend = create_backend + backend.start_event + backend.set_attributes("db.system.name" => nil) + backend.finish_event("sql.query", "Q", "SELECT 1", + Appsignal::EventFormatter::SQL_BODY_FORMAT) + + attrs = span_exporter.finished_spans + .find { |s| s.name == "sql.query (Q)" }.attributes + expect(attrs["db.system.name"]).to eq("other_sql") + end + it "writes appsignal.body for default bodies (no db.* attributes)" do backend = create_backend backend.start_event From eb8748de5ff4324b09746999a83c3a4a11b466c5 Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Thu, 20 Aug 2026 15:04:28 +0200 Subject: [PATCH 66/69] Apply formatter attributes to dry-monitor events instrument called Appsignal::EventFormatter.format but never Appsignal::EventFormatter.opentelemetry_attributes, unlike ActiveSupportNotificationsIntegration#finish_event, which calls both. A formatter that only arrives over dry-monitor, such as ROM's SQL formatter, could define opentelemetry_attributes and it would never run. The call is added before finish_event, so the attributes land on the event's own span rather than on the transaction. --- lib/appsignal/integrations/dry_monitor.rb | 5 +++ spec/lib/appsignal/hooks/dry_monitor_spec.rb | 42 ++++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/lib/appsignal/integrations/dry_monitor.rb b/lib/appsignal/integrations/dry_monitor.rb index d0f05eaa9..5a344c4aa 100644 --- a/lib/appsignal/integrations/dry_monitor.rb +++ b/lib/appsignal/integrations/dry_monitor.rb @@ -29,6 +29,11 @@ def instrument(event_id, payload = {}, &block) super ensure event_name, body, body_format = Appsignal::EventFormatter.format(name, payload) + # Set while the event's span is still open, so the attributes land + # on the event rather than on the transaction. + Appsignal::Transaction.current.add_opentelemetry_attributes( + Appsignal::EventFormatter.opentelemetry_attributes(name, payload) + ) # dry-monitor reports an event under an id, such as `sql`, rather than # a name. A formatter names the event it knows about, and an event diff --git a/spec/lib/appsignal/hooks/dry_monitor_spec.rb b/spec/lib/appsignal/hooks/dry_monitor_spec.rb index aec6985ea..f639ee842 100644 --- a/spec/lib/appsignal/hooks/dry_monitor_spec.rb +++ b/spec/lib/appsignal/hooks/dry_monitor_spec.rb @@ -86,6 +86,48 @@ def perform end end + describe "an event whose formatter declares attributes" do + let(:event_id) { :attributed } + let(:payload) { { :name => "attributed" } } + + # A minimal stand-in for a formatter that both formats an event's body + # and names OpenTelemetry attributes for it, such as ROM's SQL + # formatter does once it names a real `db.system.name`. This proves + # `instrument` calls through to `opentelemetry_attributes`, not only + # to `format`. + let(:formatter_class) do + Class.new(Appsignal::EventFormatter) do + def format(_payload) + ["attributed.dry", "body", Appsignal::EventFormatter::DEFAULT] + end + + def opentelemetry_attributes(_payload) + { "db.system.name" => "postgresql" } + end + end + end + + before { Appsignal::EventFormatter.register("attributed.dry", formatter_class) } + + after { Appsignal::EventFormatter.unregister("attributed.dry", formatter_class) } + + def perform + notifications.instrument(event_id, payload) + end + + it "puts the formatter's attributes on the event's own span", :collector_mode do + start_collector_agent + transaction = http_request_transaction + set_current_transaction(transaction) + perform + Appsignal::Transaction.complete_current! + + expect(event_spans.size).to eq(1) + span = event_spans.first + expect(span.attributes["db.system.name"]).to eq("postgresql") + end + end + describe "an event that another integration records" do let(:event_id) { :claimed } let(:payload) { { :name => "claimed" } } From 2b036e6f94e85e5dbaf8e78e658f0b300d18d39f Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Thu, 20 Aug 2026 15:08:42 +0200 Subject: [PATCH 67/69] Let record_event take opentelemetry_attributes `record_event` accepted `opentelemetry_kind` and `opentelemetry_scope` but had no way to attach attributes. An `instrument` caller can add attributes in the window between `start_event` and `finish_event`, but `record_event` reports a complete event in one call, so a caller such as DataMapper's integration had no way to describe what it recorded. The keyword threads through `Transaction#record_event` and `OpenTelemetryBackend#record_event`, which formats and writes the attributes to the span before it finishes. `ExtensionBackend` ignores it, as it does the other OpenTelemetry keywords, because agent mode has no span to put them on. A caller that names its own `db.system.name` this way takes priority over the SQL sentinel, through the same `named_db_system?` check that guards `set_attributes`. --- lib/appsignal/transaction.rb | 6 ++-- .../transaction/extension_backend.rb | 2 +- .../transaction/opentelemetry_backend.rb | 16 ++++++--- .../transaction/opentelemetry_backend_spec.rb | 36 +++++++++++++++++++ spec/lib/appsignal/transaction_spec.rb | 31 ++++++++++++++-- 5 files changed, 80 insertions(+), 11 deletions(-) diff --git a/lib/appsignal/transaction.rb b/lib/appsignal/transaction.rb index 1b08ae358..10378c148 100644 --- a/lib/appsignal/transaction.rb +++ b/lib/appsignal/transaction.rb @@ -922,7 +922,8 @@ def record_event( # rubocop:disable Metrics/ParameterLists duration, body_format = Appsignal::EventFormatter::DEFAULT, opentelemetry_kind: nil, - opentelemetry_scope: nil + opentelemetry_scope: nil, + opentelemetry_attributes: nil ) return if paused? @@ -933,7 +934,8 @@ def record_event( # rubocop:disable Metrics/ParameterLists body_format || Appsignal::EventFormatter::DEFAULT, duration, :opentelemetry_kind => opentelemetry_kind, - :opentelemetry_scope => opentelemetry_scope + :opentelemetry_scope => opentelemetry_scope, + :opentelemetry_attributes => opentelemetry_attributes ) end diff --git a/lib/appsignal/transaction/extension_backend.rb b/lib/appsignal/transaction/extension_backend.rb index 874fdd038..e71d20d0a 100644 --- a/lib/appsignal/transaction/extension_backend.rb +++ b/lib/appsignal/transaction/extension_backend.rb @@ -55,7 +55,7 @@ def finish_event(name, title, body, body_format) # Agent mode has no span kind or instrumentation scope; # `opentelemetry_kind` and `opentelemetry_scope` are ignored here. - def record_event(name, title, body, body_format, duration, opentelemetry_kind: nil, opentelemetry_scope: nil) # rubocop:disable Lint/UnusedMethodArgument, Metrics/ParameterLists, Layout/LineLength + def record_event(name, title, body, body_format, duration, opentelemetry_kind: nil, opentelemetry_scope: nil, opentelemetry_attributes: nil) # rubocop:disable Lint/UnusedMethodArgument, Metrics/ParameterLists, Layout/LineLength @handle.record_event(name, title, body, body_format, duration, 0) end diff --git a/lib/appsignal/transaction/opentelemetry_backend.rb b/lib/appsignal/transaction/opentelemetry_backend.rb index c70e81093..4f0d10ab0 100644 --- a/lib/appsignal/transaction/opentelemetry_backend.rb +++ b/lib/appsignal/transaction/opentelemetry_backend.rb @@ -172,7 +172,7 @@ def finish_event(name, title, body, body_format) # mirroring `start_event`. `nil` leaves the SDK default (INTERNAL). def record_event( # rubocop:disable Metrics/ParameterLists name, title, body, body_format, duration, - opentelemetry_kind: nil, opentelemetry_scope: nil + opentelemetry_kind: nil, opentelemetry_scope: nil, opentelemetry_attributes: nil ) start_time = Time.now - (duration / 1_000_000_000.0) span = tracer_for(opentelemetry_scope).start_span( @@ -181,10 +181,16 @@ def record_event( # rubocop:disable Metrics/ParameterLists :kind => opentelemetry_kind ) write_event_span_name(span, name, title) - # A recorded event never opens an event frame, so there is nothing to - # track a mid-event `db.system.name` on; the sentinel is always the - # fallback here. - write_event_body_attributes(span, body, body_format, false) + # A recorded event has no window between start and finish, so this is + # its only chance to attach attributes -- there is no event frame for + # a later `set_attributes` call to note a `db.system.name` on. + formatted_attributes = Appsignal::OpenTelemetry::Attributes.format( + opentelemetry_attributes || {} + ) + span.add_attributes(formatted_attributes) unless formatted_attributes.empty? + write_event_body_attributes( + span, body, body_format, named_db_system?(formatted_attributes) + ) # A recorded event has no start hook, so we never measured its # allocations. We deliberately set no allocation attribute rather than a # misleading zero. Its allocations instead fall into the enclosing diff --git a/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb b/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb index 0ffcfebdd..7db4c39b3 100644 --- a/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb +++ b/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb @@ -1481,6 +1481,42 @@ def new_transaction_with_otel_backend(namespace = Appsignal::Transaction::HTTP_R Appsignal::EventFormatter::DEFAULT, 1_000) expect(backend.instance_variable_get(:@event_stack)).to be_empty end + + it "puts opentelemetry_attributes on the finished span" do + backend = create_backend + backend.record_event("custom.event", "T", "B", + Appsignal::EventFormatter::DEFAULT, 1_000, + :opentelemetry_attributes => { "db.system.name" => "postgresql" }) + + span = span_exporter.finished_spans.find { |s| s.name == "custom.event (T)" } + expect(span.attributes["db.system.name"]).to eq("postgresql") + end + + it "lets its own db.system.name win over the SQL sentinel" do + backend = create_backend + backend.record_event("sql.query", "Q", "SELECT 1", + Appsignal::EventFormatter::SQL_BODY_FORMAT, 1_000, + :opentelemetry_attributes => { "db.system.name" => "postgresql" }) + + span = span_exporter.finished_spans.find { |s| s.name == "sql.query (Q)" } + expect(span.attributes["db.system.name"]).to eq("postgresql") + expect(span.attributes["db.query.text"]).to eq("SELECT 1") + end + + # A caller passing an explicit nil (e.g. a lookup that came back empty) + # must not block the sentinel. Attributes.format coerces nil to "", so + # the key is present either way; only a non-blank value should count as + # naming a real engine. A span left with "" would skip the collector's + # sanitizer entirely, unlike other_sql, which the sanitizer recognizes. + it "falls back to the SQL sentinel when the engine name is explicitly blank" do + backend = create_backend + backend.record_event("sql.query", "Q", "SELECT 1", + Appsignal::EventFormatter::SQL_BODY_FORMAT, 1_000, + :opentelemetry_attributes => { "db.system.name" => nil }) + + span = span_exporter.finished_spans.find { |s| s.name == "sql.query (Q)" } + expect(span.attributes["db.system.name"]).to eq("other_sql") + end end describe "nested events" do diff --git a/spec/lib/appsignal/transaction_spec.rb b/spec/lib/appsignal/transaction_spec.rb index a9950b629..a45aaa3cd 100644 --- a/spec/lib/appsignal/transaction_spec.rb +++ b/spec/lib/appsignal/transaction_spec.rb @@ -4955,7 +4955,8 @@ def exception_event 1, 1000, :opentelemetry_kind => nil, - :opentelemetry_scope => nil + :opentelemetry_scope => nil, + :opentelemetry_attributes => nil ).and_call_original transaction.record_event( @@ -4975,7 +4976,8 @@ def exception_event 1, 1000, :opentelemetry_kind => nil, - :opentelemetry_scope => ["appsignal-ruby/data_mapper", "1.0"] + :opentelemetry_scope => ["appsignal-ruby/data_mapper", "1.0"], + :opentelemetry_attributes => nil ).and_call_original transaction.record_event( @@ -4988,6 +4990,28 @@ def exception_event ) end + it "passes the opentelemetry_attributes to the backend" do + expect(transaction.backend).to receive(:record_event).with( + "name", + "title", + "body", + 1, + 1000, + :opentelemetry_kind => nil, + :opentelemetry_scope => nil, + :opentelemetry_attributes => { "db.system.name" => "postgresql" } + ).and_call_original + + transaction.record_event( + "name", + "title", + "body", + 1000, + 1, + :opentelemetry_attributes => { "db.system.name" => "postgresql" } + ) + end + it "should finish the event in the extension with nil arguments" do expect(transaction.backend).to receive(:record_event).with( "name", @@ -4996,7 +5020,8 @@ def exception_event 0, 1000, :opentelemetry_kind => nil, - :opentelemetry_scope => nil + :opentelemetry_scope => nil, + :opentelemetry_attributes => nil ).and_call_original transaction.record_event( From 79171d6d0455ed4c01cb9af80795cd2077880bf2 Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Thu, 20 Aug 2026 15:28:15 +0200 Subject: [PATCH 68/69] Name the engine for ActiveRecord, Sequel, ROM SQL spans carried a sentinel value for `db.system.name` that means the dialect is unknown, while the Redis, MongoDB and Elasticsearch spans already named theirs. Each library spells its engine differently, and none of them use the value the semantic conventions ask for, so the mapping is kept per library rather than shared. ROM is the exception. Its dry-monitor payload carries Sequel's own `database_type` symbol, so it reuses Sequel's mapping. The values follow the older semantic conventions set, so SQL Server is `mssql` and Oracle is `oracle` rather than `microsoft.sql_server` and `oracle.db`. Those are the values the AppSignal collector's sanitizer recognises today. A name that no mapping recognises returns nil, so the existing `other_sql` fallback still applies. --- .../active_record/sql_formatter.rb | 14 +++ .../event_formatter/rom/sql_formatter.rb | 12 +++ lib/appsignal/hooks/sequel.rb | 26 ++++++ lib/appsignal/opentelemetry.rb | 1 + lib/appsignal/opentelemetry/sql_db_system.rb | 89 +++++++++++++++++++ .../active_record/sql_formatter_spec.rb | 44 +++++++++ .../event_formatter/rom/sql_formatter_spec.rb | 20 +++++ .../instrument_shared_examples.rb | 25 ++++++ spec/lib/appsignal/hooks/dry_monitor_spec.rb | 5 +- spec/lib/appsignal/hooks/sequel_spec.rb | 48 +++++++++- .../opentelemetry/sql_db_system_spec.rb | 89 +++++++++++++++++++ 11 files changed, 371 insertions(+), 2 deletions(-) create mode 100644 lib/appsignal/opentelemetry/sql_db_system.rb create mode 100644 spec/lib/appsignal/opentelemetry/sql_db_system_spec.rb diff --git a/lib/appsignal/event_formatter/active_record/sql_formatter.rb b/lib/appsignal/event_formatter/active_record/sql_formatter.rb index 884c4073b..1204f28df 100644 --- a/lib/appsignal/event_formatter/active_record/sql_formatter.rb +++ b/lib/appsignal/event_formatter/active_record/sql_formatter.rb @@ -10,6 +10,20 @@ def opentelemetry_kind :client end + # The payload carries the connection the query ran on (Rails 6.0+), + # whose `adapter_name` is each adapter's own name for itself, such as + # `"PostgreSQL"` or `"Mysql2"`. A name the mapping does not recognise + # is left to the SQL sentinel, same as an adapter this gem has never + # heard of. + def opentelemetry_attributes(payload) + name = Appsignal::OpenTelemetry::SqlDbSystem.name_for_active_record( + payload[:connection]&.adapter_name + ) + return unless name + + { "db.system.name" => name } + end + def format(payload) [payload[:name], payload[:sql], SQL_BODY_FORMAT] end diff --git a/lib/appsignal/event_formatter/rom/sql_formatter.rb b/lib/appsignal/event_formatter/rom/sql_formatter.rb index 8e06147bb..84a5dd69e 100644 --- a/lib/appsignal/event_formatter/rom/sql_formatter.rb +++ b/lib/appsignal/event_formatter/rom/sql_formatter.rb @@ -29,6 +29,18 @@ def opentelemetry_kind def format(payload) ["query.rom", payload[:query], SQL_BODY_FORMAT] end + + # The payload's `name` is Sequel's `database_type` symbol for the + # database ROM is talking to, so this uses Sequel's own lookup + # rather than a coincidentally similar one. A symbol the mapping does + # not recognise is left to the SQL sentinel, same as an engine this + # gem has never heard of. + def opentelemetry_attributes(payload) + name = Appsignal::OpenTelemetry::SqlDbSystem.name_for_sequel(payload[:name]) + return unless name + + { "db.system.name" => name } + end end end end diff --git a/lib/appsignal/hooks/sequel.rb b/lib/appsignal/hooks/sequel.rb index b7ce7ea8f..9ef904716 100644 --- a/lib/appsignal/hooks/sequel.rb +++ b/lib/appsignal/hooks/sequel.rb @@ -14,6 +14,9 @@ def log_yield(sql, args = nil) :opentelemetry_kind => :client, :opentelemetry_scope => ["appsignal-ruby/sequel", Appsignal::VERSION] ) do + Appsignal::Transaction.current.add_opentelemetry_attributes( + Appsignal::Hooks::SequelHook.sequel_db_attributes(self) + ) super end end @@ -31,6 +34,9 @@ def log_connection_yield(sql, conn, args = nil) :opentelemetry_kind => :client, :opentelemetry_scope => ["appsignal-ruby/sequel", Appsignal::VERSION] ) do + Appsignal::Transaction.current.add_opentelemetry_attributes( + Appsignal::Hooks::SequelHook.sequel_db_attributes(self) + ) super end end @@ -39,6 +45,26 @@ def log_connection_yield(sql, conn, args = nil) class SequelHook < Appsignal::Hooks::Hook register :sequel + # The query's `Sequel::Database` names both the engine it talks to and + # the database it is connected to, neither of which the sql.sequel + # formatter can see -- it only gets the query text. Shared by both + # extensions above, whichever one a given Sequel version registers. + # + # @!visibility private + def self.sequel_db_attributes(database) + attributes = {} + + name = Appsignal::OpenTelemetry::SqlDbSystem.name_for_sequel(database.database_type) + attributes["db.system.name"] = name if name + + # `opts[:database]` is Sequel's own option key for the database to + # connect to, so it doubles as the database's name. + namespace = database.opts[:database].to_s + attributes["db.namespace"] = namespace unless namespace.empty? + + attributes + end + def dependencies_present? defined?(::Sequel::Database) && Appsignal.config && diff --git a/lib/appsignal/opentelemetry.rb b/lib/appsignal/opentelemetry.rb index b1b42fa64..4ee32b739 100644 --- a/lib/appsignal/opentelemetry.rb +++ b/lib/appsignal/opentelemetry.rb @@ -9,6 +9,7 @@ require "appsignal/opentelemetry/http_server_request" require "appsignal/opentelemetry/messaging" require "appsignal/opentelemetry/rendering" +require "appsignal/opentelemetry/sql_db_system" module Appsignal # @!visibility private diff --git a/lib/appsignal/opentelemetry/sql_db_system.rb b/lib/appsignal/opentelemetry/sql_db_system.rb new file mode 100644 index 000000000..7f0551dcb --- /dev/null +++ b/lib/appsignal/opentelemetry/sql_db_system.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +module Appsignal + module OpenTelemetry + # @!visibility private + # + # Maps a SQL library's own name for the engine it is talking to onto the + # `db.system.name` semantic conventions value for that engine. + # + # Each library names engines differently, and none of them matches the + # semantic conventions spelling, so every SQL integration needs a lookup. + # Kept as one map per library, each keyed on that library's own + # vocabulary, rather than one shared map: ActiveRecord's `ADAPTER_NAME` + # strings, Sequel's `database_type` symbols, and DataMapper's + # `DataObjects` connection class names do not collide today, but nothing + # about that is enforced by combining them. A wrong value passed into the + # wrong library's lookup would silently return another library's answer + # instead of nil, and a library's own gaps would be invisible next to two + # other libraries' complete entries. ROM is the exception: its payload + # already carries Sequel's own `database_type` symbol, so it is + # legitimate for it to share Sequel's map, not just convenient. + # + # A name none of these maps recognise returns `nil`, so the caller's own + # `other_sql` fallback applies -- exactly what already happens for a + # library this gem has no mapping for at all. + # + # This intentionally follows the older semantic conventions value set: + # SQL Server maps to `mssql`, not the current registry's + # `microsoft.sql_server`, and Oracle maps to `oracle`, not `oracle.db`. + # That is what the AppSignal collector's sanitizer recognizes today; + # emitting the newer values would silently turn sanitization off for + # those engines' queries. + module SqlDbSystem + # ActiveRecord's `ADAPTER_NAME`. Rails bundles the Postgres, Mysql2, + # SQLite and (7.1+) Trilogy adapters; SQL Server and Oracle come from + # the separate `activerecord-sqlserver-adapter` and + # `activerecord-oracle_enhanced-adapter` gems, which declare + # `ADAPTER_NAME` the same way. + ACTIVE_RECORD = { + "PostgreSQL" => "postgresql", + "Mysql2" => "mysql", + "Trilogy" => "mysql", + "SQLite" => "sqlite", + "SQLServer" => "mssql", + "OracleEnhanced" => "oracle" + }.freeze + + # Sequel's `database_type`. ROM's dry-monitor payload reuses this + # symbol directly, so `name_for_sequel` is also ROM's lookup. + SEQUEL = { + :postgres => "postgresql", + :mysql => "mysql", + :sqlite => "sqlite", + :mssql => "mssql", + :oracle => "oracle" + }.freeze + + # DataMapper's `DataObjects` connection classes. + DATA_MAPPER = { + "DataObjects::Postgres::Connection" => "postgresql", + "DataObjects::Mysql::Connection" => "mysql", + "DataObjects::Sqlite3::Connection" => "sqlite", + "DataObjects::SqlServer::Connection" => "mssql" + }.freeze + + class << self + # The `db.system.name` value for an ActiveRecord connection's + # `adapter_name`, or `nil` when the adapter is not one this map + # recognises. + def name_for_active_record(adapter_name) + ACTIVE_RECORD[adapter_name] + end + + # The `db.system.name` value for Sequel's `database_type`, or `nil` + # when it is not one this map recognises. Also the lookup ROM's + # formatter uses, since ROM reports this same symbol. + def name_for_sequel(database_type) + SEQUEL[database_type] + end + + # The `db.system.name` value for DataMapper's connection class name, + # or `nil` when it is not one this map recognises. + def name_for_data_mapper(connection_class_name) + DATA_MAPPER[connection_class_name] + end + end + end + end +end diff --git a/spec/lib/appsignal/event_formatter/active_record/sql_formatter_spec.rb b/spec/lib/appsignal/event_formatter/active_record/sql_formatter_spec.rb index 287c4f70c..9e5da152a 100644 --- a/spec/lib/appsignal/event_formatter/active_record/sql_formatter_spec.rb +++ b/spec/lib/appsignal/event_formatter/active_record/sql_formatter_spec.rb @@ -24,4 +24,48 @@ it { is_expected.to eq ["User load", "SELECT * FROM users", 1] } end + + describe "#opentelemetry_attributes" do + subject { formatter.opentelemetry_attributes(payload) } + + context "with a connection whose adapter the mapping recognises" do + let(:payload) { { :connection => double(:adapter_name => "PostgreSQL") } } + + it "names the engine" do + is_expected.to eq("db.system.name" => "postgresql") + end + end + + context "with a connection whose adapter is activerecord-sqlserver-adapter's" do + let(:payload) { { :connection => double(:adapter_name => "SQLServer") } } + + it "names the engine with the older semantic conventions value" do + is_expected.to eq("db.system.name" => "mssql") + end + end + + context "with a connection whose adapter is activerecord-oracle_enhanced-adapter's" do + let(:payload) { { :connection => double(:adapter_name => "OracleEnhanced") } } + + it "names the engine" do + is_expected.to eq("db.system.name" => "oracle") + end + end + + context "with a connection whose adapter the mapping does not recognise" do + let(:payload) { { :connection => double(:adapter_name => "DB2") } } + + it "names nothing, leaving the SQL sentinel to apply" do + is_expected.to be_nil + end + end + + context "without a connection (Rails versions that do not report one)" do + let(:payload) { {} } + + it "names nothing, leaving the SQL sentinel to apply" do + is_expected.to be_nil + end + end + end end diff --git a/spec/lib/appsignal/event_formatter/rom/sql_formatter_spec.rb b/spec/lib/appsignal/event_formatter/rom/sql_formatter_spec.rb index 6677dbf9c..43482dbc5 100644 --- a/spec/lib/appsignal/event_formatter/rom/sql_formatter_spec.rb +++ b/spec/lib/appsignal/event_formatter/rom/sql_formatter_spec.rb @@ -40,4 +40,24 @@ it { is_expected.to eq ["query.rom", "SELECT * FROM users", 1] } end end + + describe "#opentelemetry_attributes" do + subject { formatter.opentelemetry_attributes(payload) } + + context "with a database type the mapping recognises" do + let(:payload) { { :name => :postgres, :query => "SELECT * FROM users" } } + + it "names the engine" do + is_expected.to eq("db.system.name" => "postgresql") + end + end + + context "with a database type the mapping does not recognise" do + let(:payload) { { :name => :db2, :query => "SELECT * FROM users" } } + + it "names nothing, leaving the SQL sentinel to apply" do + is_expected.to be_nil + end + end + end end diff --git a/spec/lib/appsignal/hooks/active_support_notifications/instrument_shared_examples.rb b/spec/lib/appsignal/hooks/active_support_notifications/instrument_shared_examples.rb index 02f41572b..f34185847 100644 --- a/spec/lib/appsignal/hooks/active_support_notifications/instrument_shared_examples.rb +++ b/spec/lib/appsignal/hooks/active_support_notifications/instrument_shared_examples.rb @@ -45,6 +45,31 @@ def perform end end + describe "an ActiveRecord SQL query event with a connection" do + let(:connection) { double(:adapter_name => "PostgreSQL") } + + def perform + as.instrument("sql.active_record", :sql => "SQL", :connection => connection) { "value" } + end + + it "in collector mode", :collector_mode do + start_collector_agent + transaction = http_request_transaction + set_current_transaction(transaction) + as.notifier = notifier + + expect(perform).to eq "value" + Appsignal::Transaction.complete_current! + + expect(event_spans.size).to eq(1) + span = event_spans.find { |s| s.name == "sql.active_record" } + expect(span).not_to be_nil + # The connection's own adapter name names the engine, rather than the + # SQL sentinel every other unrecognized adapter falls back to. + expect(span.attributes["db.system.name"]).to eq("postgresql") + end + end + describe "a Sequel query event (emitted by sequel-rails)" do def perform as.instrument( diff --git a/spec/lib/appsignal/hooks/dry_monitor_spec.rb b/spec/lib/appsignal/hooks/dry_monitor_spec.rb index f639ee842..436721562 100644 --- a/spec/lib/appsignal/hooks/dry_monitor_spec.rb +++ b/spec/lib/appsignal/hooks/dry_monitor_spec.rb @@ -77,7 +77,10 @@ def perform 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") + # The payload's `name` is Sequel's database_type symbol for the + # database ROM is talking to, which the formatter maps to the real + # engine name rather than leaving it at the SQL sentinel. + expect(attrs["db.system.name"]).to eq("postgresql") expect(event_category(span)).to eq("query.rom") # ROM emits this event, so it is attributed to ROM rather than to the # bus it arrived over. diff --git a/spec/lib/appsignal/hooks/sequel_spec.rb b/spec/lib/appsignal/hooks/sequel_spec.rb index 08db49d13..7e29eaf74 100644 --- a/spec/lib/appsignal/hooks/sequel_spec.rb +++ b/spec/lib/appsignal/hooks/sequel_spec.rb @@ -1,4 +1,45 @@ describe Appsignal::Hooks::SequelHook do + describe ".sequel_db_attributes" do + subject { described_class.sequel_db_attributes(database) } + + context "with a recognised engine and a named database" do + let(:database) do + double(:database_type => :postgres, :opts => { :database => "app_production" }) + end + + it "names both the engine and the database" do + expect(subject).to eq( + "db.system.name" => "postgresql", + "db.namespace" => "app_production" + ) + end + end + + context "with an in-memory database, which Sequel names with no :database option" do + let(:database) { double(:database_type => :sqlite, :opts => {}) } + + it "names the engine without a namespace" do + expect(subject).to eq("db.system.name" => "sqlite") + end + end + + context "with an engine the mapping does not recognise" do + let(:database) { double(:database_type => :db2, :opts => {}) } + + it "names neither, leaving the SQL sentinel to apply" do + expect(subject).to eq({}) + end + end + + context "with an engine that only the older semantic conventions map" do + let(:database) { double(:database_type => :oracle, :opts => {}) } + + it "names the engine with the older semantic conventions value" do + expect(subject).to eq("db.system.name" => "oracle") + end + end + end + if DependencyHelper.sequel_present? let(:db) do if DependencyHelper.running_jruby? @@ -47,7 +88,12 @@ def perform 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") + # The Sequel::Database's own database_type names the engine, rather + # than the SQL sentinel every unrecognized engine falls back to. + expect(span.attributes["db.system.name"]).to eq("sqlite") + # This in-memory database has no :database connection option, so + # there is no name to put in db.namespace. + expect(span.attributes).not_to have_key("db.namespace") expect(span.attributes).not_to have_key("appsignal.body") expect(event_category(span)).to eq("sql.sequel") expect(scope_of(span)).to eq(["appsignal-ruby/sequel", Appsignal::VERSION]) diff --git a/spec/lib/appsignal/opentelemetry/sql_db_system_spec.rb b/spec/lib/appsignal/opentelemetry/sql_db_system_spec.rb new file mode 100644 index 000000000..0fc983a2b --- /dev/null +++ b/spec/lib/appsignal/opentelemetry/sql_db_system_spec.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +describe Appsignal::OpenTelemetry::SqlDbSystem do + describe ".name_for_active_record" do + it "maps ActiveRecord's ADAPTER_NAME to the semantic conventions value" do + expect(described_class.name_for_active_record("PostgreSQL")).to eq("postgresql") + expect(described_class.name_for_active_record("Mysql2")).to eq("mysql") + expect(described_class.name_for_active_record("Trilogy")).to eq("mysql") + expect(described_class.name_for_active_record("SQLite")).to eq("sqlite") + end + + # activerecord-sqlserver-adapter and activerecord-oracle_enhanced-adapter + # are separate gems, but declare ADAPTER_NAME the same way the adapters + # Rails bundles do. + it "maps activerecord-sqlserver-adapter's ADAPTER_NAME to the older semconv value" do + expect(described_class.name_for_active_record("SQLServer")).to eq("mssql") + end + + it "maps activerecord-oracle_enhanced-adapter's ADAPTER_NAME to the older semconv value" do + expect(described_class.name_for_active_record("OracleEnhanced")).to eq("oracle") + end + + it "returns nil for an adapter it does not recognise" do + expect(described_class.name_for_active_record("DB2")).to be_nil + expect(described_class.name_for_active_record(nil)).to be_nil + end + + it "does not recognise Sequel's or DataMapper's vocabulary" do + expect(described_class.name_for_active_record(:postgres)).to be_nil + expect(described_class.name_for_active_record("DataObjects::Postgres::Connection")).to be_nil + end + end + + describe ".name_for_sequel" do + it "maps Sequel's database_type to the semantic conventions value" do + expect(described_class.name_for_sequel(:postgres)).to eq("postgresql") + expect(described_class.name_for_sequel(:mysql)).to eq("mysql") + expect(described_class.name_for_sequel(:sqlite)).to eq("sqlite") + end + + # SQL Server and Oracle map to the older semantic conventions values, + # `mssql` and `oracle`, rather than the current registry's + # `microsoft.sql_server` and `oracle.db`, because those are what the + # AppSignal collector's sanitizer recognizes today. + it "maps SQL Server and Oracle to the older semantic conventions values" do + expect(described_class.name_for_sequel(:mssql)).to eq("mssql") + expect(described_class.name_for_sequel(:oracle)).to eq("oracle") + end + + it "returns nil for a database_type it does not recognise" do + expect(described_class.name_for_sequel(:db2)).to be_nil + expect(described_class.name_for_sequel(nil)).to be_nil + end + + it "does not recognise ActiveRecord's or DataMapper's vocabulary" do + expect(described_class.name_for_sequel("PostgreSQL")).to be_nil + expect(described_class.name_for_sequel("DataObjects::Postgres::Connection")).to be_nil + end + end + + describe ".name_for_data_mapper" do + it "maps DataMapper's DataObjects connection classes to the semantic conventions value" do + expect(described_class.name_for_data_mapper("DataObjects::Postgres::Connection")) + .to eq("postgresql") + expect(described_class.name_for_data_mapper("DataObjects::Mysql::Connection")) + .to eq("mysql") + expect(described_class.name_for_data_mapper("DataObjects::Sqlite3::Connection")) + .to eq("sqlite") + end + + # SQL Server maps to the older semantic conventions value, `mssql`, + # rather than the current registry's `microsoft.sql_server`, because + # that is what the AppSignal collector's sanitizer recognizes today. + it "maps SQL Server to the older semantic conventions value" do + expect(described_class.name_for_data_mapper("DataObjects::SqlServer::Connection")) + .to eq("mssql") + end + + it "returns nil for a connection class it does not recognise" do + expect(described_class.name_for_data_mapper("DataObjects::Oracle::Connection")).to be_nil + expect(described_class.name_for_data_mapper(nil)).to be_nil + end + + it "does not recognise ActiveRecord's or Sequel's vocabulary" do + expect(described_class.name_for_data_mapper("PostgreSQL")).to be_nil + expect(described_class.name_for_data_mapper(:postgres)).to be_nil + end + end +end From 546bde273d816d15c6e934315f13ae9e79676b4d Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Thu, 20 Aug 2026 15:29:52 +0200 Subject: [PATCH 69/69] Name the real engine for DataMapper ActiveRecord, Sequel and ROM name a real `db.system.name` instead of the sentinel that means the dialect is unknown. DataMapper's integration already checks its connection class against `SQL_CLASSES` to decide whether a query is SQL at all, so the same class names the engine through the shared `SqlDbSystem` mapping. This needs `record_event`'s `opentelemetry_attributes` keyword, because DataMapper reports a complete event in one call and has no window between start and finish in which to add an attribute. --- lib/appsignal/integrations/data_mapper.rb | 11 ++++++++++- spec/lib/appsignal/integrations/data_mapper_spec.rb | 4 +++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/lib/appsignal/integrations/data_mapper.rb b/lib/appsignal/integrations/data_mapper.rb index 26f8a3482..f408d01f6 100644 --- a/lib/appsignal/integrations/data_mapper.rb +++ b/lib/appsignal/integrations/data_mapper.rb @@ -12,10 +12,18 @@ module DataMapperLogListener ].freeze def log(message) + attributes = {} + # If scheme is SQL-like, try to sanitize it, otherwise clear the body if SQL_CLASSES.include?(self.class.to_s) body_content = message.query body_format = Appsignal::EventFormatter::SQL_BODY_FORMAT + # The connection class names the engine it talks to, one of the four + # SQL_CLASSES above; a class this map does not recognise leaves the + # SQL sentinel to apply, same as it would for a fifth SQL_CLASSES + # entry this map has not been taught about. + db_system = Appsignal::OpenTelemetry::SqlDbSystem.name_for_data_mapper(self.class.to_s) + attributes["db.system.name"] = db_system if db_system else body_content = "" body_format = Appsignal::EventFormatter::DEFAULT @@ -30,7 +38,8 @@ def log(message) message.duration, body_format, :opentelemetry_kind => :client, - :opentelemetry_scope => ["appsignal-ruby/data_mapper", Appsignal::VERSION] + :opentelemetry_scope => ["appsignal-ruby/data_mapper", Appsignal::VERSION], + :opentelemetry_attributes => attributes ) super end diff --git a/spec/lib/appsignal/integrations/data_mapper_spec.rb b/spec/lib/appsignal/integrations/data_mapper_spec.rb index 80fa423c5..e585ed7f4 100644 --- a/spec/lib/appsignal/integrations/data_mapper_spec.rb +++ b/spec/lib/appsignal/integrations/data_mapper_spec.rb @@ -60,7 +60,9 @@ def perform 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") + # The connection class names the engine, rather than the SQL sentinel + # every unrecognized SQL_CLASSES entry would fall back to. + expect(attrs["db.system.name"]).to eq("sqlite") expect(event_category(span)).to eq("query.data_mapper") expect(scope_of(span)).to eq(["appsignal-ruby/data_mapper", Appsignal::VERSION]) expect(attrs).not_to have_key("appsignal.body")