From 61d8a557693b3db2d0c194abc87e0a42d01c6fed Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Tue, 19 May 2026 17:57:50 +0200 Subject: [PATCH 001/151] Add `collector_endpoint` config option When set, the integration boots an OpenTelemetry SDK that exports OTLP/HTTP traces, metrics, and logs to that endpoint. The AppSignal agent keeps running unchanged. --- .changesets/add-collector-mode.md | 6 + appsignal.gemspec | 9 ++ lib/appsignal.rb | 5 + lib/appsignal/config.rb | 20 +++ lib/appsignal/opentelemetry.rb | 67 +++++++++ .../utils/stdout_and_logger_message.rb | 9 ++ sig/appsignal.rbi | 14 ++ sig/appsignal.rbs | 13 ++ spec/integration/collector_mode_spec.rb | 45 ++++++ spec/integration/diagnose | 2 +- .../runners/collector_mode_emit.rb | 42 ++++++ spec/lib/appsignal/config_spec.rb | 42 ++++++ spec/spec_helper.rb | 8 +- spec/support/helpers/otlp_collector_server.rb | 133 ++++++++++++++++++ 14 files changed, 413 insertions(+), 2 deletions(-) create mode 100644 .changesets/add-collector-mode.md create mode 100644 lib/appsignal/opentelemetry.rb create mode 100644 spec/integration/collector_mode_spec.rb create mode 100644 spec/integration/runners/collector_mode_emit.rb create mode 100644 spec/support/helpers/otlp_collector_server.rb diff --git a/.changesets/add-collector-mode.md b/.changesets/add-collector-mode.md new file mode 100644 index 000000000..90190152d --- /dev/null +++ b/.changesets/add-collector-mode.md @@ -0,0 +1,6 @@ +--- +bump: minor +type: add +--- + +Add a new `collector_endpoint` configuration option (`APPSIGNAL_COLLECTOR_ENDPOINT` environment variable) that puts the integration in _collector mode_. When set, AppSignal additionally configures an OpenTelemetry SDK that exports OTLP/HTTP protobuf traces, metrics, and logs to the configured endpoint. The existing AppSignal agent continues to run unchanged; no AppSignal-collected data flows through the OpenTelemetry SDK yet. diff --git a/appsignal.gemspec b/appsignal.gemspec index 087b13c7d..0e8145305 100644 --- a/appsignal.gemspec +++ b/appsignal.gemspec @@ -60,6 +60,15 @@ Gem::Specification.new do |gem| # Needs 2.0+ because we rely on Rack::Events gem.add_dependency "rack", ">= 2.0.0" + # OpenTelemetry SDK and OTLP exporters. Only loaded when collector mode is + # active (`Appsignal.config.collector_mode?`); see `lib/appsignal/opentelemetry.rb`. + gem.add_dependency "opentelemetry-exporter-otlp" + gem.add_dependency "opentelemetry-exporter-otlp-logs" + gem.add_dependency "opentelemetry-exporter-otlp-metrics" + gem.add_dependency "opentelemetry-logs-sdk" + gem.add_dependency "opentelemetry-metrics-sdk" + gem.add_dependency "opentelemetry-sdk" + gem.add_development_dependency "pry" gem.add_development_dependency "rake", ">= 12" gem.add_development_dependency "rspec", "~> 3.8" diff --git a/lib/appsignal.rb b/lib/appsignal.rb index 754182108..09ba72ce7 100644 --- a/lib/appsignal.rb +++ b/lib/appsignal.rb @@ -143,6 +143,11 @@ def start Appsignal::Hooks.load_hooks Appsignal::Loaders.start + if config.collector_mode? + require "appsignal/opentelemetry" + Appsignal::OpenTelemetry.configure(config) + end + if config[:enable_allocation_tracking] && !Appsignal::System.jruby? Appsignal::Extension.install_allocation_event_hook Appsignal::Environment.report_enabled("allocation_tracking") diff --git a/lib/appsignal/config.rb b/lib/appsignal/config.rb index b938342d1..9c31290bd 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", @@ -158,6 +159,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", @@ -433,6 +435,24 @@ def active? valid? && active_for_env? end + # Check if AppSignal is running in collector mode. + # + # Collector mode is active when a non-empty `collector_endpoint` is + # configured. In this mode, an OpenTelemetry SDK is configured to export + # OTLP/HTTP data to that endpoint. + # + # Memoised: the result is cached on first call so hot paths (metric + # and log emits) avoid re-running the string-strip predicate. A fresh + # `Config` instance always starts uncached. + # + # @return [Boolean] True if collector mode is active. + def collector_mode? + return @collector_mode if defined?(@collector_mode) + + endpoint = config_hash[:collector_endpoint] + @collector_mode = !endpoint.nil? && !endpoint.to_s.strip.empty? + end + # @!visibility private def write_to_environment ENV["_APPSIGNAL_ACTIVE"] = active?.to_s diff --git a/lib/appsignal/opentelemetry.rb b/lib/appsignal/opentelemetry.rb new file mode 100644 index 000000000..875c6106e --- /dev/null +++ b/lib/appsignal/opentelemetry.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +module Appsignal + # @!visibility private + module OpenTelemetry + class << self + # Configure the global OpenTelemetry SDK to export OTLP/HTTP protobuf to + # the collector endpoint defined in `config[:collector_endpoint]`. + # + # Lazily requires the OpenTelemetry SDK and OTLP exporter gems so that + # users not in collector mode do not pay the load cost. + def configure(config) + require "opentelemetry/sdk" + require "opentelemetry/exporter/otlp" + require "opentelemetry-metrics-sdk" + require "opentelemetry-exporter-otlp-metrics" + require "opentelemetry-logs-sdk" + require "opentelemetry-exporter-otlp-logs" + + endpoint = config[:collector_endpoint].to_s.sub(%r{/+\z}, "") + service_name = config[:name].to_s.empty? ? "unknown" : config[:name] + + ::OpenTelemetry::SDK.configure do |c| + c.service_name = service_name + 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 + ::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 + ::OpenTelemetry.logger_provider.add_log_record_processor( + ::OpenTelemetry::SDK::Logs::Export::BatchLogRecordProcessor.new( + ::OpenTelemetry::Exporter::OTLP::Logs::LogsExporter.new( + :endpoint => "#{endpoint}/v1/logs" + ) + ) + ) + rescue LoadError => e + Appsignal::Utils::StdoutAndLoggerMessage.error( + "Cannot configure OpenTelemetry SDK for collector mode: #{e.class}: #{e.message}" + ) + rescue => e + Appsignal::Utils::StdoutAndLoggerMessage.error( + "Error configuring OpenTelemetry SDK for collector mode: " \ + "#{e.class}: #{e.message}\n#{e.backtrace&.join("\n")}" + ) + end + end + end +end diff --git a/lib/appsignal/utils/stdout_and_logger_message.rb b/lib/appsignal/utils/stdout_and_logger_message.rb index f26bcf2d8..e0c94958c 100644 --- a/lib/appsignal/utils/stdout_and_logger_message.rb +++ b/lib/appsignal/utils/stdout_and_logger_message.rb @@ -8,9 +8,18 @@ def self.warning(message, logger = Appsignal.internal_logger) logger.warn message end + def self.error(message, logger = Appsignal.internal_logger) + Kernel.warn "appsignal ERROR: #{message}" + logger.error message + end + def stdout_and_logger_warning(message, logger = Appsignal.internal_logger) Appsignal::Utils::StdoutAndLoggerMessage.warning(message, logger) end + + def stdout_and_logger_error(message, logger = Appsignal.internal_logger) + Appsignal::Utils::StdoutAndLoggerMessage.error(message, logger) + end end end end diff --git a/sig/appsignal.rbi b/sig/appsignal.rbi index ac31143d8..160fe64a0 100644 --- a/sig/appsignal.rbi +++ b/sig/appsignal.rbi @@ -1041,6 +1041,20 @@ module Appsignal sig { returns(T::Boolean) } def active?; end + # Check if AppSignal is running in collector mode. + # + # Collector mode is active when a non-empty `collector_endpoint` is + # configured. In this mode, an OpenTelemetry SDK is configured to export + # OTLP/HTTP data to that endpoint. + # + # Memoised: the result is cached on first call so hot paths (metric + # and log emits) avoid re-running the string-strip predicate. A fresh + # `Config` instance always starts uncached. + # + # _@return_ — True if collector mode is active. + 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 b2d4a0a91..41d6c426b 100644 --- a/sig/appsignal.rbs +++ b/sig/appsignal.rbs @@ -991,6 +991,19 @@ module Appsignal # _@return_ — True if valid and active for the current environment. def active?: () -> bool + # Check if AppSignal is running in collector mode. + # + # Collector mode is active when a non-empty `collector_endpoint` is + # configured. In this mode, an OpenTelemetry SDK is configured to export + # OTLP/HTTP data to that endpoint. + # + # Memoised: the result is cached on first call so hot paths (metric + # and log emits) avoid re-running the string-strip predicate. A fresh + # `Config` instance always starts uncached. + # + # _@return_ — True if collector mode is active. + 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..7819aa104 --- /dev/null +++ b/spec/integration/collector_mode_spec.rb @@ -0,0 +1,45 @@ +# 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 } + + it "configures collector mode and emits OTLP traces, metrics, and logs" do + runner = Runner.new("collector_mode_emit") + runner.run + + expect(runner.status.exitstatus).to eq(0), "runner failed:\n#{runner.output}" + expect(runner.output).to include("DONE") + + # Config wiring: the child process saw the value and computed the predicate. + expect(runner.output).to include("collector_endpoint=http://127.0.0.1:9090") + 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") + + 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") + + 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") + end +end diff --git a/spec/integration/diagnose b/spec/integration/diagnose index 1e98e7d5e..69b23b08b 160000 --- a/spec/integration/diagnose +++ b/spec/integration/diagnose @@ -1 +1 @@ -Subproject commit 1e98e7d5ef86f0bf3631d1b45fdfdc86f9ced736 +Subproject commit 69b23b08bd80de5971d9d803a7e7244558c1c234 diff --git a/spec/integration/runners/collector_mode_emit.rb b/spec/integration/runners/collector_mode_emit.rb new file mode 100644 index 000000000..7e207d063 --- /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 "fileutils" +require "appsignal" + +Appsignal.configure(:test) do |config| + config.active = true + config.push_api_key = "abc" + config.name = "collector-mode-test" + config.collector_endpoint = "http://127.0.0.1:9090" + + working_directory = "tmp/appsignal" + FileUtils.rm_rf(working_directory) + FileUtils.mkdir_p(working_directory) + config.working_directory_path = File.join(__dir__, working_directory) +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") + +# Force-flush so the spec can assert on the queued requests deterministically. +OpenTelemetry.tracer_provider.force_flush +OpenTelemetry.meter_provider.force_flush +OpenTelemetry.logger_provider.force_flush + +puts "DONE" diff --git a/spec/lib/appsignal/config_spec.rb b/spec/lib/appsignal/config_spec.rb index c5a2fb8c7..9fd4cd2a6 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, @@ -768,6 +769,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", @@ -919,6 +921,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", @@ -1609,6 +1612,45 @@ def log_file_path end end + describe "#collector_mode?" do + let(:options) { {} } + let(:config) { build_config(:root_path => "", :env => nil, :options => options) } + subject { config.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 + end + describe Appsignal::Config::ConfigDSL do let(:env) { :production } let(:options) { {} } diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index d4708cadf..884726845 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -92,7 +92,13 @@ def spec_system_tmp_dir end config.before :suite do - WebMock.disable_net_connect! + # Allow connections to the OTLP mock server bound by `OTLPCollectorServer`. + WebMock.disable_net_connect!(:allow => "127.0.0.1:#{OTLPCollectorServer::PORT}") + OTLPCollectorServer.boot! + end + + config.after do + OTLPCollectorServer.clear if defined?(OTLPCollectorServer) end config.before :context do diff --git a/spec/support/helpers/otlp_collector_server.rb b/spec/support/helpers/otlp_collector_server.rb new file mode 100644 index 000000000..ca1d4285e --- /dev/null +++ b/spec/support/helpers/otlp_collector_server.rb @@ -0,0 +1,133 @@ +# 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 + PORT = 9090 + PATHS = %w[/v1/traces /v1/metrics /v1/logs].freeze + + @received = Hash.new { |h, k| h[k] = Queue.new } + @booted = false + + class << self + attr_reader :received + + def endpoint + "http://127.0.0.1:#{PORT}" + 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 + + @server = TCPServer.new("127.0.0.1", PORT) + @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 From b7b9dfaebe9047c7b5e8039581aa879f75d7c79f Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Tue, 19 May 2026 18:46:05 +0200 Subject: [PATCH 002/151] Add OpenTelemetry resource attribute options Add service_name and collector-only filter/send options. Route the AppSignal config (name, env, hostname, revision, filters, etc.) into the OTel resource so the collector can act on it. Warn when options are set in the wrong mode. --- .../add-collector-attribute-options.md | 10 ++ lib/appsignal/config.rb | 77 +++++++++++- lib/appsignal/opentelemetry.rb | 56 ++++++++- spec/integration/collector_mode_spec.rb | 53 ++++++++ .../runners/collector_mode_emit.rb | 8 ++ spec/lib/appsignal/config_spec.rb | 117 ++++++++++++++++++ 6 files changed, 316 insertions(+), 5 deletions(-) create mode 100644 .changesets/add-collector-attribute-options.md 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/lib/appsignal/config.rb b/lib/appsignal/config.rb index 9c31290bd..a7115dca4 100644 --- a/lib/appsignal/config.rb +++ b/lib/appsignal/config.rb @@ -107,8 +107,12 @@ def dsl_config_file? :enable_rake_performance_instrumentation => false, :endpoint => "https://push.appsignal.com", :files_world_accessible => true, + :filter_attributes => [], + :filter_function_parameters => [], :filter_metadata => [], :filter_parameters => [], + :filter_request_payload => [], + :filter_request_query_parameters => [], :filter_session_data => [], :ignore_actions => [], :ignore_errors => [], @@ -131,9 +135,14 @@ def dsl_config_file? REQUEST_METHOD REQUEST_PATH SERVER_NAME SERVER_PORT SERVER_PROTOCOL ], + :response_headers => [], :send_environment_metadata => true, + :send_function_parameters => nil, :send_params => true, + :send_request_payload => nil, + :send_request_query_parameters => nil, :send_session_data => true, + :service_name => nil, :sidekiq_report_errors => "all", :default_tags => {} }.freeze @@ -170,6 +179,7 @@ def dsl_config_file? :logging_endpoint => "APPSIGNAL_LOGGING_ENDPOINT", :endpoint => "APPSIGNAL_PUSH_API_ENDPOINT", :push_api_key => "APPSIGNAL_PUSH_API_KEY", + :service_name => "APPSIGNAL_SERVICE_NAME", :sidekiq_report_errors => "APPSIGNAL_SIDEKIQ_REPORT_ERRORS", :statsd_port => "APPSIGNAL_STATSD_PORT", :nginx_port => "APPSIGNAL_NGINX_PORT", @@ -204,21 +214,29 @@ def dsl_config_file? :ownership_set_namespace => "APPSIGNAL_OWNERSHIP_SET_NAMESPACE", :running_in_container => "APPSIGNAL_RUNNING_IN_CONTAINER", :send_environment_metadata => "APPSIGNAL_SEND_ENVIRONMENT_METADATA", + :send_function_parameters => "APPSIGNAL_SEND_FUNCTION_PARAMETERS", :send_params => "APPSIGNAL_SEND_PARAMS", + :send_request_payload => "APPSIGNAL_SEND_REQUEST_PAYLOAD", + :send_request_query_parameters => "APPSIGNAL_SEND_REQUEST_QUERY_PARAMETERS", :send_session_data => "APPSIGNAL_SEND_SESSION_DATA" }.freeze # @!visibility private ARRAY_OPTIONS = { :dns_servers => "APPSIGNAL_DNS_SERVERS", + :filter_attributes => "APPSIGNAL_FILTER_ATTRIBUTES", + :filter_function_parameters => "APPSIGNAL_FILTER_FUNCTION_PARAMETERS", :filter_metadata => "APPSIGNAL_FILTER_METADATA", :filter_parameters => "APPSIGNAL_FILTER_PARAMETERS", + :filter_request_payload => "APPSIGNAL_FILTER_REQUEST_PAYLOAD", + :filter_request_query_parameters => "APPSIGNAL_FILTER_REQUEST_QUERY_PARAMETERS", :filter_session_data => "APPSIGNAL_FILTER_SESSION_DATA", :ignore_actions => "APPSIGNAL_IGNORE_ACTIONS", :ignore_errors => "APPSIGNAL_IGNORE_ERRORS", :ignore_logs => "APPSIGNAL_IGNORE_LOGS", :ignore_namespaces => "APPSIGNAL_IGNORE_NAMESPACES", - :request_headers => "APPSIGNAL_REQUEST_HEADERS" + :request_headers => "APPSIGNAL_REQUEST_HEADERS", + :response_headers => "APPSIGNAL_RESPONSE_HEADERS" }.freeze # @!visibility private @@ -231,6 +249,32 @@ def dsl_config_file? :default_tags => "APPSIGNAL_DEFAULT_TAGS" }.freeze + # 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 @@ -529,6 +573,26 @@ 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). + # @!visibility private + def warn_for_mode_mismatch + if collector_mode? + 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 @@ -552,6 +616,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 index 875c6106e..33cd27720 100644 --- a/lib/appsignal/opentelemetry.rb +++ b/lib/appsignal/opentelemetry.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require "appsignal/opentelemetry/attributes" + module Appsignal # @!visibility private module OpenTelemetry @@ -18,10 +20,16 @@ def configure(config) require "opentelemetry-exporter-otlp-logs" endpoint = config[:collector_endpoint].to_s.sub(%r{/+\z}, "") - service_name = config[:name].to_s.empty? ? "unknown" : config[:name] + # 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.service_name = service_name + c.resource = resource c.add_span_processor( ::OpenTelemetry::SDK::Trace::Export::BatchSpanProcessor.new( ::OpenTelemetry::Exporter::OTLP::Exporter.new( @@ -35,7 +43,8 @@ def configure(config) # `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 + ::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( @@ -44,7 +53,8 @@ def configure(config) ) ) - ::OpenTelemetry.logger_provider = ::OpenTelemetry::SDK::Logs::LoggerProvider.new + ::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( @@ -62,6 +72,44 @@ def configure(config) "#{e.class}: #{e.message}\n#{e.backtrace&.join("\n")}" ) 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.service.process_id" => Process.pid, + "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 end end end diff --git a/spec/integration/collector_mode_spec.rb b/spec/integration/collector_mode_spec.rb index 7819aa104..4d16fedeb 100644 --- a/spec/integration/collector_mode_spec.rb +++ b/spec/integration/collector_mode_spec.rb @@ -9,6 +9,56 @@ 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) # rubocop:disable Metrics/AbcSize + attrs = resource.attributes.to_h { |kv| [kv.key, kv.value] } + + 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("collector-mode-test") + expect(attrs["appsignal.config.environment"].string_value).to eq("test") + expect(attrs["appsignal.config.push_api_key"].string_value).to eq("abc") + expect(attrs["appsignal.config.revision"].string_value).to eq("abc1234") + expect(attrs["appsignal.config.language_integration"].string_value).to eq("ruby") + expect(attrs["appsignal.service.process_id"].int_value).to be > 0 + + 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") runner.run @@ -26,6 +76,7 @@ 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 @@ -33,6 +84,7 @@ 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 @@ -41,5 +93,6 @@ 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 diff --git a/spec/integration/runners/collector_mode_emit.rb b/spec/integration/runners/collector_mode_emit.rb index 7e207d063..1b90a6fc2 100644 --- a/spec/integration/runners/collector_mode_emit.rb +++ b/spec/integration/runners/collector_mode_emit.rb @@ -10,6 +10,14 @@ config.push_api_key = "abc" config.name = "collector-mode-test" config.collector_endpoint = "http://127.0.0.1:9090" + 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"] working_directory = "tmp/appsignal" FileUtils.rm_rf(working_directory) diff --git a/spec/lib/appsignal/config_spec.rb b/spec/lib/appsignal/config_spec.rb index 9fd4cd2a6..df79ef628 100644 --- a/spec/lib/appsignal/config_spec.rb +++ b/spec/lib/appsignal/config_spec.rb @@ -726,8 +726,12 @@ def on_load :enable_statsd => false, :endpoint => "https://test.appsignal.com", :files_world_accessible => false, + :filter_attributes => ["attr1", "attr2"], + :filter_function_parameters => ["fn1", "fn2"], :filter_metadata => ["key1", "key2"], :filter_parameters => ["param1", "param2"], + :filter_request_payload => ["payload1", "payload2"], + :filter_request_query_parameters => ["query1", "query2"], :filter_session_data => ["session1", "session2"], :host_role => "my host role", :hostname => "my hostname", @@ -751,11 +755,16 @@ def on_load :ownership_set_namespace => true, :push_api_key => "aaa-bbb-ccc", :request_headers => ["accept", "accept-charset"], + :response_headers => ["x-response-1", "x-response-2"], :revision => "v2.5.1", :running_in_container => true, :send_environment_metadata => false, + :send_function_parameters => true, :send_params => false, + :send_request_payload => true, + :send_request_query_parameters => true, :send_session_data => false, + :service_name => "my-service", :sidekiq_report_errors => "all", :statsd_port => "7890", :working_directory_path => working_directory_path, @@ -780,6 +789,7 @@ def on_load "APPSIGNAL_LOG_PATH" => "/tmp/something", "APPSIGNAL_PUSH_API_ENDPOINT" => "https://test.appsignal.com", "APPSIGNAL_PUSH_API_KEY" => "aaa-bbb-ccc", + "APPSIGNAL_SERVICE_NAME" => "my-service", "APPSIGNAL_SIDEKIQ_REPORT_ERRORS" => "all", "APPSIGNAL_STATSD_PORT" => "7890", "APPSIGNAL_NGINX_PORT" => "4321", @@ -810,19 +820,27 @@ def on_load "APPSIGNAL_OWNERSHIP_SET_NAMESPACE" => "true", "APPSIGNAL_RUNNING_IN_CONTAINER" => "true", "APPSIGNAL_SEND_ENVIRONMENT_METADATA" => "false", + "APPSIGNAL_SEND_FUNCTION_PARAMETERS" => "true", "APPSIGNAL_SEND_PARAMS" => "false", + "APPSIGNAL_SEND_REQUEST_PAYLOAD" => "true", + "APPSIGNAL_SEND_REQUEST_QUERY_PARAMETERS" => "true", "APPSIGNAL_SEND_SESSION_DATA" => "false", # Arrays "APPSIGNAL_DNS_SERVERS" => "8.8.8.8,8.8.4.4", + "APPSIGNAL_FILTER_ATTRIBUTES" => "attr1,attr2", + "APPSIGNAL_FILTER_FUNCTION_PARAMETERS" => "fn1,fn2", "APPSIGNAL_FILTER_METADATA" => "key1,key2", "APPSIGNAL_FILTER_PARAMETERS" => "param1,param2", + "APPSIGNAL_FILTER_REQUEST_PAYLOAD" => "payload1,payload2", + "APPSIGNAL_FILTER_REQUEST_QUERY_PARAMETERS" => "query1,query2", "APPSIGNAL_FILTER_SESSION_DATA" => "session1,session2", "APPSIGNAL_IGNORE_ACTIONS" => "action1,action2", "APPSIGNAL_IGNORE_ERRORS" => "ExampleStandardError,AnotherError", "APPSIGNAL_IGNORE_LOGS" => "^start$,^Completed 2.* in .*ms (.*)", "APPSIGNAL_IGNORE_NAMESPACES" => "admin,private_namespace", "APPSIGNAL_REQUEST_HEADERS" => "accept,accept-charset", + "APPSIGNAL_RESPONSE_HEADERS" => "x-response-1,x-response-2", # Floats "APPSIGNAL_CPU_COUNT" => "1.5" @@ -937,8 +955,12 @@ def on_load :enable_rake_performance_instrumentation => false, :endpoint => "https://push.appsignal.com", :files_world_accessible => true, + :filter_attributes => [], + :filter_function_parameters => [], :filter_metadata => [], :filter_parameters => [], + :filter_request_payload => [], + :filter_request_query_parameters => [], :filter_session_data => [], :ignore_actions => [], :ignore_errors => [], @@ -957,10 +979,15 @@ def on_load :ownership_set_namespace => false, :push_api_key => "abc", :request_headers => [], + :response_headers => [], :revision => "v2.5.1", :send_environment_metadata => true, + :send_function_parameters => nil, :send_params => true, + :send_request_payload => nil, + :send_request_query_parameters => nil, :send_session_data => true, + :service_name => nil, :sidekiq_report_errors => "all", :default_tags => {} ) @@ -1651,6 +1678,96 @@ def log_file_path 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 + + 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) { {} } From 1a7931f0a9cce92de2c16ece1c5600378fa05731 Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Wed, 20 May 2026 15:30:03 +0200 Subject: [PATCH 003/151] Emit custom metrics via OTel in collector mode When `collector_endpoint` is set, the metric helpers (set_gauge / increment_counter / add_distribution_value) now emit OpenTelemetry instruments to the collector instead of calling into the agent through the C extension. Introduces a swappable-backend pattern (Appsignal::Metrics dispatches per call based on `collector_mode?`) that future agent-only subsystems can adopt. Mirrors the Python integration's `appsignal/metrics.py`. --- lib/appsignal.rb | 1 + lib/appsignal/helpers/metrics.rb | 27 +--- lib/appsignal/metrics.rb | 23 +++ lib/appsignal/metrics/extension_backend.rb | 47 ++++++ .../metrics/opentelemetry_backend.rb | 89 +++++++++++ lib/appsignal/opentelemetry.rb | 7 + lib/appsignal/opentelemetry/attributes.rb | 31 ++++ .../collector_mode_metrics_spec.rb | 51 +++++++ .../runners/collector_mode_metrics.rb | 32 ++++ .../metrics/opentelemetry_backend_spec.rb | 143 ++++++++++++++++++ spec/lib/appsignal_spec.rb | 43 ++++++ 11 files changed, 470 insertions(+), 24 deletions(-) create mode 100644 lib/appsignal/metrics.rb create mode 100644 lib/appsignal/metrics/extension_backend.rb create mode 100644 lib/appsignal/metrics/opentelemetry_backend.rb create mode 100644 lib/appsignal/opentelemetry/attributes.rb create mode 100644 spec/integration/collector_mode_metrics_spec.rb create mode 100644 spec/integration/runners/collector_mode_metrics.rb create mode 100644 spec/lib/appsignal/metrics/opentelemetry_backend_spec.rb diff --git a/lib/appsignal.rb b/lib/appsignal.rb index 09ba72ce7..bede3d621 100644 --- a/lib/appsignal.rb +++ b/lib/appsignal.rb @@ -8,6 +8,7 @@ require "appsignal/utils/stdout_and_logger_message" require "appsignal/helpers/instrumentation" require "appsignal/helpers/metrics" +require "appsignal/metrics" # AppSignal for Ruby gem's main module. # diff --git a/lib/appsignal/helpers/metrics.rb b/lib/appsignal/helpers/metrics.rb index 5932b8a1f..0cc304a26 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::Metrics.backend.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::Metrics.backend.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::Metrics.backend.add_distribution_value(name, value, tags) end end end diff --git a/lib/appsignal/metrics.rb b/lib/appsignal/metrics.rb new file mode 100644 index 000000000..d871a80a5 --- /dev/null +++ b/lib/appsignal/metrics.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require "appsignal/metrics/extension_backend" +require "appsignal/metrics/opentelemetry_backend" + +module Appsignal + # @!visibility private + # + # Dispatches custom-metric helper calls to the backend that matches the + # active mode: the agent-backed extension in normal operation, or the + # OpenTelemetry SDK when `collector_endpoint` is configured. + module Metrics + class << self + def backend + if Appsignal.config&.collector_mode? + OpenTelemetryBackend + else + ExtensionBackend + end + end + end + end +end diff --git a/lib/appsignal/metrics/extension_backend.rb b/lib/appsignal/metrics/extension_backend.rb new file mode 100644 index 000000000..ad2d63909 --- /dev/null +++ b/lib/appsignal/metrics/extension_backend.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +module Appsignal + module Metrics + # @!visibility private + # + # Routes custom metric helper calls through the AppSignal C-extension, + # which forwards them to the agent. This is the default backend used + # when collector mode is not active. + module ExtensionBackend + class << self + def set_gauge(name, value, tags) + Appsignal::Extension.set_gauge( + name.to_s, + value.to_f, + Appsignal::Utils::Data.generate(tags) + ) + rescue RangeError + Appsignal.internal_logger + .warn("The gauge value '#{value}' for metric '#{name}' is too big") + end + + def increment_counter(name, value, tags) + Appsignal::Extension.increment_counter( + name.to_s, + value.to_f, + Appsignal::Utils::Data.generate(tags) + ) + rescue RangeError + Appsignal.internal_logger + .warn("The counter value '#{value}' for metric '#{name}' is too big") + end + + def add_distribution_value(name, value, tags) + Appsignal::Extension.add_distribution_value( + name.to_s, + value.to_f, + Appsignal::Utils::Data.generate(tags) + ) + rescue RangeError + Appsignal.internal_logger + .warn("The distribution value '#{value}' for metric '#{name}' is too big") + end + end + end + end +end diff --git a/lib/appsignal/metrics/opentelemetry_backend.rb b/lib/appsignal/metrics/opentelemetry_backend.rb new file mode 100644 index 000000000..188a73ae1 --- /dev/null +++ b/lib/appsignal/metrics/opentelemetry_backend.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +require "appsignal/opentelemetry/attributes" + +module Appsignal + module Metrics + # @!visibility private + # + # Routes custom metric helper calls through the OpenTelemetry metrics + # SDK using the meter provider configured at `Appsignal.start` time when + # collector mode is active. Mirrors the Python integration's + # `appsignal/metrics.py`: + # + # - `set_gauge` uses a synchronous OTel Gauge. + # - `increment_counter` uses an UpDownCounter so negative increments + # work (Counter would reject them). + # - `add_distribution_value` uses a Histogram. + # + # Instruments are created once per name and cached: the OTel SDK logs a + # "duplicate instrument registration" warning and swaps the instrument + # if `create_*` is called again for the same name. Tags attach at record + # time, not at instrument creation time. + module OpenTelemetryBackend + MUTEX = Mutex.new + + class << self + def set_gauge(name, value, tags) + instrument(:gauge, name).record( + value.to_f, + :attributes => Appsignal::OpenTelemetry::Attributes.format(tags) + ) + end + + def increment_counter(name, value, tags) + instrument(:up_down_counter, name).add( + value.to_f, + :attributes => Appsignal::OpenTelemetry::Attributes.format(tags) + ) + end + + def add_distribution_value(name, value, tags) + instrument(:histogram, name).record( + value.to_f, + :attributes => Appsignal::OpenTelemetry::Attributes.format(tags) + ) + end + + # @!visibility private + # + # Test-only. Drops the cached meter and instruments so the next + # call re-resolves `OpenTelemetry.meter_provider`. + def reset! + MUTEX.synchronize do + @meter = nil + @gauges = nil + @counters = nil + @histograms = nil + end + end + + private + + # Fetch the named instrument, creating and caching it on first use. + # The lookup-or-create runs under the mutex so two concurrent + # first-time calls don't both create the instrument (which would + # make the SDK log a duplicate-registration warning). + def instrument(kind, name) + name = name.to_s + MUTEX.synchronize do + case kind + when :gauge + (@gauges ||= {})[name] ||= meter.create_gauge(name) + when :up_down_counter + (@counters ||= {})[name] ||= meter.create_up_down_counter(name) + when :histogram + (@histograms ||= {})[name] ||= meter.create_histogram(name) + end + end + end + + # Only called from `instrument` while the mutex is held, so the plain + # memoisation needs no extra locking of its own. + def meter + @meter ||= ::OpenTelemetry.meter_provider.meter("appsignal-helpers") + end + end + end + end +end diff --git a/lib/appsignal/opentelemetry.rb b/lib/appsignal/opentelemetry.rb index 33cd27720..16e63b520 100644 --- a/lib/appsignal/opentelemetry.rb +++ b/lib/appsignal/opentelemetry.rb @@ -12,6 +12,13 @@ class << self # Lazily requires the OpenTelemetry SDK and OTLP exporter gems so that # users not in collector mode do not pay the load cost. 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" + require "opentelemetry/sdk" require "opentelemetry/exporter/otlp" require "opentelemetry-metrics-sdk" 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/spec/integration/collector_mode_metrics_spec.rb b/spec/integration/collector_mode_metrics_spec.rb new file mode 100644 index 000000000..c1144c4ab --- /dev/null +++ b/spec/integration/collector_mode_metrics_spec.rb @@ -0,0 +1,51 @@ +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") + runner.run + + expect(runner.status.exitstatus).to eq(0), "runner failed:\n#{runner.output}" + expect(runner.output).to include("DONE") + + 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 diff --git a/spec/integration/runners/collector_mode_metrics.rb b/spec/integration/runners/collector_mode_metrics.rb new file mode 100644 index 000000000..5c06b302b --- /dev/null +++ b/spec/integration/runners/collector_mode_metrics.rb @@ -0,0 +1,32 @@ +PROJECT_ROOT = "../../../".freeze +$LOAD_PATH.unshift(File.expand_path("ext", PROJECT_ROOT)) +$LOAD_PATH.unshift(File.expand_path("lib", PROJECT_ROOT)) + +require "fileutils" +require "appsignal" + +Appsignal.configure(:test) do |config| + config.active = true + config.push_api_key = "abc" + config.name = "collector-mode-test" + config.collector_endpoint = "http://127.0.0.1:9090" + + working_directory = "tmp/appsignal" + FileUtils.rm_rf(working_directory) + FileUtils.mkdir_p(working_directory) + config.working_directory_path = File.join(__dir__, working_directory) +end + +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") + +# Force-flush so the spec can assert on the queued request deterministically. +OpenTelemetry.meter_provider.force_flush + +puts "DONE" 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..2e3d0d143 --- /dev/null +++ b/spec/lib/appsignal/metrics/opentelemetry_backend_spec.rb @@ -0,0 +1,143 @@ +# frozen_string_literal: true + +require "opentelemetry/sdk" +require "opentelemetry-metrics-sdk" + +describe Appsignal::Metrics::OpenTelemetryBackend 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 423ee99ed..78e6e0981 100644 --- a/spec/lib/appsignal_spec.rb +++ b/spec/lib/appsignal_spec.rb @@ -1590,6 +1590,49 @@ def on_start Appsignal.add_distribution_value("key", 10) end end + + context "when collector mode is active" do + before do + Appsignal.clear! + start_agent(:options => { :collector_endpoint => "http://127.0.0.1:9090" }) + end + + describe ".set_gauge" do + it "routes through the OpenTelemetry backend, not the extension" do + allow(Appsignal::Metrics::OpenTelemetryBackend).to receive(:set_gauge) + expect(Appsignal::Extension).not_to receive(:set_gauge) + + Appsignal.set_gauge("key", 0.1, tags) + + expect(Appsignal::Metrics::OpenTelemetryBackend).to have_received(:set_gauge) + .with("key", 0.1, tags) + end + end + + describe ".increment_counter" do + it "routes through the OpenTelemetry backend, not the extension" do + allow(Appsignal::Metrics::OpenTelemetryBackend).to receive(:increment_counter) + expect(Appsignal::Extension).not_to receive(:increment_counter) + + Appsignal.increment_counter("key", 5, tags) + + expect(Appsignal::Metrics::OpenTelemetryBackend).to have_received(:increment_counter) + .with("key", 5, tags) + end + end + + describe ".add_distribution_value" do + it "routes through the OpenTelemetry backend, not the extension" do + allow(Appsignal::Metrics::OpenTelemetryBackend).to receive(:add_distribution_value) + expect(Appsignal::Extension).not_to receive(:add_distribution_value) + + Appsignal.add_distribution_value("key", 0.1, tags) + + expect(Appsignal::Metrics::OpenTelemetryBackend) + .to have_received(:add_distribution_value).with("key", 0.1, tags) + end + end + end end describe ".internal_logger" do From 675542ce802adbf7b14207c4236c3cc493b9df75 Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Wed, 20 May 2026 16:47:08 +0200 Subject: [PATCH 004/151] Route Logger via OTel under collector mode When `collector_endpoint` is set, `Appsignal::Logger#add` now emits OpenTelemetry log records via the configured logger provider instead of calling `Appsignal::Extension.log`. The existing extension path is preserved when collector mode is off. Each emitted record carries `appsignal.group` and `appsignal.format` attributes so the AppSignal collector and processor can group lines by logger and apply per-line message parsing without having to look at the source-wide config. --- lib/appsignal.rb | 2 +- lib/appsignal/backends.rb | 44 +++++ lib/appsignal/helpers/metrics.rb | 6 +- lib/appsignal/logger.rb | 22 +-- lib/appsignal/logger/extension_backend.rb | 24 +++ lib/appsignal/logger/opentelemetry_backend.rb | 78 +++++++++ lib/appsignal/metrics.rb | 23 --- spec/integration/collector_mode_logs_spec.rb | 46 ++++++ .../runners/collector_mode_logs.rb | 39 +++++ spec/lib/appsignal/backends_spec.rb | 67 ++++++++ .../logger/opentelemetry_backend_spec.rb | 153 ++++++++++++++++++ spec/lib/appsignal/logger_spec.rb | 40 +++++ 12 files changed, 508 insertions(+), 36 deletions(-) create mode 100644 lib/appsignal/backends.rb create mode 100644 lib/appsignal/logger/extension_backend.rb create mode 100644 lib/appsignal/logger/opentelemetry_backend.rb delete mode 100644 lib/appsignal/metrics.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 diff --git a/lib/appsignal.rb b/lib/appsignal.rb index bede3d621..82a0dc3bc 100644 --- a/lib/appsignal.rb +++ b/lib/appsignal.rb @@ -8,7 +8,7 @@ require "appsignal/utils/stdout_and_logger_message" require "appsignal/helpers/instrumentation" require "appsignal/helpers/metrics" -require "appsignal/metrics" +require "appsignal/backends" # AppSignal for Ruby gem's main module. # diff --git a/lib/appsignal/backends.rb b/lib/appsignal/backends.rb new file mode 100644 index 000000000..18f9979e0 --- /dev/null +++ b/lib/appsignal/backends.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +require "appsignal/metrics/extension_backend" +require "appsignal/metrics/opentelemetry_backend" +require "appsignal/logger/extension_backend" +require "appsignal/logger/opentelemetry_backend" + +module Appsignal + # @!visibility private + # + # Looks up the active backend for each AppSignal subsystem. In normal + # operation, subsystems route through the C-extension (and its agent). + # When collector mode is configured and the OpenTelemetry SDK has booted + # successfully, supported subsystems route through OTel instead. + # + # Centralizes the mode-check so per-subsystem call sites don't repeat the + # "if collector? then OTel else Extension" branch. Future subsystems plug + # in by adding one more lookup method here. + module Backends + class << self + def metrics + if collector? + Appsignal::Metrics::OpenTelemetryBackend + else + Appsignal::Metrics::ExtensionBackend + end + end + + def logger + if collector? + Appsignal::Logger::OpenTelemetryBackend + else + Appsignal::Logger::ExtensionBackend + end + end + + private + + def collector? + Appsignal.config&.collector_mode? || false + end + end + end +end diff --git a/lib/appsignal/helpers/metrics.rb b/lib/appsignal/helpers/metrics.rb index 0cc304a26..ded4d0ec7 100644 --- a/lib/appsignal/helpers/metrics.rb +++ b/lib/appsignal/helpers/metrics.rb @@ -16,7 +16,7 @@ module Metrics # @see https://docs.appsignal.com/metrics/custom.html # Metrics documentation def set_gauge(name, value, tags = {}) - Appsignal::Metrics.backend.set_gauge(name, value, tags) + Appsignal::Backends.metrics.set_gauge(name, value, tags) end # Report a counter metric. @@ -32,7 +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::Metrics.backend.increment_counter(name, value, tags) + Appsignal::Backends.metrics.increment_counter(name, value, tags) end # Report a distribution metric. @@ -48,7 +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::Metrics.backend.add_distribution_value(name, value, tags) + Appsignal::Backends.metrics.add_distribution_value(name, value, tags) end end end diff --git a/lib/appsignal/logger.rb b/lib/appsignal/logger.rb index c912e5401..e41335ae8 100644 --- a/lib/appsignal/logger.rb +++ b/lib/appsignal/logger.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true require "logger" -require "set" module Appsignal # Logger that flushes logs to the AppSignal logging service. @@ -55,6 +54,8 @@ def to_proc # @!visibility private AUTODETECT = 3 # @!visibility private + FORMATS = [PLAINTEXT, LOGFMT, JSON, AUTODETECT].freeze + # @!visibility private SEVERITY_MAP = { DEBUG => 2, INFO => 3, @@ -83,7 +84,7 @@ def initialize(group, level: INFO, format: AUTODETECT, attributes: {}) @group = group @level = level @silenced = false - @format = format + @format = validated_format(format) @mutex = Mutex.new @default_attributes = attributes @appsignal_attributes = attributes @@ -145,13 +146,7 @@ def add(severity, message = nil, group = nil, &block) message = formatter.call(severity, Time.now, group, message) if formatter - Appsignal::Extension.log( - group, - SEVERITY_MAP.fetch(severity, 0), - @format, - message.to_s, - Appsignal::Utils::Data.generate(appsignal_attributes) - ) + Appsignal::Backends.logger.emit(group, severity, @format, message.to_s, appsignal_attributes) false end @@ -299,5 +294,14 @@ def add_with_attributes(severity, message, group, attributes, &block) ensure @appsignal_attributes = default_attributes end + + def validated_format(format) + return format if FORMATS.include?(format) + + Appsignal.internal_logger.warn( + "Unknown Appsignal::Logger format #{format.inspect}; falling back to AUTODETECT" + ) + AUTODETECT + end end end diff --git a/lib/appsignal/logger/extension_backend.rb b/lib/appsignal/logger/extension_backend.rb new file mode 100644 index 000000000..3ab716d54 --- /dev/null +++ b/lib/appsignal/logger/extension_backend.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Appsignal + class Logger < ::Logger + # @!visibility private + # + # Routes Appsignal::Logger emits through the AppSignal C-extension, + # which forwards them to the agent. This is the default backend used + # when collector mode is not active. + module ExtensionBackend + class << self + def emit(group, severity, format, message, attributes) + Appsignal::Extension.log( + group, + SEVERITY_MAP.fetch(severity, 0), + format, + message, + Appsignal::Utils::Data.generate(attributes) + ) + end + end + end + end +end diff --git a/lib/appsignal/logger/opentelemetry_backend.rb b/lib/appsignal/logger/opentelemetry_backend.rb new file mode 100644 index 000000000..1cf4f62be --- /dev/null +++ b/lib/appsignal/logger/opentelemetry_backend.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +require "appsignal/opentelemetry/attributes" + +module Appsignal + class Logger < ::Logger + # @!visibility private + # + # Routes Appsignal::Logger emits through the OpenTelemetry logs SDK + # using the logger provider configured at `Appsignal.start` time when + # collector mode is active. + # + # Each emit attaches two well-known attributes that the AppSignal + # collector consumes: + # + # - `appsignal.group` — overrides the collector's default + # `service.name`-based grouping with the logger's `group` argument. + # - `appsignal.format` — the lowercase parse-format name + # (`plaintext`/`logfmt`/`json`/`autodetect`) the processor uses to + # extract structured attributes from the message body. + module OpenTelemetryBackend + # Maps Ruby `::Logger` severities to OTel SeverityNumber + the + # human-readable severity text. + OTEL_SEVERITY_MAP = { + ::Logger::DEBUG => [5, "DEBUG"], + ::Logger::INFO => [9, "INFO"], + ::Logger::WARN => [13, "WARN"], + ::Logger::ERROR => [17, "ERROR"], + ::Logger::FATAL => [21, "FATAL"] + }.freeze + + # Maps the integer parse-format flag on `Appsignal::Logger` to the + # lowercase string the AppSignal collector and processor share. + FORMAT_NAMES = { + Appsignal::Logger::PLAINTEXT => "plaintext", + Appsignal::Logger::LOGFMT => "logfmt", + Appsignal::Logger::JSON => "json", + Appsignal::Logger::AUTODETECT => "autodetect" + }.freeze + + MUTEX = Mutex.new + + class << self + def emit(group, severity, format, message, attributes) + number, text = OTEL_SEVERITY_MAP.fetch(severity, [0, nil]) + otel_attributes = Appsignal::OpenTelemetry::Attributes.format(attributes) + otel_attributes["appsignal.group"] = group.to_s + otel_attributes["appsignal.format"] = FORMAT_NAMES.fetch(format, "autodetect") + logger.on_emit( + :severity_number => number, + :severity_text => text, + :body => message, + :attributes => otel_attributes + ) + end + + # @!visibility private + # + # Test-only. Drops the cached logger so the next call re-resolves + # `OpenTelemetry.logger_provider`. + def reset! + MUTEX.synchronize { @logger = nil } + end + + private + + # Double-checked locking: read the cached logger without the + # mutex on the hot path, take the lock and re-check only on the + # first call. + def logger + @logger || MUTEX.synchronize do + @logger ||= ::OpenTelemetry.logger_provider.logger(:name => "appsignal-logger") + end + end + end + end + end +end diff --git a/lib/appsignal/metrics.rb b/lib/appsignal/metrics.rb deleted file mode 100644 index d871a80a5..000000000 --- a/lib/appsignal/metrics.rb +++ /dev/null @@ -1,23 +0,0 @@ -# frozen_string_literal: true - -require "appsignal/metrics/extension_backend" -require "appsignal/metrics/opentelemetry_backend" - -module Appsignal - # @!visibility private - # - # Dispatches custom-metric helper calls to the backend that matches the - # active mode: the agent-backed extension in normal operation, or the - # OpenTelemetry SDK when `collector_endpoint` is configured. - module Metrics - class << self - def backend - if Appsignal.config&.collector_mode? - OpenTelemetryBackend - else - ExtensionBackend - 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..b93c4159f --- /dev/null +++ b/spec/integration/collector_mode_logs_spec.rb @@ -0,0 +1,46 @@ +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") + runner.run + + expect(runner.status.exitstatus).to eq(0), "runner failed:\n#{runner.output}" + expect(runner.output).to include("DONE") + + 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 diff --git a/spec/integration/runners/collector_mode_logs.rb b/spec/integration/runners/collector_mode_logs.rb new file mode 100644 index 000000000..9907d679d --- /dev/null +++ b/spec/integration/runners/collector_mode_logs.rb @@ -0,0 +1,39 @@ +PROJECT_ROOT = "../../../".freeze +$LOAD_PATH.unshift(File.expand_path("ext", PROJECT_ROOT)) +$LOAD_PATH.unshift(File.expand_path("lib", PROJECT_ROOT)) + +require "fileutils" +require "appsignal" + +Appsignal.configure(:test) do |config| + config.active = true + config.push_api_key = "abc" + config.name = "collector-mode-test" + config.collector_endpoint = "http://127.0.0.1:9090" + + working_directory = "tmp/appsignal" + FileUtils.rm_rf(working_directory) + FileUtils.mkdir_p(working_directory) + config.working_directory_path = File.join(__dir__, working_directory) +end + +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") + +# Force-flush so the spec can assert on the queued request deterministically. +OpenTelemetry.logger_provider.force_flush + +puts "DONE" diff --git a/spec/lib/appsignal/backends_spec.rb b/spec/lib/appsignal/backends_spec.rb new file mode 100644 index 000000000..186dacce0 --- /dev/null +++ b/spec/lib/appsignal/backends_spec.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +describe Appsignal::Backends do + describe ".metrics" do + context "when no config is loaded" do + before { allow(Appsignal).to receive(:config).and_return(nil) } + + it "returns the extension backend" do + expect(described_class.metrics).to eq(Appsignal::Metrics::ExtensionBackend) + end + end + + context "when collector mode is not active" do + before do + config = instance_double(Appsignal::Config, :collector_mode? => false) + allow(Appsignal).to receive(:config).and_return(config) + end + + it "returns the extension backend" do + expect(described_class.metrics).to eq(Appsignal::Metrics::ExtensionBackend) + end + end + + context "when collector mode is active" do + before do + config = instance_double(Appsignal::Config, :collector_mode? => true) + allow(Appsignal).to receive(:config).and_return(config) + end + + it "returns the OpenTelemetry backend" do + expect(described_class.metrics).to eq(Appsignal::Metrics::OpenTelemetryBackend) + end + end + end + + describe ".logger" do + context "when no config is loaded" do + before { allow(Appsignal).to receive(:config).and_return(nil) } + + it "returns the extension backend" do + expect(described_class.logger).to eq(Appsignal::Logger::ExtensionBackend) + end + end + + context "when collector mode is not active" do + before do + config = instance_double(Appsignal::Config, :collector_mode? => false) + allow(Appsignal).to receive(:config).and_return(config) + end + + it "returns the extension backend" do + expect(described_class.logger).to eq(Appsignal::Logger::ExtensionBackend) + end + end + + context "when collector mode is active" do + before do + config = instance_double(Appsignal::Config, :collector_mode? => true) + allow(Appsignal).to receive(:config).and_return(config) + end + + it "returns the OpenTelemetry backend" do + expect(described_class.logger).to eq(Appsignal::Logger::OpenTelemetryBackend) + end + end + end +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..c95e9c953 --- /dev/null +++ b/spec/lib/appsignal/logger/opentelemetry_backend_spec.rb @@ -0,0 +1,153 @@ +# frozen_string_literal: true + +require "opentelemetry/sdk" +require "opentelemetry-logs-sdk" + +describe Appsignal::Logger::OpenTelemetryBackend 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..a2142e0b7 100644 --- a/spec/lib/appsignal/logger_spec.rb +++ b/spec/lib/appsignal/logger_spec.rb @@ -238,6 +238,29 @@ end.to raise_error(TypeError) end + describe "format validation" do + it "accepts the documented format constants" 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 + + it "warns and falls back to AUTODETECT for an unknown format" 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 + describe "#add" do it "should log with a level and message" do expect(Appsignal::Extension).to receive(:log) @@ -703,4 +726,21 @@ it_behaves_like "tagged logging" end end + + context "when collector mode is active" do + before do + Appsignal.clear! + start_agent(:options => { :collector_endpoint => "http://127.0.0.1:9090" }) + end + + it "routes through the OpenTelemetry backend, not the extension" do + allow(Appsignal::Logger::OpenTelemetryBackend).to receive(:emit) + expect(Appsignal::Extension).not_to receive(:log) + + logger.info("Hello", :tag => "value") + + expect(Appsignal::Logger::OpenTelemetryBackend).to have_received(:emit) + .with("group", ::Logger::INFO, Appsignal::Logger::AUTODETECT, "Hello", { :tag => "value" }) + end + end end From e2099590079d5341456f882b6e27f455d53b4172 Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Wed, 3 Jun 2026 10:13:02 +0200 Subject: [PATCH 005/151] Suppress the OTel SDK's auto-configured exporters With the metrics and logs SDK gems loaded, `OpenTelemetry::SDK.configure` auto-installs a metrics reader and a log processor based on the OTEL_METRICS_EXPORTER / OTEL_LOGS_EXPORTER env vars, which default to "otlp" pointed at the default OTLP endpoint. Each spawns a background thread. Collector mode replaces both providers with its own right after, orphaning those threads where no shutdown can reach them. Default the env vars to "none" so the SDK skips the auto-setup; the exporters collector mode builds explicitly are the only ones that should run. User-set values still win. [skip changeset] --- lib/appsignal/opentelemetry.rb | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/lib/appsignal/opentelemetry.rb b/lib/appsignal/opentelemetry.rb index 16e63b520..88648bf0f 100644 --- a/lib/appsignal/opentelemetry.rb +++ b/lib/appsignal/opentelemetry.rb @@ -19,6 +19,16 @@ def configure(config) # this preference, per the OTel spec.) ENV["OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE"] ||= "delta" + # With the metrics and logs SDK gems loaded, `SDK.configure` below + # auto-installs a metrics reader and a log processor from these env + # vars (both default to "otlp", pointed at the default OTLP + # endpoint), each with its own background thread. We replace both + # providers with our own right after, which would orphan those + # threads -- unreachable by any shutdown. Suppress the auto-setup; + # the exporters we build below are the only ones that should run. + ENV["OTEL_METRICS_EXPORTER"] ||= "none" + ENV["OTEL_LOGS_EXPORTER"] ||= "none" + require "opentelemetry/sdk" require "opentelemetry/exporter/otlp" require "opentelemetry-metrics-sdk" From 7686f026d6c8036616a32cc6a7fe9783cf4d6594 Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Thu, 21 May 2026 15:32:53 +0200 Subject: [PATCH 006/151] Generalize integration runner working directory The runner now creates a per-run temp dir under `/tmp` and passes it as `APPSIGNAL_WORKING_DIRECTORY_PATH` to the spawned subprocess, so runner scripts no longer manage one each. Cleaned up on exit. `/tmp` rather than `$TMPDIR` because macOS's default tmpdir resolves under `/var/folders/...` and the agent's unix socket path would exceed the 104-char limit, hanging `Appsignal::Extension.stop`. The runner also raises on non-zero exit status, so each spec drops its own redundant exit-status assertion. --- spec/integration/collector_mode_logs_spec.rb | 3 -- .../collector_mode_metrics_spec.rb | 3 -- spec/integration/collector_mode_spec.rb | 3 -- spec/integration/runner.rb | 45 ++++++++++++++++++- .../runners/collector_mode_emit.rb | 6 --- .../runners/collector_mode_logs.rb | 6 --- .../runners/collector_mode_metrics.rb | 6 --- spec/integration/runners/stop_with_trap.rb | 8 ---- spec/integration/stop_spec.rb | 3 -- spec/support/helpers/otlp_collector_server.rb | 7 +++ 10 files changed, 51 insertions(+), 39 deletions(-) diff --git a/spec/integration/collector_mode_logs_spec.rb b/spec/integration/collector_mode_logs_spec.rb index b93c4159f..ffe568abd 100644 --- a/spec/integration/collector_mode_logs_spec.rb +++ b/spec/integration/collector_mode_logs_spec.rb @@ -8,9 +8,6 @@ runner = Runner.new("collector_mode_logs") runner.run - expect(runner.status.exitstatus).to eq(0), "runner failed:\n#{runner.output}" - expect(runner.output).to include("DONE") - log_req = OTLPCollectorServer.listen_to("/v1/logs") log_msg = Opentelemetry::Proto::Collector::Logs::V1::ExportLogsServiceRequest .decode(log_req[:body]) diff --git a/spec/integration/collector_mode_metrics_spec.rb b/spec/integration/collector_mode_metrics_spec.rb index c1144c4ab..ba2a04b61 100644 --- a/spec/integration/collector_mode_metrics_spec.rb +++ b/spec/integration/collector_mode_metrics_spec.rb @@ -8,9 +8,6 @@ runner = Runner.new("collector_mode_metrics") runner.run - expect(runner.status.exitstatus).to eq(0), "runner failed:\n#{runner.output}" - expect(runner.output).to include("DONE") - metric_req = OTLPCollectorServer.listen_to("/v1/metrics") metric_msg = Opentelemetry::Proto::Collector::Metrics::V1::ExportMetricsServiceRequest .decode(metric_req[:body]) diff --git a/spec/integration/collector_mode_spec.rb b/spec/integration/collector_mode_spec.rb index 4d16fedeb..0abc86ffc 100644 --- a/spec/integration/collector_mode_spec.rb +++ b/spec/integration/collector_mode_spec.rb @@ -63,9 +63,6 @@ def expect_appsignal_resource(resource) # rubocop:disable Metrics/AbcSize runner = Runner.new("collector_mode_emit") runner.run - expect(runner.status.exitstatus).to eq(0), "runner failed:\n#{runner.output}" - expect(runner.output).to include("DONE") - # Config wiring: the child process saw the value and computed the predicate. expect(runner.output).to include("collector_endpoint=http://127.0.0.1:9090") expect(runner.output).to include("collector_mode?=true") 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 index 1b90a6fc2..4f7117f46 100644 --- a/spec/integration/runners/collector_mode_emit.rb +++ b/spec/integration/runners/collector_mode_emit.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" Appsignal.configure(:test) do |config| @@ -18,11 +17,6 @@ config.send_request_payload = false config.ignore_actions = ["IgnoredController#action"] config.ignore_namespaces = ["background"] - - working_directory = "tmp/appsignal" - FileUtils.rm_rf(working_directory) - FileUtils.mkdir_p(working_directory) - config.working_directory_path = File.join(__dir__, working_directory) end Appsignal.start diff --git a/spec/integration/runners/collector_mode_logs.rb b/spec/integration/runners/collector_mode_logs.rb index 9907d679d..45c43977e 100644 --- a/spec/integration/runners/collector_mode_logs.rb +++ b/spec/integration/runners/collector_mode_logs.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" Appsignal.configure(:test) do |config| @@ -10,11 +9,6 @@ config.push_api_key = "abc" config.name = "collector-mode-test" config.collector_endpoint = "http://127.0.0.1:9090" - - working_directory = "tmp/appsignal" - FileUtils.rm_rf(working_directory) - FileUtils.mkdir_p(working_directory) - config.working_directory_path = File.join(__dir__, working_directory) end Appsignal.start diff --git a/spec/integration/runners/collector_mode_metrics.rb b/spec/integration/runners/collector_mode_metrics.rb index 5c06b302b..8497e1758 100644 --- a/spec/integration/runners/collector_mode_metrics.rb +++ b/spec/integration/runners/collector_mode_metrics.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" Appsignal.configure(:test) do |config| @@ -10,11 +9,6 @@ config.push_api_key = "abc" config.name = "collector-mode-test" config.collector_endpoint = "http://127.0.0.1:9090" - - working_directory = "tmp/appsignal" - FileUtils.rm_rf(working_directory) - FileUtils.mkdir_p(working_directory) - config.working_directory_path = File.join(__dir__, working_directory) end Appsignal.start diff --git a/spec/integration/runners/stop_with_trap.rb b/spec/integration/runners/stop_with_trap.rb index adb9e5938..95da17463 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 @@ -17,13 +16,6 @@ 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 Appsignal.start 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/support/helpers/otlp_collector_server.rb b/spec/support/helpers/otlp_collector_server.rb index ca1d4285e..3c391e1a5 100644 --- a/spec/support/helpers/otlp_collector_server.rb +++ b/spec/support/helpers/otlp_collector_server.rb @@ -29,6 +29,13 @@ 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 From 41253d3ee8958dd927ad9e1089537c88204638f3 Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Thu, 21 May 2026 15:33:58 +0200 Subject: [PATCH 007/151] Shut down OpenTelemetry SDK on Appsignal.stop Without this, the PeriodicMetricReader and BatchLogRecordProcessor hold their buffers until the next interval tick, so anything emitted shortly before exit gets dropped. `shutdown` on each provider drains the queues synchronously before returning. Only called in collector mode; the no-op proxy providers exposed by the OTel API gems outside collector mode don't define `shutdown`. Adds an integration test that emits a counter and a log line, then calls Appsignal.stop without any explicit force_flush, and asserts both reach the mock OTLP collector. --- lib/appsignal.rb | 6 + lib/appsignal/opentelemetry.rb | 38 +++ .../collector_mode_stop_flush_spec.rb | 29 ++ .../runners/collector_mode_stop_flush.rb | 28 ++ spec/lib/appsignal/opentelemetry_spec.rb | 302 ++++++++++++++++++ spec/lib/appsignal_spec.rb | 25 ++ spec/spec_helper.rb | 1 + 7 files changed, 429 insertions(+) create mode 100644 spec/integration/collector_mode_stop_flush_spec.rb create mode 100644 spec/integration/runners/collector_mode_stop_flush.rb create mode 100644 spec/lib/appsignal/opentelemetry_spec.rb diff --git a/lib/appsignal.rb b/lib/appsignal.rb index 82a0dc3bc..79d57ba4d 100644 --- a/lib/appsignal.rb +++ b/lib/appsignal.rb @@ -9,6 +9,7 @@ require "appsignal/helpers/instrumentation" require "appsignal/helpers/metrics" require "appsignal/backends" +require "appsignal/opentelemetry" # AppSignal for Ruby gem's main module. # @@ -247,6 +248,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})") @@ -256,6 +261,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/opentelemetry.rb b/lib/appsignal/opentelemetry.rb index 88648bf0f..89ba934dd 100644 --- a/lib/appsignal/opentelemetry.rb +++ b/lib/appsignal/opentelemetry.rb @@ -11,6 +11,10 @@ class << self # # Lazily requires the OpenTelemetry SDK and OTLP exporter gems so that # users not in collector mode do not pay the load cost. + # + # Sets `@started` to `true` on success, `false` if the SDK gems can't be + # loaded or any other error occurs. Callers can read this via + # {.started?} to decide whether to route through the OTel backends. def configure(config) # The OTel Ruby SDK exposes no programmatic knob for the default # aggregation temporality; this env var is the only way to set @@ -79,17 +83,51 @@ def configure(config) ) ) ) + + @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 + + # @!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. 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..7c7d5f2a7 --- /dev/null +++ b/spec/integration/collector_mode_stop_flush_spec.rb @@ -0,0 +1,29 @@ +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") + 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 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..d7d4010c0 --- /dev/null +++ b/spec/integration/runners/collector_mode_stop_flush.rb @@ -0,0 +1,28 @@ +PROJECT_ROOT = "../../../".freeze +$LOAD_PATH.unshift(File.expand_path("ext", PROJECT_ROOT)) +$LOAD_PATH.unshift(File.expand_path("lib", PROJECT_ROOT)) + +require "appsignal" + +Appsignal.configure(:test) do |config| + config.active = true + config.push_api_key = "abc" + config.name = "collector-mode-test" + config.collector_endpoint = "http://127.0.0.1:9090" +end + +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/opentelemetry_spec.rb b/spec/lib/appsignal/opentelemetry_spec.rb new file mode 100644 index 000000000..de835f974 --- /dev/null +++ b/spec/lib/appsignal/opentelemetry_spec.rb @@ -0,0 +1,302 @@ +# 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.ruby_3_1_or_newer? + 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 ".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.service.process_id"]).to eq(Process.pid) + 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 78e6e0981..cfc3fc94c 100644 --- a/spec/lib/appsignal_spec.rb +++ b/spec/lib/appsignal_spec.rb @@ -934,6 +934,31 @@ def on_start expect(Appsignal::CheckIn.scheduler).to receive(:stop) Appsignal.stop end + + 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 + + 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 884726845..619a7b7da 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -99,6 +99,7 @@ def spec_system_tmp_dir config.after do OTLPCollectorServer.clear if defined?(OTLPCollectorServer) + Appsignal::OpenTelemetry.reset! end config.before :context do From 315f005afd3e267a46cf8c1a209e7d54c5d8d7b3 Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Wed, 3 Jun 2026 17:18:37 +0200 Subject: [PATCH 008/151] Use Appsignal.stop to flush runners The integration runner scripts called `force_flush` directly on the OpenTelemetry providers to drain buffered telemetry before exiting. `Appsignal.stop` already does this by shutting the OTel SDK down, and the public API is what an application would actually use. Swap the manual `force_flush` calls for `Appsignal.stop`. The dedicated `collector_mode_stop_flush` spec already proves stop flushes the providers; the other runners now exercise the same path. [skip changeset] --- spec/integration/runners/collector_mode_emit.rb | 7 +++---- spec/integration/runners/collector_mode_logs.rb | 5 +++-- spec/integration/runners/collector_mode_metrics.rb | 5 +++-- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/spec/integration/runners/collector_mode_emit.rb b/spec/integration/runners/collector_mode_emit.rb index 4f7117f46..cbb87cd87 100644 --- a/spec/integration/runners/collector_mode_emit.rb +++ b/spec/integration/runners/collector_mode_emit.rb @@ -36,9 +36,8 @@ logger = OpenTelemetry.logger_provider.logger(:name => "collector-mode-runner") logger.on_emit(:severity_text => "INFO", :body => "test-log-line") -# Force-flush so the spec can assert on the queued requests deterministically. -OpenTelemetry.tracer_provider.force_flush -OpenTelemetry.meter_provider.force_flush -OpenTelemetry.logger_provider.force_flush +# Shut AppSignal down so the OTel providers drain their buffers and the +# spec sees the queued requests deterministically. +Appsignal.stop("integration test") puts "DONE" diff --git a/spec/integration/runners/collector_mode_logs.rb b/spec/integration/runners/collector_mode_logs.rb index 45c43977e..fcd140eab 100644 --- a/spec/integration/runners/collector_mode_logs.rb +++ b/spec/integration/runners/collector_mode_logs.rb @@ -27,7 +27,8 @@ logger.warn("warn line") logger.error("error line") -# Force-flush so the spec can assert on the queued request deterministically. -OpenTelemetry.logger_provider.force_flush +# Shut AppSignal down so the OTel providers drain their buffers and the +# spec sees the queued request deterministically. +Appsignal.stop("integration test") puts "DONE" diff --git a/spec/integration/runners/collector_mode_metrics.rb b/spec/integration/runners/collector_mode_metrics.rb index 8497e1758..ec2ffb68a 100644 --- a/spec/integration/runners/collector_mode_metrics.rb +++ b/spec/integration/runners/collector_mode_metrics.rb @@ -20,7 +20,8 @@ Appsignal.set_gauge("test_gauge", 42.5, :tag => "value") Appsignal.add_distribution_value("test_distribution", 0.123, :tag => "value") -# Force-flush so the spec can assert on the queued request deterministically. -OpenTelemetry.meter_provider.force_flush +# Shut AppSignal down so the OTel providers drain their buffers and the +# spec sees the queued request deterministically. +Appsignal.stop("integration test") puts "DONE" From 260c72d695bccdb1dc2751da6c3402f25332ab31 Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Thu, 21 May 2026 15:34:44 +0200 Subject: [PATCH 009/151] Lock down Appsignal.forked behavior under OTel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tightens the .forked unit tests to assert the order (logger first, then extension), that the extension is not stopped before the restart, and that minutely probes are not restarted (the probe thread dies on fork by design). Adds an integration test that forks a child, emits a metric, and asserts it reaches the mock OTLP collector — verifying the OTel Ruby SDK's built-in fork hooks restart the PeriodicMetricReader in the child without us needing to wire anything up. If a future refactor drops the `OpenTelemetry::SDK.configure` call (or otherwise disconnects the fork hooks), this spec fails. --- spec/integration/collector_mode_fork_spec.rb | 34 +++++++++++++++++ .../runners/collector_mode_fork.rb | 38 +++++++++++++++++++ spec/lib/appsignal_spec.rb | 22 +++++++++-- 3 files changed, 91 insertions(+), 3 deletions(-) create mode 100644 spec/integration/collector_mode_fork_spec.rb create mode 100644 spec/integration/runners/collector_mode_fork.rb diff --git a/spec/integration/collector_mode_fork_spec.rb b/spec/integration/collector_mode_fork_spec.rb new file mode 100644 index 000000000..53f94c725 --- /dev/null +++ b/spec/integration/collector_mode_fork_spec.rb @@ -0,0 +1,34 @@ +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").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 diff --git a/spec/integration/runners/collector_mode_fork.rb b/spec/integration/runners/collector_mode_fork.rb new file mode 100644 index 000000000..3ae9af4e8 --- /dev/null +++ b/spec/integration/runners/collector_mode_fork.rb @@ -0,0 +1,38 @@ +# 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" + +Appsignal.configure(:test) do |config| + config.active = true + config.push_api_key = "abc" + config.name = "collector-mode-test" + config.collector_endpoint = "http://127.0.0.1:9090" +end + +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/lib/appsignal_spec.rb b/spec/lib/appsignal_spec.rb index cfc3fc94c..606fcb6e5 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 From 077b89c3676d76d15d553974829b97726fdcba1b Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Fri, 22 May 2026 13:08:42 +0200 Subject: [PATCH 010/151] Gate collector mode on Ruby 3.1 or newer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The OpenTelemetry SDK's fork hooks rely on `Process._fork`, which only exists on Ruby 3.1 and newer. On older Rubies the hooks never fire, so the periodic metric reader and batch log processor in a forked child sit in dead reader threads and silently drop their buffered data. Rather than ship a feature that silently misbehaves on 2.7 and 3.0, treat those versions as unsupported for collector mode: warn and force `collector_mode?` false when `collector_endpoint` is set on an older Ruby, so users fall back to the AppSignal agent with a clear explanation in stderr and the internal log. JRuby (which reports `RUBY_VERSION` as 3.1) keeps collector mode enabled — only the fork integration test is skipped there, since JRuby raises `NotImplementedError` on `Process.fork`. --- lib/appsignal.rb | 5 +- lib/appsignal/config.rb | 68 +++++-- sig/appsignal.rbi | 37 +++- sig/appsignal.rbs | 36 +++- spec/integration/collector_mode_fork_spec.rb | 62 ++++--- spec/integration/collector_mode_logs_spec.rb | 64 +++---- .../collector_mode_metrics_spec.rb | 94 +++++----- spec/integration/collector_mode_spec.rb | 169 ++++++++++-------- .../collector_mode_stop_flush_spec.rb | 42 ++--- .../runners/collector_mode_emit.rb | 9 +- .../runners/collector_mode_fork.rb | 9 +- .../runners/collector_mode_logs.rb | 9 +- .../runners/collector_mode_metrics.rb | 9 +- .../runners/collector_mode_stop_flush.rb | 9 +- spec/integration/runners/stop_with_trap.rb | 9 +- spec/lib/appsignal/config_spec.rb | 66 ++++++- spec/lib/appsignal/logger_spec.rb | 25 +-- spec/lib/appsignal_spec.rb | 113 ++++++++---- 18 files changed, 508 insertions(+), 327 deletions(-) diff --git a/lib/appsignal.rb b/lib/appsignal.rb index 79d57ba4d..75c6ef40a 100644 --- a/lib/appsignal.rb +++ b/lib/appsignal.rb @@ -145,10 +145,7 @@ def start Appsignal::Hooks.load_hooks Appsignal::Loaders.start - if config.collector_mode? - require "appsignal/opentelemetry" - Appsignal::OpenTelemetry.configure(config) - end + Appsignal::OpenTelemetry.configure(config) if config.collector_mode_configured? if config[:enable_allocation_tracking] && !Appsignal::System.jruby? Appsignal::Extension.install_allocation_event_hook diff --git a/lib/appsignal/config.rb b/lib/appsignal/config.rb index a7115dca4..e70c20431 100644 --- a/lib/appsignal/config.rb +++ b/lib/appsignal/config.rb @@ -249,6 +249,13 @@ 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. @@ -479,22 +486,55 @@ def active? valid? && active_for_env? end - # Check if AppSignal is running in collector mode. + # Check if collector mode is configured. # - # Collector mode is active when a non-empty `collector_endpoint` is - # configured. In this mode, an OpenTelemetry SDK is configured to export - # OTLP/HTTP data to that endpoint. + # 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. # - # Memoised: the result is cached on first call so hot paths (metric - # and log emits) avoid re-running the string-strip predicate. A fresh - # `Config` instance always starts uncached. + # 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. # - # @return [Boolean] True if collector mode is active. - def collector_mode? - return @collector_mode if defined?(@collector_mode) + # 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] - @collector_mode = !endpoint.nil? && !endpoint.to_s.strip.empty? + 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 @@ -579,9 +619,13 @@ def validate # 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? + 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." diff --git a/sig/appsignal.rbi b/sig/appsignal.rbi index 160fe64a0..76fe378a8 100644 --- a/sig/appsignal.rbi +++ b/sig/appsignal.rbi @@ -1041,17 +1041,35 @@ module Appsignal sig { returns(T::Boolean) } def active?; end - # Check if AppSignal is running in collector mode. + # Check if collector mode is configured. # - # Collector mode is active when a non-empty `collector_endpoint` is - # configured. In this mode, an OpenTelemetry SDK is configured to export - # OTLP/HTTP data to that endpoint. + # 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. # - # Memoised: the result is cached on first call so hot paths (metric - # and log emits) avoid re-running the string-strip predicate. A fresh - # `Config` instance always starts uncached. + # 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. # - # _@return_ — True if collector mode is active. + # 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 @@ -2643,6 +2661,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 41d6c426b..75108ebca 100644 --- a/sig/appsignal.rbs +++ b/sig/appsignal.rbs @@ -991,17 +991,34 @@ module Appsignal # _@return_ — True if valid and active for the current environment. def active?: () -> bool - # Check if AppSignal is running in collector mode. + # Check if collector mode is configured. # - # Collector mode is active when a non-empty `collector_endpoint` is - # configured. In this mode, an OpenTelemetry SDK is configured to export - # OTLP/HTTP data to that endpoint. + # 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. # - # Memoised: the result is cached on first call so hot paths (metric - # and log emits) avoid re-running the string-strip predicate. A fresh - # `Config` instance always starts uncached. + # 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. # - # _@return_ — True if collector mode is active. + # 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 @@ -2463,6 +2480,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 index 53f94c725..a60474b77 100644 --- a/spec/integration/collector_mode_fork_spec.rb +++ b/spec/integration/collector_mode_fork_spec.rb @@ -1,34 +1,40 @@ -require "opentelemetry/exporter/otlp" -require "opentelemetry/proto/collector/metrics/v1/metrics_service_pb" +# 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.ruby_3_1_or_newer? && !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 } + 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").run + 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 + 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") + expect(metric_names).to include("forked_child_counter") + end end end diff --git a/spec/integration/collector_mode_logs_spec.rb b/spec/integration/collector_mode_logs_spec.rb index ffe568abd..96bd953d5 100644 --- a/spec/integration/collector_mode_logs_spec.rb +++ b/spec/integration/collector_mode_logs_spec.rb @@ -1,43 +1,45 @@ -require "opentelemetry/exporter/otlp" -require "opentelemetry/proto/collector/logs/v1/logs_service_pb" +if DependencyHelper.ruby_3_1_or_newer? + require "opentelemetry/exporter/otlp" + require "opentelemetry/proto/collector/logs/v1/logs_service_pb" -describe "AppSignal collector mode log helpers" do - before { OTLPCollectorServer.clear } + 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") - runner.run + 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]) + 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") + 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] } + 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") + 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") + 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") + 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 + 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 + def attribute_value(record, key) + record.attributes.find { |kv| kv.key == key }&.value + end end end diff --git a/spec/integration/collector_mode_metrics_spec.rb b/spec/integration/collector_mode_metrics_spec.rb index ba2a04b61..b31789442 100644 --- a/spec/integration/collector_mode_metrics_spec.rb +++ b/spec/integration/collector_mode_metrics_spec.rb @@ -1,48 +1,50 @@ -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") - 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 +if DependencyHelper.ruby_3_1_or_newer? + 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_spec.rb b/spec/integration/collector_mode_spec.rb index 0abc86ffc..984be55ff 100644 --- a/spec/integration/collector_mode_spec.rb +++ b/spec/integration/collector_mode_spec.rb @@ -1,95 +1,106 @@ -# 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" +# 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.ruby_3_1_or_newer? + # 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 } + 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) # rubocop:disable Metrics/AbcSize - attrs = resource.attributes.to_h { |kv| [kv.key, kv.value] } + # 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) # rubocop:disable Metrics/AbcSize + 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("collector-mode-test") - expect(attrs["appsignal.config.environment"].string_value).to eq("test") - expect(attrs["appsignal.config.push_api_key"].string_value).to eq("abc") - expect(attrs["appsignal.config.revision"].string_value).to eq("abc1234") - expect(attrs["appsignal.config.language_integration"].string_value).to eq("ruby") - expect(attrs["appsignal.service.process_id"].int_value).to be > 0 + 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.service.process_id"].int_value).to be > 0 - 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) + 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) + # 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") + # 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}" + # 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 - end - it "configures collector mode and emits OTLP traces, metrics, and logs" do - runner = Runner.new("collector_mode_emit") - runner.run + 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=http://127.0.0.1:9090") - expect(runner.output).to include("collector_mode?=true") + # 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) + 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) + 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 } } + 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 - expect(log_bodies).to include("test-log-line") - expect_appsignal_resource(log_msg.resource_logs.first.resource) end end diff --git a/spec/integration/collector_mode_stop_flush_spec.rb b/spec/integration/collector_mode_stop_flush_spec.rb index 7c7d5f2a7..8f025c114 100644 --- a/spec/integration/collector_mode_stop_flush_spec.rb +++ b/spec/integration/collector_mode_stop_flush_spec.rb @@ -1,29 +1,31 @@ -require "opentelemetry/exporter/otlp" -require "opentelemetry/proto/collector/metrics/v1/metrics_service_pb" -require "opentelemetry/proto/collector/logs/v1/logs_service_pb" +if DependencyHelper.ruby_3_1_or_newer? + 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 } + 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") - runner.run + 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_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") + 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_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 } } + 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 - expect(log_bodies).to include("stop log line") end end diff --git a/spec/integration/runners/collector_mode_emit.rb b/spec/integration/runners/collector_mode_emit.rb index cbb87cd87..c07be6cf8 100644 --- a/spec/integration/runners/collector_mode_emit.rb +++ b/spec/integration/runners/collector_mode_emit.rb @@ -4,11 +4,10 @@ require "appsignal" -Appsignal.configure(:test) do |config| - config.active = true - config.push_api_key = "abc" - config.name = "collector-mode-test" - config.collector_endpoint = "http://127.0.0.1:9090" +# 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" diff --git a/spec/integration/runners/collector_mode_fork.rb b/spec/integration/runners/collector_mode_fork.rb index 3ae9af4e8..f12364ff1 100644 --- a/spec/integration/runners/collector_mode_fork.rb +++ b/spec/integration/runners/collector_mode_fork.rb @@ -10,13 +10,8 @@ require "appsignal" -Appsignal.configure(:test) do |config| - config.active = true - config.push_api_key = "abc" - config.name = "collector-mode-test" - config.collector_endpoint = "http://127.0.0.1:9090" -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 # In the child: emit a metric and wait long enough for the periodic diff --git a/spec/integration/runners/collector_mode_logs.rb b/spec/integration/runners/collector_mode_logs.rb index fcd140eab..61dbb0850 100644 --- a/spec/integration/runners/collector_mode_logs.rb +++ b/spec/integration/runners/collector_mode_logs.rb @@ -4,13 +4,8 @@ require "appsignal" -Appsignal.configure(:test) do |config| - config.active = true - config.push_api_key = "abc" - config.name = "collector-mode-test" - config.collector_endpoint = "http://127.0.0.1:9090" -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 # Exercise Appsignal::Logger under collector mode: in this mode each emit diff --git a/spec/integration/runners/collector_mode_metrics.rb b/spec/integration/runners/collector_mode_metrics.rb index ec2ffb68a..7ec06e79b 100644 --- a/spec/integration/runners/collector_mode_metrics.rb +++ b/spec/integration/runners/collector_mode_metrics.rb @@ -4,13 +4,8 @@ require "appsignal" -Appsignal.configure(:test) do |config| - config.active = true - config.push_api_key = "abc" - config.name = "collector-mode-test" - config.collector_endpoint = "http://127.0.0.1:9090" -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 # Exercise the public AppSignal metric helpers; in collector mode these diff --git a/spec/integration/runners/collector_mode_stop_flush.rb b/spec/integration/runners/collector_mode_stop_flush.rb index d7d4010c0..94db756a9 100644 --- a/spec/integration/runners/collector_mode_stop_flush.rb +++ b/spec/integration/runners/collector_mode_stop_flush.rb @@ -4,13 +4,8 @@ require "appsignal" -Appsignal.configure(:test) do |config| - config.active = true - config.push_api_key = "abc" - config.name = "collector-mode-test" - config.collector_endpoint = "http://127.0.0.1:9090" -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 # Emit one of each signal but deliberately do NOT call `force_flush` diff --git a/spec/integration/runners/stop_with_trap.rb b/spec/integration/runners/stop_with_trap.rb index 95da17463..73aaa3c6b 100644 --- a/spec/integration/runners/stop_with_trap.rb +++ b/spec/integration/runners/stop_with_trap.rb @@ -11,13 +11,8 @@ exit 0 end -# Dummy config -Appsignal.configure(:test) do |config| - config.active = true - config.push_api_key = "abc" - config.name = "Signal app" -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/lib/appsignal/config_spec.rb b/spec/lib/appsignal/config_spec.rb index df79ef628..08df217dd 100644 --- a/spec/lib/appsignal/config_spec.rb +++ b/spec/lib/appsignal/config_spec.rb @@ -1639,10 +1639,14 @@ def log_file_path end end - describe "#collector_mode?" do + describe "#collector_mode_configured?" do let(:options) { {} } let(:config) { build_config(:root_path => "", :env => nil, :options => options) } - subject { config.collector_mode? } + 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) } @@ -1676,6 +1680,63 @@ def log_file_path 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 @@ -1686,6 +1747,7 @@ def log_file_path 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 = diff --git a/spec/lib/appsignal/logger_spec.rb b/spec/lib/appsignal/logger_spec.rb index a2142e0b7..39d771f7e 100644 --- a/spec/lib/appsignal/logger_spec.rb +++ b/spec/lib/appsignal/logger_spec.rb @@ -727,20 +727,23 @@ end end - context "when collector mode is active" do - before do - Appsignal.clear! - start_agent(:options => { :collector_endpoint => "http://127.0.0.1:9090" }) - end + if DependencyHelper.ruby_3_1_or_newer? + context "when collector mode is active" do + before do + Appsignal.clear! + start_agent(:options => { :collector_endpoint => "http://127.0.0.1:9090" }) + end - it "routes through the OpenTelemetry backend, not the extension" do - allow(Appsignal::Logger::OpenTelemetryBackend).to receive(:emit) - expect(Appsignal::Extension).not_to receive(:log) + it "routes through the OpenTelemetry backend, not the extension" do + allow(Appsignal::Logger::OpenTelemetryBackend).to receive(:emit) + expect(Appsignal::Extension).not_to receive(:log) - logger.info("Hello", :tag => "value") + logger.info("Hello", :tag => "value") - expect(Appsignal::Logger::OpenTelemetryBackend).to have_received(:emit) - .with("group", ::Logger::INFO, Appsignal::Logger::AUTODETECT, "Hello", { :tag => "value" }) + expect(Appsignal::Logger::OpenTelemetryBackend).to have_received(:emit) + .with("group", ::Logger::INFO, Appsignal::Logger::AUTODETECT, "Hello", + { :tag => "value" }) + end end end end diff --git a/spec/lib/appsignal_spec.rb b/spec/lib/appsignal_spec.rb index 606fcb6e5..d5b3bf2f9 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.ruby_3_1_or_newer? + 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 @@ -951,17 +984,19 @@ def on_start Appsignal.stop end - context "in collector mode" do - before do - Appsignal.clear! - start_agent(:options => { :collector_endpoint => "http://127.0.0.1:9090" }) - end + if DependencyHelper.ruby_3_1_or_newer? + 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 + 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 @@ -1632,45 +1667,47 @@ def on_start end end - context "when collector mode is active" do - before do - Appsignal.clear! - start_agent(:options => { :collector_endpoint => "http://127.0.0.1:9090" }) - end + if DependencyHelper.ruby_3_1_or_newer? + context "when collector mode is active" do + before do + Appsignal.clear! + start_agent(:options => { :collector_endpoint => "http://127.0.0.1:9090" }) + end - describe ".set_gauge" do - it "routes through the OpenTelemetry backend, not the extension" do - allow(Appsignal::Metrics::OpenTelemetryBackend).to receive(:set_gauge) - expect(Appsignal::Extension).not_to receive(:set_gauge) + describe ".set_gauge" do + it "routes through the OpenTelemetry backend, not the extension" do + allow(Appsignal::Metrics::OpenTelemetryBackend).to receive(:set_gauge) + expect(Appsignal::Extension).not_to receive(:set_gauge) - Appsignal.set_gauge("key", 0.1, tags) + Appsignal.set_gauge("key", 0.1, tags) - expect(Appsignal::Metrics::OpenTelemetryBackend).to have_received(:set_gauge) - .with("key", 0.1, tags) + expect(Appsignal::Metrics::OpenTelemetryBackend).to have_received(:set_gauge) + .with("key", 0.1, tags) + end end - end - describe ".increment_counter" do - it "routes through the OpenTelemetry backend, not the extension" do - allow(Appsignal::Metrics::OpenTelemetryBackend).to receive(:increment_counter) - expect(Appsignal::Extension).not_to receive(:increment_counter) + describe ".increment_counter" do + it "routes through the OpenTelemetry backend, not the extension" do + allow(Appsignal::Metrics::OpenTelemetryBackend).to receive(:increment_counter) + expect(Appsignal::Extension).not_to receive(:increment_counter) - Appsignal.increment_counter("key", 5, tags) + Appsignal.increment_counter("key", 5, tags) - expect(Appsignal::Metrics::OpenTelemetryBackend).to have_received(:increment_counter) - .with("key", 5, tags) + expect(Appsignal::Metrics::OpenTelemetryBackend).to have_received(:increment_counter) + .with("key", 5, tags) + end end - end - describe ".add_distribution_value" do - it "routes through the OpenTelemetry backend, not the extension" do - allow(Appsignal::Metrics::OpenTelemetryBackend).to receive(:add_distribution_value) - expect(Appsignal::Extension).not_to receive(:add_distribution_value) + describe ".add_distribution_value" do + it "routes through the OpenTelemetry backend, not the extension" do + allow(Appsignal::Metrics::OpenTelemetryBackend).to receive(:add_distribution_value) + expect(Appsignal::Extension).not_to receive(:add_distribution_value) - Appsignal.add_distribution_value("key", 0.1, tags) + Appsignal.add_distribution_value("key", 0.1, tags) - expect(Appsignal::Metrics::OpenTelemetryBackend) - .to have_received(:add_distribution_value).with("key", 0.1, tags) + expect(Appsignal::Metrics::OpenTelemetryBackend) + .to have_received(:add_distribution_value).with("key", 0.1, tags) + end end end end From 32f35be63c523d8d8a2c4f36c86061261f6900cb Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Thu, 28 May 2026 10:13:03 +0200 Subject: [PATCH 011/151] Add Transaction backend abstraction Mirror the existing Backends.metrics / Backends.logger pattern at the Transaction layer: a Transaction's per-instance handle now goes through Backends.transaction, which returns ExtensionBackend in agent mode and OpenTelemetryBackend in collector mode. The Extension backend just wraps the C extension transaction handle (plus the MockTransaction fallback) so agent-mode behavior is unchanged. The OTel backend is currently no-op stubs: collector- mode transactions don't crash, but no spans are emitted yet. Real span emission lands in follow-up steps (root span on create, child spans for events, exception events for errors). The @ext ivar on Transaction is renamed to @backend (and the matching ext: kwarg to backend:) to reflect that the field no longer points at a raw extension handle. --- lib/appsignal/backends.rb | 10 ++ lib/appsignal/transaction.rb | 32 ++-- .../transaction/extension_backend.rb | 85 ++++++++++ .../transaction/opentelemetry_backend.rb | 86 ++++++++++ spec/lib/appsignal/backends_spec.rb | 32 ++++ spec/lib/appsignal/rack/event_handler_spec.rb | 2 +- .../transaction/extension_backend_spec.rb | 127 +++++++++++++++ .../transaction/opentelemetry_backend_spec.rb | 148 ++++++++++++++++++ spec/lib/appsignal/transaction_spec.rb | 34 ++-- spec/support/helpers/transaction_helpers.rb | 4 +- spec/support/matchers/transaction.rb | 6 +- spec/support/testing.rb | 2 +- 12 files changed, 528 insertions(+), 40 deletions(-) create mode 100644 lib/appsignal/transaction/extension_backend.rb create mode 100644 lib/appsignal/transaction/opentelemetry_backend.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..f7acc6715 100644 --- a/lib/appsignal/backends.rb +++ b/lib/appsignal/backends.rb @@ -4,6 +4,8 @@ require "appsignal/metrics/opentelemetry_backend" require "appsignal/logger/extension_backend" require "appsignal/logger/opentelemetry_backend" +require "appsignal/transaction/extension_backend" +require "appsignal/transaction/opentelemetry_backend" module Appsignal # @!visibility private @@ -34,6 +36,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/transaction.rb b/lib/appsignal/transaction.rb index de328628d..85c5994a5 100644 --- a/lib/appsignal/transaction.rb +++ b/lib/appsignal/transaction.rb @@ -160,7 +160,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 @@ -179,11 +179,11 @@ def initialize(namespace, id: SecureRandom.uuid, ext: nil) @headers = Appsignal::SampleData.new(:headers, Hash) @custom_data = Appsignal::SampleData.new(:custom_data) - @ext = ext || Appsignal::Extension.start_transaction( + @backend = backend || Appsignal::Backends.transaction.new( @transaction_id, @namespace, 0 - ) || Appsignal::Extension::MockTransaction.new + ) run_after_create_hooks end @@ -220,7 +220,7 @@ def complete unless duplicate? self.class.last_errors = @error_blocks.keys - should_sample = @ext.finish(0) + should_sample = @backend.finish(0) end @error_blocks.each do |error, blocks| @@ -252,7 +252,7 @@ def complete sample_data if should_sample @completed = true - @ext.complete + @backend.complete end # @!visibility private @@ -543,7 +543,7 @@ def set_action(action) return unless action @action = action - @ext.set_action(action) + @backend.set_action(action) end # Set an action name only if there is no current action set. @@ -591,7 +591,7 @@ def set_namespace(namespace) return unless namespace @namespace = namespace - @ext.set_namespace(namespace) + @backend.set_namespace(namespace) end # Set queue start time for transaction. @@ -605,7 +605,7 @@ def set_namespace(namespace) def set_queue_start(start) return unless start - @ext.set_queue_start(start) + @backend.set_queue_start(start) rescue RangeError Appsignal.internal_logger.warn("Queue start value #{start} is too big") end @@ -615,7 +615,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 @@ -651,7 +651,7 @@ def add_error(error, &block) def start_event return if paused? - @ext.start_event(0) + @backend.start_event(0) end # @!visibility private @@ -659,7 +659,7 @@ 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, @@ -673,7 +673,7 @@ def finish_event(name, title, body, body_format = Appsignal::EventFormatter::DEF def record_event(name, title, body, duration, body_format = Appsignal::EventFormatter::DEFAULT) return if paused? - @ext.record_event( + @backend.record_event( name, title || BLANK, body || BLANK, @@ -694,7 +694,7 @@ 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 @@ -736,7 +736,7 @@ def run_before_complete_hooks def _set_error(error) backtrace = cleaned_backtrace(error.backtrace) - @ext.set_error( + @backend.set_error( error.class.name, cleaned_error_message(error), backtrace ? Appsignal::Utils::Data.generate(backtrace) : Appsignal::Extension.data_array_new @@ -820,7 +820,7 @@ def set_sample_data(key, data) return end - @ext.set_sample_data( + @backend.set_sample_data( key.to_s, Appsignal::Utils::Data.generate(data) ) @@ -855,7 +855,7 @@ 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 diff --git a/lib/appsignal/transaction/extension_backend.rb b/lib/appsignal/transaction/extension_backend.rb new file mode 100644 index 000000000..3cb021537 --- /dev/null +++ b/lib/appsignal/transaction/extension_backend.rb @@ -0,0 +1,85 @@ +# 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 + def initialize(transaction_id, namespace, gc_duration, handle: nil) + @handle = handle || + Appsignal::Extension.start_transaction(transaction_id, namespace, gc_duration) || + Appsignal::Extension::MockTransaction.new + end + + def start_event(gc_duration) + @handle.start_event(gc_duration) + end + + def finish_event(name, title, body, body_format, gc_duration) + @handle.finish_event(name, title, body, body_format, gc_duration) + end + + def record_event(name, title, body, body_format, duration, gc_duration) + @handle.record_event(name, title, body, body_format, duration, gc_duration) + end + + def set_action(action) # rubocop:disable Naming/AccessorMethodName + @handle.set_action(action) + end + + def set_namespace(namespace) # rubocop:disable Naming/AccessorMethodName + @handle.set_namespace(namespace) + end + + def set_queue_start(start) # rubocop:disable Naming/AccessorMethodName + @handle.set_queue_start(start) + end + + def set_metadata(key, value) + @handle.set_metadata(key, value) + end + + def set_sample_data(key, data) + @handle.set_sample_data(key, data) + end + + def set_error(class_name, message, backtrace_data) + @handle.set_error(class_name, message, backtrace_data) + end + + def finish(gc_duration) + @handle.finish(gc_duration) + end + + def complete + @handle.complete + end + + def duplicate(new_transaction_id) + self.class.new(new_transaction_id, nil, 0, :handle => @handle.duplicate(new_transaction_id)) + end + + def to_json # rubocop:disable Lint/ToJSON + @handle.to_json + end + + # Test-mode introspection. The `Appsignal::Extension::Transaction` patches + # in `spec/support/testing.rb` add `queue_start` and `_completed?` on the + # underlying handle; matchers reach in via `transaction.backend.`. + def queue_start + @handle.queue_start + end + + def _completed? + @handle._completed? + 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..258d1e2f1 --- /dev/null +++ b/lib/appsignal/transaction/opentelemetry_backend.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +module Appsignal + class Transaction + # @!visibility private + # + # The transaction backend used in collector mode. Will eventually emit + # OpenTelemetry spans for the transaction lifecycle (root span on + # creation, child spans for events, exception events on errors). + # + # Step 1 of the transaction OTel migration only scaffolds the seam: + # every write method is a no-op, `finish` returns `false` so the + # `Transaction#complete` sampling path doesn't run, and `to_json` + # returns `"{}"` so `Transaction#to_h` does not raise if called in + # collector mode. + class OpenTelemetryBackend + def initialize(transaction_id, namespace, gc_duration, **) + @transaction_id = transaction_id + @namespace = namespace + @gc_duration = gc_duration + @completed = false + end + + def start_event(_gc_duration) + end + + def finish_event(_name, _title, _body, _body_format, _gc_duration) + end + + def record_event(_name, _title, _body, _body_format, _duration, _gc_duration) + end + + def set_action(_action) # rubocop:disable Naming/AccessorMethodName + end + + def set_namespace(_namespace) # rubocop:disable Naming/AccessorMethodName + end + + def set_queue_start(_start) # rubocop:disable Naming/AccessorMethodName + end + + def set_metadata(_key, _value) + end + + def set_sample_data(_key, _data) + end + + def set_error(_class_name, _message, _backtrace_data) + end + + # Always returns `false` for now: there is no sample data flush in + # collector mode (the OTel span carries the data itself once span + # emission lands in later steps). + def finish(_gc_duration) + false + end + + def complete + @completed = true + end + + def duplicate(new_transaction_id) + self.class.new(new_transaction_id, @namespace, 0) + end + + # Returned so `Transaction#to_h` (which does `JSON.parse(@backend.to_json)`) + # produces an empty Hash instead of raising. The existing agent-mode + # transaction matchers all read from `to_h`; in collector mode they + # will be replaced by OTel-based assertions in subsequent steps. + def to_json # rubocop:disable Lint/ToJSON + "{}" + end + + # Test-mode introspection parity with `ExtensionBackend`. Returns `nil` + # for now since `set_queue_start` is a no-op; `_completed?` toggles on + # `complete` so `be_completed` keeps working. + def queue_start + nil + end + + def _completed? + @completed + end + end + end +end 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..c1928d580 --- /dev/null +++ b/spec/lib/appsignal/transaction/extension_backend_spec.rb @@ -0,0 +1,127 @@ +# frozen_string_literal: true + +describe Appsignal::Transaction::ExtensionBackend do + before { start_agent } + + let(:backend) { described_class.new("abc-123", Appsignal::Transaction::HTTP_REQUEST, 0) } + + 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", + 0, + :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, 0) + 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(0) + 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, 0) + 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, 0) + 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 "forwards #set_sample_data to the handle" do + data = Appsignal::Utils::Data.generate({ :a => 1 }) + expect(handle).to receive(:set_sample_data).with("params", data) + backend.set_sample_data("params", data) + end + + it "forwards #set_error to the handle" do + data = Appsignal::Extension.data_array_new + expect(handle).to receive(:set_error).with("RuntimeError", "boom", data) + backend.set_error("RuntimeError", "boom", data) + 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(0)).to eq(true) + end + + it "forwards #complete to the handle" do + expect(handle).to receive(:complete) + backend.complete + 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 +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..50b0b1e94 --- /dev/null +++ b/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb @@ -0,0 +1,148 @@ +# frozen_string_literal: true + +describe Appsignal::Transaction::OpenTelemetryBackend do + let(:backend) { described_class.new("abc-123", Appsignal::Transaction::HTTP_REQUEST, 0) } + + it "constructs without an extension or an OpenTelemetry SDK booted" do + expect { backend }.not_to raise_error + end + + describe "write methods (no-op for step 1)" do + it "accepts #start_event without raising" do + expect { backend.start_event(0) }.not_to raise_error + end + + it "accepts #finish_event without raising" do + expect { backend.finish_event("name", "title", "body", 1, 0) }.not_to raise_error + end + + it "accepts #record_event without raising" do + expect { backend.record_event("name", "title", "body", 1, 1000, 0) }.not_to raise_error + end + + it "accepts #set_action without raising" do + expect { backend.set_action("MyAction") }.not_to raise_error + end + + it "accepts #set_namespace without raising" do + expect { backend.set_namespace("background_job") }.not_to raise_error + end + + it "accepts #set_queue_start without raising" do + expect { backend.set_queue_start(123_456) }.not_to raise_error + end + + it "accepts #set_metadata without raising" do + expect { backend.set_metadata("key", "value") }.not_to raise_error + end + + it "accepts #set_sample_data without raising" do + expect { backend.set_sample_data("params", "anything") }.not_to raise_error + end + + it "accepts #set_error without raising" do + expect { backend.set_error("RuntimeError", "boom", "backtrace") }.not_to raise_error + end + end + + describe "#finish" do + it "returns false so Transaction#complete does not run the sample_data path" do + expect(backend.finish(0)).to eq(false) + end + end + + describe "#complete" do + it "marks the backend completed without raising" do + expect(backend._completed?).to eq(false) + backend.complete + expect(backend._completed?).to eq(true) + end + end + + describe "#duplicate" do + it "returns a new OpenTelemetryBackend instance with the new id" 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(:@transaction_id)).to eq("new-id") + end + end + + describe "#to_json" do + it 'returns "{}" so Transaction#to_h yields an empty Hash' do + 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.set_queue_start(123_456) + expect(backend.queue_start).to be_nil + end + end + + # Smoke test: a Transaction backed by an OpenTelemetryBackend (the collector-mode + # configuration) exercises every public API path without raising, and emits no + # OpenTelemetry spans yet. Span emission lands in subsequent steps. The backend + # is injected via the `backend:` kwarg here — the `Appsignal::Backends.transaction` + # dispatcher is covered in `spec/lib/appsignal/backends_spec.rb`. + describe "Transaction backed by this backend (collector-mode shape)" do + require "opentelemetry/sdk" + + 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 + start_agent + ::OpenTelemetry.tracer_provider = tracer_provider + end + + def new_transaction_with_otel_backend(namespace = Appsignal::Transaction::HTTP_REQUEST) + Appsignal::Transaction.new( + namespace, + :backend => described_class.new("abc-123", namespace, 0) + ) + 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.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 no OpenTelemetry spans (step-1 backend is a no-op)" do + transaction = new_transaction_with_otel_backend + transaction.start_event + transaction.finish_event("event", "title", "body", 1) + transaction.complete + + expect(span_exporter.finished_spans).to be_empty + end + end +end diff --git a/spec/lib/appsignal/transaction_spec.rb b/spec/lib/appsignal/transaction_spec.rb index d3ab930dd..08f1315ea 100644 --- a/spec/lib/appsignal/transaction_spec.rb +++ b/spec/lib/appsignal/transaction_spec.rb @@ -36,11 +36,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 @@ -364,7 +364,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 @@ -599,7 +599,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 +608,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", @@ -1502,7 +1502,7 @@ 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) + 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") @@ -2545,7 +2545,7 @@ 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(0).and_call_original transaction.start_event end @@ -2553,7 +2553,7 @@ def to_s 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 @@ -2565,7 +2565,7 @@ def to_s 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", @@ -2582,7 +2582,7 @@ 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", "", "", @@ -2601,7 +2601,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 @@ -2613,7 +2613,7 @@ def to_s 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", @@ -2632,7 +2632,7 @@ 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", "", "", @@ -2653,7 +2653,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", @@ -2695,7 +2695,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/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..25e052d4c 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 @@ -181,7 +181,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 +190,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..59001f559 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 From 3f90fe2ef52ca2c82d0f558decaa19dda204208b Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Thu, 28 May 2026 13:50:10 +0200 Subject: [PATCH 012/151] Open the OTel root span on transaction create MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In collector mode `Appsignal::Transaction::OpenTelemetryBackend#initialize` now starts an OpenTelemetry root span and attaches it as the current OTel context; `#complete` detaches the context and finishes the span. All other backend methods stay no-op for now and land in subsequent steps. Span kind comes from the namespace: HTTP_REQUEST and ACTION_CABLE map to SERVER, BACKGROUND_JOB to CONSUMER, anything else defaults to SERVER. The placeholder name is "appsignal.transaction " — `set_action` will rename via `span.name=` once it's wired up in a later step. Adds `spec/lib/appsignal/transaction_integration_spec.rb` for the "unit integration" rhythm: shared examples for the mode-agnostic structural lifecycle (id, namespace, current, complete_current!), plus an agent-mode context using existing matchers and a collector- mode context with InMemorySpanExporter-based OTel assertions. --- .../transaction/opentelemetry_backend.rb | 55 +++++- .../transaction/opentelemetry_backend_spec.rb | 164 +++++++++++++----- .../appsignal/transaction_integration_spec.rb | 153 ++++++++++++++++ 3 files changed, 323 insertions(+), 49 deletions(-) create mode 100644 spec/lib/appsignal/transaction_integration_spec.rb diff --git a/lib/appsignal/transaction/opentelemetry_backend.rb b/lib/appsignal/transaction/opentelemetry_backend.rb index 258d1e2f1..b49090811 100644 --- a/lib/appsignal/transaction/opentelemetry_backend.rb +++ b/lib/appsignal/transaction/opentelemetry_backend.rb @@ -4,21 +4,48 @@ module Appsignal class Transaction # @!visibility private # - # The transaction backend used in collector mode. Will eventually emit - # OpenTelemetry spans for the transaction lifecycle (root span on - # creation, child spans for events, exception events on errors). + # The transaction backend used in collector mode. Emits an OpenTelemetry + # root span for the transaction lifecycle. Child spans for events and + # exception events for errors land in subsequent steps. # - # Step 1 of the transaction OTel migration only scaffolds the seam: - # every write method is a no-op, `finish` returns `false` so the - # `Transaction#complete` sampling path doesn't run, and `to_json` - # returns `"{}"` so `Transaction#to_h` does not raise if called in - # collector mode. + # On construction the backend starts an OTel root span and attaches it + # to the current OpenTelemetry context, so any third-party OTel + # instrumentation that runs while the transaction is active nests + # naturally under the transaction's span. On `complete` the backend + # detaches the context and finishes the span. All other write methods + # are still no-ops for now. class OpenTelemetryBackend + 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 + def initialize(transaction_id, namespace, gc_duration, **) @transaction_id = transaction_id @namespace = namespace @gc_duration = gc_duration @completed = false + + @span = tracer.start_span( + placeholder_span_name(namespace), + :kind => SPAN_KIND_BY_NAMESPACE.fetch(namespace, DEFAULT_SPAN_KIND) + ) + @context_token = ::OpenTelemetry::Context.attach( + ::OpenTelemetry::Trace.context_with_span(@span) + ) end def start_event(_gc_duration) @@ -57,6 +84,8 @@ def finish(_gc_duration) def complete @completed = true + ::OpenTelemetry::Context.detach(@context_token) if @context_token + @span&.finish end def duplicate(new_transaction_id) @@ -81,6 +110,16 @@ def queue_start def _completed? @completed end + + private + + def tracer + ::OpenTelemetry.tracer_provider.tracer(TRACER_NAME, Appsignal::VERSION) + end + + def placeholder_span_name(namespace) + "appsignal.transaction #{namespace}" + 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 50b0b1e94..846925d9c 100644 --- a/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb +++ b/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb @@ -1,67 +1,161 @@ # frozen_string_literal: true +require "opentelemetry/sdk" + describe Appsignal::Transaction::OpenTelemetryBackend do - let(:backend) { described_class.new("abc-123", Appsignal::Transaction::HTTP_REQUEST, 0) } + 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 = [] + 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. + after do + @backends_created.each { |backend| backend.complete unless backend._completed? } + end + + def create_backend(namespace = "http_request") + described_class.new("abc-123", namespace, 0).tap { |b| @backends_created << b } + 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 "constructs without an extension or an OpenTelemetry SDK booted" do - expect { backend }.not_to raise_error + it "nests under the current OpenTelemetry context (free distributed-tracing inheritance)" 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(outer.context.span_id) + ensure + ::OpenTelemetry::Context.detach(outer_token) + outer.finish + end + end end - describe "write methods (no-op for step 1)" do + describe "write methods (no-op for step 2 — implementations land in subsequent steps)" do it "accepts #start_event without raising" do - expect { backend.start_event(0) }.not_to raise_error + expect { create_backend.start_event(0) }.not_to raise_error end it "accepts #finish_event without raising" do - expect { backend.finish_event("name", "title", "body", 1, 0) }.not_to raise_error + expect { create_backend.finish_event("name", "title", "body", 1, 0) }.not_to raise_error end it "accepts #record_event without raising" do - expect { backend.record_event("name", "title", "body", 1, 1000, 0) }.not_to raise_error + expect { create_backend.record_event("name", "title", "body", 1, 1000, 0) }.not_to raise_error end it "accepts #set_action without raising" do - expect { backend.set_action("MyAction") }.not_to raise_error + expect { create_backend.set_action("MyAction") }.not_to raise_error end it "accepts #set_namespace without raising" do - expect { backend.set_namespace("background_job") }.not_to raise_error + expect { create_backend.set_namespace("background_job") }.not_to raise_error end it "accepts #set_queue_start without raising" do - expect { backend.set_queue_start(123_456) }.not_to raise_error + expect { create_backend.set_queue_start(123_456) }.not_to raise_error end it "accepts #set_metadata without raising" do - expect { backend.set_metadata("key", "value") }.not_to raise_error + expect { create_backend.set_metadata("key", "value") }.not_to raise_error end it "accepts #set_sample_data without raising" do - expect { backend.set_sample_data("params", "anything") }.not_to raise_error + expect { create_backend.set_sample_data("params", "anything") }.not_to raise_error end it "accepts #set_error without raising" do - expect { backend.set_error("RuntimeError", "boom", "backtrace") }.not_to raise_error + expect { create_backend.set_error("RuntimeError", "boom", "backtrace") }.not_to raise_error end end describe "#finish" do it "returns false so Transaction#complete does not run the sample_data path" do - expect(backend.finish(0)).to eq(false) + expect(create_backend.finish(0)).to eq(false) end end describe "#complete" do - it "marks the backend completed without raising" 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 "#duplicate" do + # Multi-error duplicate is dead code in collector mode until errors are + # wired up in a later step (one span + multiple `record_exception` events + # will replace the duplicate-per-error model). For now we just preserve + # the shape so `Transaction#complete`'s duplicate loop won't crash. it "returns a new OpenTelemetryBackend instance with the new id" do + backend = create_backend duplicate = backend.duplicate("new-id") + @backends_created << duplicate expect(duplicate).to be_kind_of(described_class) expect(duplicate).not_to be(backend) @@ -71,6 +165,7 @@ 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 @@ -78,38 +173,24 @@ 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 (the collector-mode - # configuration) exercises every public API path without raising, and emits no - # OpenTelemetry spans yet. Span emission lands in subsequent steps. The backend - # is injected via the `backend:` kwarg here — the `Appsignal::Backends.transaction` - # dispatcher is covered in `spec/lib/appsignal/backends_spec.rb`. + # 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 - require "opentelemetry/sdk" - - 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 - start_agent - ::OpenTelemetry.tracer_provider = tracer_provider - end + before { start_agent } def new_transaction_with_otel_backend(namespace = Appsignal::Transaction::HTTP_REQUEST) - Appsignal::Transaction.new( - namespace, - :backend => described_class.new("abc-123", namespace, 0) - ) + backend = described_class.new("abc-123", namespace, 0) + @backends_created << backend + Appsignal::Transaction.new(namespace, :backend => backend) end it "does not raise across the create -> events -> set_action -> complete flow" do @@ -136,13 +217,14 @@ def new_transaction_with_otel_backend(namespace = Appsignal::Transaction::HTTP_R expect(transaction.to_h).to eq({}) end - it "emits no OpenTelemetry spans (step-1 backend is a no-op)" do + it "emits exactly one OTel root span on completion" do transaction = new_transaction_with_otel_backend transaction.start_event transaction.finish_event("event", "title", "body", 1) transaction.complete - expect(span_exporter.finished_spans).to be_empty + expect(span_exporter.finished_spans.size).to eq(1) + expect(span_exporter.finished_spans.first.kind).to eq(:server) end end end diff --git a/spec/lib/appsignal/transaction_integration_spec.rb b/spec/lib/appsignal/transaction_integration_spec.rb new file mode 100644 index 000000000..d0e930fdc --- /dev/null +++ b/spec/lib/appsignal/transaction_integration_spec.rb @@ -0,0 +1,153 @@ +# frozen_string_literal: true + +# Unit-integration tests for `Appsignal::Transaction`. These exercise the +# Transaction object in realistic flows (creation -> current -> complete -> +# clear thread-local) in both agent mode and collector mode, side by side. +# +# The shared examples cover the mode-agnostic structural behavior: the new +# Transaction lives in the thread-local, it carries the namespace and id we +# passed, completion toggles the completed? flag, and `complete_current!` +# clears the thread-local. Each mode then adds its own telemetry-specific +# assertions on top — `to_h` matchers in agent mode, OpenTelemetry span +# exporter assertions in collector mode. +# +# See [[unit-integration-test-rhythm]]. + +RSpec.shared_examples "transaction lifecycle (mode-agnostic)" do + it "creates a Transaction with the given namespace and a generated id" do + transaction = Appsignal::Transaction.create(Appsignal::Transaction::HTTP_REQUEST) + + expect(transaction).to be_a(Appsignal::Transaction) + expect(transaction.transaction_id).to be_a(String) + expect(transaction.transaction_id).not_to be_empty + expect(transaction.namespace).to eq(Appsignal::Transaction::HTTP_REQUEST) + end + + it "puts the created transaction in Appsignal::Transaction.current" do + transaction = Appsignal::Transaction.create(Appsignal::Transaction::HTTP_REQUEST) + + expect(Appsignal::Transaction.current).to eq(transaction) + expect(Appsignal::Transaction.current?).to be(true) + end + + it "marks the transaction completed after #complete" do + transaction = Appsignal::Transaction.create(Appsignal::Transaction::HTTP_REQUEST) + transaction.complete + + expect(transaction.completed?).to be(true) + end + + it "clears the thread-local on complete_current!" do + Appsignal::Transaction.create(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 + +describe "Appsignal::Transaction lifecycle in agent mode" do + before { start_agent } + + include_examples "transaction lifecycle (mode-agnostic)" + + it "serializes id, namespace and completed? via to_h after completion" do + transaction = Appsignal::Transaction.create(Appsignal::Transaction::HTTP_REQUEST) + keep_transactions { Appsignal::Transaction.complete_current! } + + expect(transaction).to have_id(transaction.transaction_id) + expect(transaction).to have_namespace(Appsignal::Transaction::HTTP_REQUEST) + expect(transaction).to be_completed + end +end + +describe "Appsignal::Transaction lifecycle in collector mode" do + require "opentelemetry/sdk" + + 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 + start_agent(:options => { :collector_endpoint => "http://127.0.0.1:9090" }) + # Replace the tracer provider booted by Appsignal::OpenTelemetry.configure + # with one whose processor pushes into our in-memory exporter, so we can + # inspect spans inside the test instead of trying to flush them out over + # OTLP/HTTP. + ::OpenTelemetry.tracer_provider = tracer_provider + end + + # Any test that creates a transaction but doesn't complete it leaves an + # OpenTelemetry context attached, which would pollute the next test's + # `Trace.current_span` reading. spec_helper's `clear_current_transaction!` + # clears the thread-local but not the OTel context — `complete_current!` + # does both (it calls `complete` on the backend, which detaches). + after { Appsignal::Transaction.complete_current! } + + include_examples "transaction lifecycle (mode-agnostic)" + + describe "OpenTelemetry root span shape" do + it "starts an OTel root span with SpanKind::SERVER for HTTP_REQUEST" do + Appsignal::Transaction.create(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" do + Appsignal::Transaction.create(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" do + Appsignal::Transaction.create(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" do + Appsignal::Transaction.create("my_custom_namespace") + Appsignal::Transaction.complete_current! + + expect(span_exporter.finished_spans.first.kind).to eq(:server) + expect(span_exporter.finished_spans.first.name) + .to eq("appsignal.transaction my_custom_namespace") + end + end + + describe "OpenTelemetry current context" do + it "makes the root span the OpenTelemetry current span between create and complete" do + expect(::OpenTelemetry::Trace.current_span).to eq(::OpenTelemetry::Trace::Span::INVALID) + + Appsignal::Transaction.create(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 span emission timing" do + it "emits no span until complete is called" do + Appsignal::Transaction.create(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 From 689347764fca98ae2b0c440cbef8b3cfd9573131 Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Fri, 29 May 2026 12:33:39 +0200 Subject: [PATCH 013/151] Emit OTel child spans for transaction events In collector mode `Appsignal::Transaction#start_event` / `#finish_event` / `#record_event` now open OpenTelemetry child spans of the transaction's root span. Strict-nest semantics: start_event always nests under the current OTel context, finish_event closes the current one. A per-transaction LIFO of [span, token] pairs holds open event spans; `complete` drains any leftovers defensively. finish_event sets `span.name` to the event name, `appsignal.title` when title is non-empty, and branches on body_format: SQL bodies go to `db.query.text` + `db.system.name = "other_sql"` so the collector sanitizes them; default-format bodies go to `appsignal.body`. record_event creates a span with a backdated `start_timestamp` for its given duration, sets the same attributes, finishes it, and does not touch the context stack. --- .../transaction/opentelemetry_backend.rb | 68 +++++- .../transaction/opentelemetry_backend_spec.rb | 177 ++++++++++++++- .../appsignal/transaction_integration_spec.rb | 205 ++++++++++++++++++ 3 files changed, 439 insertions(+), 11 deletions(-) diff --git a/lib/appsignal/transaction/opentelemetry_backend.rb b/lib/appsignal/transaction/opentelemetry_backend.rb index b49090811..85492c932 100644 --- a/lib/appsignal/transaction/opentelemetry_backend.rb +++ b/lib/appsignal/transaction/opentelemetry_backend.rb @@ -5,15 +5,19 @@ class Transaction # @!visibility private # # The transaction backend used in collector mode. Emits an OpenTelemetry - # root span for the transaction lifecycle. Child spans for events and - # exception events for errors land in subsequent steps. + # root span for the transaction lifecycle plus child spans for each + # instrumented event. Error events land in a subsequent step. # # On construction the backend starts an OTel root span and attaches it # to the current OpenTelemetry context, so any third-party OTel # instrumentation that runs while the transaction is active nests - # naturally under the transaction's span. On `complete` the backend - # detaches the context and finishes the span. All other write methods - # are still no-ops for now. + # naturally under the transaction's span. `start_event` opens a child + # span and pushes it onto a per-transaction LIFO; `finish_event` pops + # it, renames it from a placeholder to the event name, writes the + # body/title attributes, and closes the span. `record_event` is the + # post-facto variant that creates a span with a backdated start + # timestamp. On `complete` the backend drains any leftover event + # spans, detaches the root context, and finishes the root span. class OpenTelemetryBackend TRACER_NAME = "appsignal-ruby" @@ -33,11 +37,23 @@ class OpenTelemetryBackend # external-triggered units of work). DEFAULT_SPAN_KIND = :server + # 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" + def initialize(transaction_id, namespace, gc_duration, **) @transaction_id = transaction_id @namespace = namespace @gc_duration = gc_duration @completed = false + @event_stack = [] @span = tracer.start_span( placeholder_span_name(namespace), @@ -49,12 +65,30 @@ def initialize(transaction_id, namespace, gc_duration, **) end def start_event(_gc_duration) + span = tracer.start_span(EVENT_SPAN_PLACEHOLDER_NAME) + token = ::OpenTelemetry::Context.attach( + ::OpenTelemetry::Trace.context_with_span(span) + ) + @event_stack.push([span, token]) end - def finish_event(_name, _title, _body, _body_format, _gc_duration) + def finish_event(name, title, body, body_format, _gc_duration) + return if @event_stack.empty? + + span, token = @event_stack.pop + span.name = name + write_event_body_attributes(span, body, body_format) + span.set_attribute("appsignal.title", title) if title && !title.empty? + ::OpenTelemetry::Context.detach(token) + span.finish end - def record_event(_name, _title, _body, _body_format, _duration, _gc_duration) + def record_event(name, title, body, body_format, duration, _gc_duration) + start_time = Time.now - (duration / 1_000_000_000.0) + span = tracer.start_span(name, :start_timestamp => start_time) + write_event_body_attributes(span, body, body_format) + span.set_attribute("appsignal.title", title) if title && !title.empty? + span.finish end def set_action(_action) # rubocop:disable Naming/AccessorMethodName @@ -84,6 +118,15 @@ def finish(_gc_duration) def complete @completed = true + # Drain unfinished event spans defensively: if `start_event` was + # called without a matching `finish_event` (caller bug or aborted + # flow), the root context can't detach in LIFO order until the + # event tokens are released first. + 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 @@ -120,6 +163,17 @@ def tracer def placeholder_span_name(namespace) "appsignal.transaction #{namespace}" 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/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb b/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb index 846925d9c..c8d9098f0 100644 --- a/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb +++ b/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb @@ -217,14 +217,183 @@ def new_transaction_with_otel_backend(namespace = Appsignal::Transaction::HTTP_R expect(transaction.to_h).to eq({}) end - it "emits exactly one OTel root span on completion" do + 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", 1) + transaction.finish_event("event", "title", "body", Appsignal::EventFormatter::DEFAULT) transaction.complete - expect(span_exporter.finished_spans.size).to eq(1) - expect(span_exporter.finished_spans.first.kind).to eq(:server) + 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(0) + + 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, renames the span, finishes it, and detaches the context" do + backend = create_backend + root_span = backend.instance_variable_get(:@span) + + backend.start_event(0) + backend.finish_event("custom.event", "Title", "Body", + Appsignal::EventFormatter::DEFAULT, 0) + + expect(backend.instance_variable_get(:@event_stack)).to be_empty + expect(::OpenTelemetry::Trace.current_span).to eq(root_span) + + event_span = span_exporter.finished_spans.find { |s| s.name == "custom.event" } + expect(event_span).not_to be_nil + expect(event_span.attributes["appsignal.body"]).to eq("Body") + expect(event_span.attributes["appsignal.title"]).to eq("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, 0) + 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, 0) + + span = span_exporter.finished_spans.find { |s| s.name == "custom.event" } + expect(span).not_to be_nil + 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, 0) + 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(0) + outer_span = backend.instance_variable_get(:@event_stack).last.first + backend.start_event(0) + inner_span = backend.instance_variable_get(:@event_stack).last.first + + backend.finish_event("inner.event", nil, nil, + Appsignal::EventFormatter::DEFAULT, 0) + backend.finish_event("outer.event", nil, nil, + Appsignal::EventFormatter::DEFAULT, 0) + + 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(0) + backend.finish_event("sql.query", "Q", "SELECT 1", + Appsignal::EventFormatter::SQL_BODY_FORMAT, 0) + + attrs = span_exporter.finished_spans.find { |s| s.name == "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(0) + backend.finish_event("custom", "T", "Body", + Appsignal::EventFormatter::DEFAULT, 0) + + attrs = span_exporter.finished_spans.find { |s| s.name == "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(0) + backend.finish_event("no.body", "T", nil, + Appsignal::EventFormatter::DEFAULT, 0) + backend.start_event(0) + backend.finish_event("empty.body", "T", "", + Appsignal::EventFormatter::DEFAULT, 0) + + no_body = span_exporter.finished_spans.find { |s| s.name == "no.body" } + empty_body = span_exporter.finished_spans.find { |s| s.name == "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 "omits appsignal.title when title is empty or nil" do + backend = create_backend + backend.start_event(0) + backend.finish_event("no.title", nil, "Body", + Appsignal::EventFormatter::DEFAULT, 0) + backend.start_event(0) + backend.finish_event("empty.title", "", "Body", + Appsignal::EventFormatter::DEFAULT, 0) + + no_title = span_exporter.finished_spans.find { |s| s.name == "no.title" } + empty_title = span_exporter.finished_spans.find { |s| s.name == "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(0) + backend.start_event(0) + + 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 end diff --git a/spec/lib/appsignal/transaction_integration_spec.rb b/spec/lib/appsignal/transaction_integration_spec.rb index d0e930fdc..8b24babab 100644 --- a/spec/lib/appsignal/transaction_integration_spec.rb +++ b/spec/lib/appsignal/transaction_integration_spec.rb @@ -13,6 +13,25 @@ # # See [[unit-integration-test-rhythm]]. +RSpec.shared_examples "transaction events (mode-agnostic)" do + it "Transaction#instrument returns the block's value" do + transaction = Appsignal::Transaction.create(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 + + it "Transaction#instrument re-raises a block exception" do + transaction = Appsignal::Transaction.create(Appsignal::Transaction::HTTP_REQUEST) + + expect do + transaction.instrument("x.y", nil, nil, Appsignal::EventFormatter::DEFAULT) { raise "boom" } + end.to raise_error("boom") + end +end + RSpec.shared_examples "transaction lifecycle (mode-agnostic)" do it "creates a Transaction with the given namespace and a generated id" do transaction = Appsignal::Transaction.create(Appsignal::Transaction::HTTP_REQUEST) @@ -50,6 +69,7 @@ before { start_agent } include_examples "transaction lifecycle (mode-agnostic)" + include_examples "transaction events (mode-agnostic)" it "serializes id, namespace and completed? via to_h after completion" do transaction = Appsignal::Transaction.create(Appsignal::Transaction::HTTP_REQUEST) @@ -59,6 +79,74 @@ expect(transaction).to have_namespace(Appsignal::Transaction::HTTP_REQUEST) expect(transaction).to be_completed end + + describe "events recorded into to_h" do + it "records an instrumented event with name, title, body and SQL body_format" do + transaction = Appsignal::Transaction.create(Appsignal::Transaction::HTTP_REQUEST) + keep_transactions do + transaction.instrument("sql.active_record", "Query", "SELECT 1", + Appsignal::EventFormatter::SQL_BODY_FORMAT) { nil } + Appsignal::Transaction.complete_current! + end + + expect(transaction).to include_event( + "name" => "sql.active_record", + "title" => "Query", + "body" => "SELECT 1", + "body_format" => Appsignal::EventFormatter::SQL_BODY_FORMAT + ) + end + + it "records an instrumented event with the default body_format" do + transaction = Appsignal::Transaction.create(Appsignal::Transaction::HTTP_REQUEST) + keep_transactions do + transaction.instrument("custom.event", "Title", "Body", + Appsignal::EventFormatter::DEFAULT) { nil } + Appsignal::Transaction.complete_current! + end + + expect(transaction).to include_event( + "name" => "custom.event", + "title" => "Title", + "body" => "Body", + "body_format" => Appsignal::EventFormatter::DEFAULT + ) + end + + it "records a record_event call with the given duration" do + transaction = Appsignal::Transaction.create(Appsignal::Transaction::HTTP_REQUEST) + keep_transactions do + transaction.record_event("custom.event", "T", "B", 1_000_000_000, + Appsignal::EventFormatter::DEFAULT) + Appsignal::Transaction.complete_current! + end + + expect(transaction).to include_event( + "name" => "custom.event", + "title" => "T", + "body" => "B" + ) + end + + it "records both events for nested instrument calls" do + transaction = Appsignal::Transaction.create(Appsignal::Transaction::HTTP_REQUEST) + keep_transactions do + transaction.instrument("outer.event", "Outer", "outer body", + Appsignal::EventFormatter::DEFAULT) do + transaction.instrument("inner.event", "Inner", "inner body", + Appsignal::EventFormatter::DEFAULT) { nil } + end + Appsignal::Transaction.complete_current! + end + + 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 + end end describe "Appsignal::Transaction lifecycle in collector mode" do @@ -90,6 +178,17 @@ after { Appsignal::Transaction.complete_current! } include_examples "transaction lifecycle (mode-agnostic)" + include_examples "transaction events (mode-agnostic)" + + # Helper: the root span (kind == :server / :consumer) vs the event spans + # (kind == :internal by default for `tracer.start_span`). + 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 describe "OpenTelemetry root span shape" do it "starts an OTel root span with SpanKind::SERVER for HTTP_REQUEST" do @@ -150,4 +249,110 @@ expect(span_exporter.finished_spans.size).to eq(1) end end + + describe "events as OpenTelemetry child spans" do + it "creates a child span on instrument with the event name as the span name" do + transaction = Appsignal::Transaction.create(Appsignal::Transaction::HTTP_REQUEST) + transaction.instrument("sql.active_record", "Query", "SELECT 1", + Appsignal::EventFormatter::SQL_BODY_FORMAT) { nil } + Appsignal::Transaction.complete_current! + + expect(event_spans.size).to eq(1) + span = event_spans.first + expect(span.name).to eq("sql.active_record") + expect(span.parent_span_id).to eq(root_span.span_id) + end + + it "writes db.query.text + db.system.name=other_sql for SQL_BODY_FORMAT events" do + transaction = Appsignal::Transaction.create(Appsignal::Transaction::HTTP_REQUEST) + transaction.instrument("sql.active_record", "Query", "SELECT 1", + Appsignal::EventFormatter::SQL_BODY_FORMAT) { nil } + Appsignal::Transaction.complete_current! + + attrs = event_spans.first.attributes + expect(attrs["db.query.text"]).to eq("SELECT 1") + expect(attrs["db.system.name"]).to eq("other_sql") + expect(attrs["appsignal.title"]).to eq("Query") + expect(attrs).not_to have_key("appsignal.body") + end + + it "writes appsignal.body for default-format events (no db.* attrs)" do + transaction = Appsignal::Transaction.create(Appsignal::Transaction::HTTP_REQUEST) + transaction.instrument("custom.event", "Title", "Body", + Appsignal::EventFormatter::DEFAULT) { nil } + Appsignal::Transaction.complete_current! + + attrs = event_spans.first.attributes + expect(attrs["appsignal.body"]).to eq("Body") + expect(attrs["appsignal.title"]).to eq("Title") + expect(attrs).not_to have_key("db.query.text") + expect(attrs).not_to have_key("db.system.name") + end + + it "omits appsignal.title when title is empty" do + transaction = Appsignal::Transaction.create(Appsignal::Transaction::HTTP_REQUEST) + transaction.instrument("custom.event", nil, "Body", + Appsignal::EventFormatter::DEFAULT) { nil } + Appsignal::Transaction.complete_current! + + expect(event_spans.first.attributes).not_to have_key("appsignal.title") + end + + it "omits the body attribute when body is empty" do + transaction = Appsignal::Transaction.create(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 + + it "makes the event span the OTel current span during the block" do + transaction = Appsignal::Transaction.create(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) + + Appsignal::Transaction.complete_current! + end + + it "nests events: the inner span's parent is the outer event span" do + transaction = Appsignal::Transaction.create(Appsignal::Transaction::HTTP_REQUEST) + transaction.instrument("outer.event", "Outer", "outer body", + Appsignal::EventFormatter::DEFAULT) do + transaction.instrument("inner.event", "Inner", "inner body", + Appsignal::EventFormatter::DEFAULT) { nil } + end + Appsignal::Transaction.complete_current! + + outer = event_spans.find { |s| s.name == "outer.event" } + inner = event_spans.find { |s| s.name == "inner.event" } + + expect(inner.parent_span_id).to eq(outer.span_id) + expect(outer.parent_span_id).to eq(root_span.span_id) + end + + it "record_event creates a child span with backdated start_timestamp" do + transaction = Appsignal::Transaction.create(Appsignal::Transaction::HTTP_REQUEST) + duration_ns = 1_000_000_000 # 1 second + transaction.record_event("custom.event", "T", "B", duration_ns, + Appsignal::EventFormatter::DEFAULT) + Appsignal::Transaction.complete_current! + + span = event_spans.first + expect(span.name).to eq("custom.event") + expect(span.parent_span_id).to eq(root_span.span_id) + # OTel timestamps are nanoseconds; allow a small slack for clock jitter. + observed = span.end_timestamp - span.start_timestamp + expect(observed).to be_within(50_000_000).of(duration_ns) + end + end end From de7c371dbbf8908181c5c142bdf9bf7a53a40250 Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Fri, 29 May 2026 13:15:46 +0200 Subject: [PATCH 014/151] Cover hooks and helpers in collector mode Add collector-mode tests for AS::N, DryMonitor, DataMapper, and the public instrument helpers, alongside the existing agent-mode coverage. Uses the original `context "in collector mode"` shape; the dual-mode metadata pattern lands next. --- ...sh_with_state_collector_shared_examples.rb | 23 +++ .../instrument_collector_shared_examples.rb | 92 +++++++++++ .../start_finish_collector_shared_examples.rb | 48 ++++++ .../active_support_notifications_spec.rb | 103 ++++++++++-- spec/lib/appsignal/hooks/dry_monitor_spec.rb | 153 +++++++++++++---- .../integrations/data_mapper_spec.rb | 156 ++++++++++++++---- spec/lib/appsignal_spec.rb | 140 ++++++++++++++++ 7 files changed, 629 insertions(+), 86 deletions(-) create mode 100644 spec/lib/appsignal/hooks/active_support_notifications/finish_with_state_collector_shared_examples.rb create mode 100644 spec/lib/appsignal/hooks/active_support_notifications/instrument_collector_shared_examples.rb create mode 100644 spec/lib/appsignal/hooks/active_support_notifications/start_finish_collector_shared_examples.rb diff --git a/spec/lib/appsignal/hooks/active_support_notifications/finish_with_state_collector_shared_examples.rb b/spec/lib/appsignal/hooks/active_support_notifications/finish_with_state_collector_shared_examples.rb new file mode 100644 index 000000000..edabaf1d7 --- /dev/null +++ b/spec/lib/appsignal/hooks/active_support_notifications/finish_with_state_collector_shared_examples.rb @@ -0,0 +1,23 @@ +shared_examples "activesupport finish_with_state override in collector mode" do + let(:instrumenter) { as.instrumenter } + + it "uses the payload provided at finish_with_state" do + listeners_state = instrumenter.start("sql.active_record", {}) + instrumenter.finish_with_state(listeners_state, "sql.active_record", :sql => "SQL") + Appsignal::Transaction.complete_current! + + 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) + expect(span.attributes["db.query.text"]).to eq("SQL") + expect(span.attributes["db.system.name"]).to eq("other_sql") + end + + it "does not emit a span for 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") + Appsignal::Transaction.complete_current! + + expect(event_spans).to be_empty + end +end diff --git a/spec/lib/appsignal/hooks/active_support_notifications/instrument_collector_shared_examples.rb b/spec/lib/appsignal/hooks/active_support_notifications/instrument_collector_shared_examples.rb new file mode 100644 index 000000000..6a9828601 --- /dev/null +++ b/spec/lib/appsignal/hooks/active_support_notifications/instrument_collector_shared_examples.rb @@ -0,0 +1,92 @@ +shared_examples "activesupport instrument override in collector mode" do + it "emits a child span for an event with a registered formatter" do + return_value = as.instrument("sql.active_record", :sql => "SQL") do + "value" + end + Appsignal::Transaction.complete_current! + + expect(return_value).to eq "value" + 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) + expect(span.attributes["db.query.text"]).to eq("SQL") + expect(span.attributes["db.system.name"]).to eq("other_sql") + expect(span.attributes).not_to have_key("appsignal.title") + expect(span.attributes).not_to have_key("appsignal.body") + end + + it "emits a child span with no body attributes for an unregistered formatter" do + return_value = as.instrument("no-registered.formatter", :key => "something") do + "value" + end + Appsignal::Transaction.complete_current! + + expect(return_value).to eq "value" + 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) + expect(span.attributes).not_to have_key("appsignal.body") + expect(span.attributes).not_to have_key("appsignal.title") + expect(span.attributes).not_to have_key("db.query.text") + expect(span.attributes).not_to have_key("db.system.name") + end + + it "converts non-string event names to strings" do + as.instrument(:not_a_string) {} # rubocop:disable Lint/EmptyBlock + Appsignal::Transaction.complete_current! + + expect(event_spans.map(&:name)).to include("not_a_string") + end + + it "does not emit a span for events whose name starts with a bang" do + return_value = as.instrument("!sql.active_record", :sql => "SQL") do + "value" + end + Appsignal::Transaction.complete_current! + + expect(return_value).to eq "value" + expect(event_spans).to be_empty + end + + context "when an error is raised in an instrumented block" do + it "emits the child span and re-raises the error" do + expect do + as.instrument("sql.active_record", :sql => "SQL") do + raise ExampleException, "foo" + end + end.to raise_error(ExampleException, "foo") + Appsignal::Transaction.complete_current! + + span = event_spans.find { |s| s.name == "sql.active_record" } + expect(span).not_to be_nil + 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 "emits the child span and propagates the throw" do + expect do + as.instrument("sql.active_record", :sql => "SQL") do + throw :foo + end + end.to throw_symbol(:foo) + Appsignal::Transaction.complete_current! + + span = event_spans.find { |s| s.name == "sql.active_record" } + expect(span).not_to be_nil + expect(span.attributes["db.query.text"]).to eq("SQL") + end + end + + context "when a transaction is completed in an instrumented block" do + it "does not emit a span named after the in-flight event" do + as.instrument("sql.active_record", :sql => "SQL") do + Appsignal::Transaction.complete_current! + end + + 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_collector_shared_examples.rb b/spec/lib/appsignal/hooks/active_support_notifications/start_finish_collector_shared_examples.rb new file mode 100644 index 000000000..ecc39096b --- /dev/null +++ b/spec/lib/appsignal/hooks/active_support_notifications/start_finish_collector_shared_examples.rb @@ -0,0 +1,48 @@ +shared_examples "activesupport start finish override in collector mode" do + let(:instrumenter) { as.instrumenter } + + it "ignores the payload from the start call and uses the finish payload" do + instrumenter.start("sql.active_record", :sql => "SQL") + instrumenter.finish("sql.active_record", {}) + Appsignal::Transaction.complete_current! + + 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) + # 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 + + it "uses the payload provided at finish" do + instrumenter.start("sql.active_record", {}) + instrumenter.finish("sql.active_record", :sql => "SQL") + Appsignal::Transaction.complete_current! + + 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) + expect(span.attributes["db.query.text"]).to eq("SQL") + expect(span.attributes["db.system.name"]).to eq("other_sql") + end + + it "does not emit a span for events whose name starts with a bang" do + instrumenter.start("!sql.active_record", {}) + instrumenter.finish("!sql.active_record", {}) + Appsignal::Transaction.complete_current! + + expect(event_spans).to be_empty + end + + context "when the transaction is completed between start and finish" do + it "does not emit a span named after the in-flight event" do + instrumenter.start("sql.active_record", {}) + Appsignal::Transaction.complete_current! + instrumenter.finish("sql.active_record", {}) + + 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..7fbf05725 100644 --- a/spec/lib/appsignal/hooks/active_support_notifications_spec.rb +++ b/spec/lib/appsignal/hooks/active_support_notifications_spec.rb @@ -4,13 +4,6 @@ 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 - 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 @@ -30,24 +23,98 @@ it { is_expected.to be_truthy } end - it_behaves_like "activesupport instrument override" + describe "in agent mode" do + let(:transaction) { http_request_transaction } + before do + start_agent + set_current_transaction(transaction) + as.notifier = notifier + end + around { |example| keep_transactions { example.run } } - if defined?(::ActiveSupport::Notifications::Fanout::Handle) - require_relative "active_support_notifications/start_finish_shared_examples" + it_behaves_like "activesupport instrument override" - it_behaves_like "activesupport start finish override" - end + if defined?(::ActiveSupport::Notifications::Fanout::Handle) + require_relative "active_support_notifications/start_finish_shared_examples" + + it_behaves_like "activesupport start finish override" + end + + if ::ActiveSupport::Notifications::Instrumenter.method_defined?(:start) + require_relative "active_support_notifications/start_finish_shared_examples" + + it_behaves_like "activesupport start finish override" + end - if ::ActiveSupport::Notifications::Instrumenter.method_defined?(:start) - require_relative "active_support_notifications/start_finish_shared_examples" + if ::ActiveSupport::Notifications::Instrumenter.method_defined?(:finish_with_state) + require_relative "active_support_notifications/finish_with_state_shared_examples" - it_behaves_like "activesupport start finish override" + it_behaves_like "activesupport finish_with_state override" + end end - if ::ActiveSupport::Notifications::Instrumenter.method_defined?(:finish_with_state) - require_relative "active_support_notifications/finish_with_state_shared_examples" + describe "in collector mode" do + require "opentelemetry/sdk" + require_relative "active_support_notifications/instrument_collector_shared_examples" + + 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 + start_agent(:options => { :collector_endpoint => "http://127.0.0.1:9090" }) + # Replace the tracer provider booted by Appsignal::OpenTelemetry.configure + # with one whose processor pushes into our in-memory exporter, so we + # can inspect spans inside the test instead of trying to flush them + # out over OTLP/HTTP. Mirrors transaction_integration_spec.rb. + ::OpenTelemetry.tracer_provider = tracer_provider + @transaction = Appsignal::Transaction.create(Appsignal::Transaction::HTTP_REQUEST) + as.notifier = notifier + end + + # complete_current! clears both the thread-local and the attached OTel + # context. spec_helper's clear_current_transaction! only handles the + # former, so a leaked OTel context would pollute the next test's + # current_span reading. + after { Appsignal::Transaction.complete_current! } + + # Bind to the specific Transaction created in `before` (not to + # Appsignal::Transaction.current, which becomes NilTransaction after + # `complete_current!` and has no backend). + let(:transaction) { @transaction } + + 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 + + it_behaves_like "activesupport instrument override in collector mode" + + if defined?(::ActiveSupport::Notifications::Fanout::Handle) + require_relative "active_support_notifications/start_finish_collector_shared_examples" + + it_behaves_like "activesupport start finish override in collector mode" + end + + if ::ActiveSupport::Notifications::Instrumenter.method_defined?(:start) + require_relative "active_support_notifications/start_finish_collector_shared_examples" + + it_behaves_like "activesupport start finish override in collector mode" + end + + if ::ActiveSupport::Notifications::Instrumenter.method_defined?(:finish_with_state) + require_relative "active_support_notifications/finish_with_state_collector_shared_examples" - it_behaves_like "activesupport finish_with_state override" + it_behaves_like "activesupport finish_with_state override in collector mode" + end end else describe "#dependencies_present?" do diff --git a/spec/lib/appsignal/hooks/dry_monitor_spec.rb b/spec/lib/appsignal/hooks/dry_monitor_spec.rb index 43e183058..e5489e81b 100644 --- a/spec/lib/appsignal/hooks/dry_monitor_spec.rb +++ b/spec/lib/appsignal/hooks/dry_monitor_spec.rb @@ -34,49 +34,132 @@ 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 - let(:event_id) { :sql } - let(:payload) do - { - :name => "postgres", - :query => "SELECT * FROM users" - } + context "in agent mode" do + before do + start_agent + set_current_transaction(transaction) end - it "creates an sql event" do - notifications.instrument(event_id, payload) - expect(transaction).to include_event( - "body" => "SELECT * FROM users", - "body_format" => Appsignal::EventFormatter::SQL_BODY_FORMAT, - "count" => 1, - "name" => "query.postgres", - "title" => "query.postgres" - ) + context "when is a dry-sql event" do + let(:event_id) { :sql } + let(:payload) do + { + :name => "postgres", + :query => "SELECT * FROM users" + } + end + + it "creates an sql event" do + notifications.instrument(event_id, payload) + expect(transaction).to include_event( + "body" => "SELECT * FROM users", + "body_format" => Appsignal::EventFormatter::SQL_BODY_FORMAT, + "count" => 1, + "name" => "query.postgres", + "title" => "query.postgres" + ) + end end - end - context "when is an unregistered formatter event" do - let(:event_id) { :foo } - let(:payload) do - { - :name => "foo" - } + context "when is an unregistered formatter event" do + let(:event_id) { :foo } + let(:payload) do + { + :name => "foo" + } + end + + it "creates a generic event" do + notifications.instrument(event_id, payload) + expect(transaction).to include_event( + "body" => "", + "body_format" => Appsignal::EventFormatter::DEFAULT, + "count" => 1, + "name" => "foo", + "title" => "" + ) + end end + end + + context "in collector mode" do + require "opentelemetry/sdk" - it "creates a generic event" do - notifications.instrument(event_id, payload) - expect(transaction).to include_event( - "body" => "", - "body_format" => Appsignal::EventFormatter::DEFAULT, - "count" => 1, - "name" => "foo", - "title" => "" + 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 + start_agent(:options => { :collector_endpoint => "http://127.0.0.1:9090" }) + # Replace the tracer provider booted by Appsignal::OpenTelemetry.configure + # with one whose processor pushes into our in-memory exporter, so we can + # inspect spans inside the test instead of trying to flush them out over + # OTLP/HTTP. + ::OpenTelemetry.tracer_provider = tracer_provider + set_current_transaction(transaction) + end + after { Appsignal::Transaction.complete_current! } + + 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 + + context "when is a dry-sql event" do + let(:event_id) { :sql } + let(:payload) do + { + :name => "postgres", + :query => "SELECT * FROM users" + } + end + + it "emits a child span with SQL semantic attributes" do + notifications.instrument(event_id, payload) + Appsignal::Transaction.complete_current! + + expect(event_spans.size).to eq(1) + span = event_spans.first + expect(span.name).to eq("query.postgres") + expect(span.parent_span_id).to eq(root_span.span_id) + 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.title"]).to eq("query.postgres") + expect(attrs).not_to have_key("appsignal.body") + end + end + + context "when is an unregistered formatter event" do + let(:event_id) { :foo } + let(:payload) do + { + :name => "foo" + } + end + + it "emits a child span with the event id as the name and no body/title attrs" do + notifications.instrument(event_id, payload) + Appsignal::Transaction.complete_current! + + expect(event_spans.size).to eq(1) + span = event_spans.first + expect(span.name).to eq("foo") + expect(span.parent_span_id).to eq(root_span.span_id) + attrs = span.attributes + expect(attrs).not_to have_key("appsignal.title") + 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/integrations/data_mapper_spec.rb b/spec/lib/appsignal/integrations/data_mapper_spec.rb index 0b0fd58e6..ccf1fb3c9 100644 --- a/spec/lib/appsignal/integrations/data_mapper_spec.rb +++ b/spec/lib/appsignal/integrations/data_mapper_spec.rb @@ -15,56 +15,146 @@ 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 - let(:connection_class) { DataObjects::Sqlite3::Connection } + context "in agent mode" do before do - stub_const("DataObjects::Sqlite3::Connection", Class.new do - include DataMapperLog - include Appsignal::Hooks::DataMapperLogListener - end) + start_agent + set_current_transaction(transaction) end + around { |example| keep_transactions { example.run } } - it "records the log entry in an event" do - log_message + context "when the scheme is SQL-like" do + let(:connection_class) { DataObjects::Sqlite3::Connection } + before do + stub_const("DataObjects::Sqlite3::Connection", Class.new do + include DataMapperLog + include Appsignal::Hooks::DataMapperLogListener + end) + end - expect(transaction).to include_event( - "name" => "query.data_mapper", - "title" => "DataMapper Query", - "body" => "SELECT * from users", - "body_format" => Appsignal::EventFormatter::SQL_BODY_FORMAT, - "duration" => 100.0 - ) + it "records the log entry in an event" do + log_message + + expect(transaction).to include_event( + "name" => "query.data_mapper", + "title" => "DataMapper Query", + "body" => "SELECT * from users", + "body_format" => Appsignal::EventFormatter::SQL_BODY_FORMAT, + "duration" => 100.0 + ) + end + end + + context "when the scheme is not SQL-like" do + let(:connection_class) { DataObjects::MongoDB::Connection } + before do + stub_const("DataObjects::MongoDB::Connection", Class.new do + include DataMapperLog + include Appsignal::Hooks::DataMapperLogListener + end) + end + + it "records the log entry in an event without body" do + log_message + + expect(transaction).to include_event( + "name" => "query.data_mapper", + "title" => "DataMapper Query", + "body" => "", + "body_format" => Appsignal::EventFormatter::DEFAULT, + "duration" => 100.0 + ) + end end end - context "when the scheme is not SQL-like" do - let(:connection_class) { DataObjects::MongoDB::Connection } + context "in collector mode" do + require "opentelemetry/sdk" + + 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 - stub_const("DataObjects::MongoDB::Connection", Class.new do - include DataMapperLog - include Appsignal::Hooks::DataMapperLogListener - end) + start_agent(:options => { :collector_endpoint => "http://127.0.0.1:9090" }) + # Replace the tracer provider booted by Appsignal::OpenTelemetry.configure + # with one whose processor pushes into our in-memory exporter, so we can + # inspect spans inside the test instead of trying to flush them out over + # OTLP/HTTP. + ::OpenTelemetry.tracer_provider = tracer_provider + set_current_transaction(transaction) end + after { Appsignal::Transaction.complete_current! } - it "records the log entry in an event without body" do - log_message + def root_span + span_exporter.finished_spans.find { |s| [:server, :consumer].include?(s.kind) } + end - expect(transaction).to include_event( - "name" => "query.data_mapper", - "title" => "DataMapper Query", - "body" => "", - "body_format" => Appsignal::EventFormatter::DEFAULT, - "duration" => 100.0 - ) + def event_spans + span_exporter.finished_spans.reject { |s| [:server, :consumer].include?(s.kind) } + end + + context "when the scheme is SQL-like" do + let(:connection_class) { DataObjects::Sqlite3::Connection } + before do + stub_const("DataObjects::Sqlite3::Connection", Class.new do + include DataMapperLog + include Appsignal::Hooks::DataMapperLogListener + end) + end + + it "emits a child span with SQL semantic attributes and the recorded duration" do + log_message + Appsignal::Transaction.complete_current! + + expect(event_spans.size).to eq(1) + span = event_spans.first + expect(span.name).to eq("query.data_mapper") + 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.title"]).to eq("DataMapper Query") + 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 + let(:connection_class) { DataObjects::MongoDB::Connection } + before do + stub_const("DataObjects::MongoDB::Connection", Class.new do + include DataMapperLog + include Appsignal::Hooks::DataMapperLogListener + end) + end + + it "emits a child span with no body and the recorded duration" do + log_message + Appsignal::Transaction.complete_current! + + expect(event_spans.size).to eq(1) + span = event_spans.first + expect(span.name).to eq("query.data_mapper") + expect(span.parent_span_id).to eq(root_span.span_id) + attrs = span.attributes + expect(attrs["appsignal.title"]).to eq("DataMapper Query") + 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_spec.rb b/spec/lib/appsignal_spec.rb index d5b3bf2f9..27f9b2b27 100644 --- a/spec/lib/appsignal_spec.rb +++ b/spec/lib/appsignal_spec.rb @@ -2205,6 +2205,146 @@ def on_start end end + context "in collector mode" do + require "opentelemetry/sdk" + + 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 + start_agent(:options => { :collector_endpoint => "http://127.0.0.1:9090" }) + ::OpenTelemetry.tracer_provider = tracer_provider + end + + # complete_current! clears both the Transaction thread-local AND the + # OTel context (the default clear_current_transaction! only clears + # the thread-local, which would leave the OTel context attached and + # leak into the next test). + after { Appsignal::Transaction.complete_current! } + + 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 + + describe ".instrument" do + before { set_current_transaction(transaction) } + + it "records an OTel child span around the given block" do + return_value = Appsignal.instrument "name", "title", "body" do + :block_result + end + expect(return_value).to eq :block_result + + Appsignal::Transaction.complete_current! + + expect(event_spans.size).to eq 1 + span = event_spans.first + expect(span.name).to eq "name" + expect(span.parent_span_id).to eq root_span.span_id + expect(span.attributes["appsignal.title"]).to eq "title" + 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 + + context "with an error raised in the passed block" do + it "still records the event span" do + expect do + Appsignal.instrument "name", "title", "body" do + raise ExampleException, "foo" + end + end.to raise_error(ExampleException, "foo") + + Appsignal::Transaction.complete_current! + + expect(event_spans.size).to eq 1 + span = event_spans.first + expect(span.name).to eq "name" + expect(span.attributes["appsignal.title"]).to eq "title" + expect(span.attributes["appsignal.body"]).to eq "body" + end + end + + context "with a symbol thrown in the passed block" do + it "still records the event span" do + expect do + Appsignal.instrument "name", "title", "body" do + throw :foo + end + end.to throw_symbol(:foo) + + Appsignal::Transaction.complete_current! + + expect(event_spans.size).to eq 1 + span = event_spans.first + expect(span.name).to eq "name" + expect(span.attributes["appsignal.title"]).to eq "title" + expect(span.attributes["appsignal.body"]).to eq "body" + end + end + end + + describe ".instrument_sql" do + before { set_current_transaction(transaction) } + + it "creates an SQL OTel child span on the transaction" do + result = + Appsignal.instrument_sql "name", "title", "body" do + "return value" + end + expect(result).to eq "return value" + + Appsignal::Transaction.complete_current! + + expect(event_spans.size).to eq 1 + span = event_spans.first + expect(span.name).to eq "name" + expect(span.parent_span_id).to eq root_span.span_id + expect(span.attributes["appsignal.title"]).to eq "title" + expect(span.attributes["db.query.text"]).to eq "body" + expect(span.attributes["db.system.name"]).to eq "other_sql" + expect(span.attributes).not_to have_key("appsignal.body") + end + end + + describe ".ignore_instrumentation_events" do + context "with current transaction" do + before { set_current_transaction(transaction) } + + it "does not emit OTel spans for ignored events but emits them for recorded events" do + 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 + + context "without current transaction" do + it "does not crash" do + expect do + Appsignal.ignore_instrumentation_events { :do_nothing } + end.not_to raise_error + end + end + end + end + describe "._start_logger" do let(:out_stream) { std_stream } let(:output) { out_stream.read } From 3bcab308ab61bcdfd599a6a7fa31db271bdb718e Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Mon, 1 Jun 2026 13:45:19 +0200 Subject: [PATCH 015/151] Add :agent_mode / :collector_mode test contexts Lay the groundwork for restructuring dual-mode specs around RSpec example metadata. `:collector_mode` boots Appsignal with a collector endpoint and an in-memory span exporter; `:agent_mode` just starts the agent. The `it_in_both_modes` helper produces a pair of mode-tagged examples for assertions that should hold under both backends. The collector context boots a full OTel SDK per example, so it also tears it down: the booted tracer provider is shut down before the in-memory swap orphans it, and the meter and logger providers are shut down in an after hook. The targeted `Appsignal::OpenTelemetry.shutdown` rather than `Appsignal.stop`: stop's `Extension.stop` takes ~2 seconds per call, which across every collector-mode example would add minutes to the suite. Nothing uses these yet -- follow-up commits migrate the existing agent/collector-mode test pairs onto this scaffolding. --- spec/spec_helper.rb | 3 ++ spec/support/helpers/mode_helpers.rb | 10 ++++ spec/support/shared_contexts/agent_mode.rb | 9 ++++ .../support/shared_contexts/collector_mode.rb | 49 +++++++++++++++++++ 4 files changed, 71 insertions(+) create mode 100644 spec/support/helpers/mode_helpers.rb create mode 100644 spec/support/shared_contexts/agent_mode.rb create mode 100644 spec/support/shared_contexts/collector_mode.rb diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 619a7b7da..aef78c143 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")].each do |f| + require f +end if DependencyHelper.rails_present? require File.join(ConfigHelpers.rails_project_fixture_path, "config/application.rb") end diff --git a/spec/support/helpers/mode_helpers.rb b/spec/support/helpers/mode_helpers.rb new file mode 100644 index 000000000..17091e9cb --- /dev/null +++ b/spec/support/helpers/mode_helpers.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module ModeHelpers + def it_in_both_modes(&block) + it("in agent mode", :agent_mode, &block) + it("in collector mode", :collector_mode, &block) + end +end + +RSpec.configure { |config| config.extend ModeHelpers } diff --git a/spec/support/shared_contexts/agent_mode.rb b/spec/support/shared_contexts/agent_mode.rb new file mode 100644 index 000000000..2f775d2d8 --- /dev/null +++ b/spec/support/shared_contexts/agent_mode.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +RSpec.shared_context "agent mode", :agent_mode do + before { start_agent } +end + +RSpec.configure do |config| + config.include_context "agent mode", :agent_mode +end diff --git a/spec/support/shared_contexts/collector_mode.rb b/spec/support/shared_contexts/collector_mode.rb new file mode 100644 index 000000000..1089459d7 --- /dev/null +++ b/spec/support/shared_contexts/collector_mode.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +require "opentelemetry/sdk" + +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 + + before do + start_agent(:options => { :collector_endpoint => "http://127.0.0.1:9090" }) + # `Appsignal.start` booted a full SDK tracer provider backed by a + # BatchSpanProcessor (a background export thread). Shut it down before + # swapping in the threadless in-memory provider: after the swap it is + # unreachable and its thread would leak across examples. + ::OpenTelemetry.tracer_provider.shutdown + ::OpenTelemetry.tracer_provider = tracer_provider + end + + 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 the OTel SDK down so the meter and logger providers' background + # threads don't 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 + 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 +end + +RSpec.configure do |config| + config.include_context "collector mode", :collector_mode +end From 66568e6e697b752513234ed0e5811a38747ff0b2 Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Mon, 1 Jun 2026 13:55:49 +0200 Subject: [PATCH 016/151] Restructure tests around mode metadata Lift agent-mode and collector-mode tests out of their separate contexts into sibling `it`s inside each scenario describe, driven by the `:agent_mode` / `:collector_mode` metadata that the shared contexts auto-include. --- ...sh_with_state_collector_shared_examples.rb | 23 - .../finish_with_state_shared_examples.rb | 70 ++- .../instrument_collector_shared_examples.rb | 92 --- .../instrument_shared_examples.rb | 223 +++++-- .../start_finish_collector_shared_examples.rb | 48 -- .../start_finish_shared_examples.rb | 137 ++++- .../active_support_notifications_spec.rb | 96 +-- spec/lib/appsignal/hooks/dry_monitor_spec.rb | 176 ++---- .../integrations/data_mapper_spec.rb | 186 +++--- .../appsignal/transaction_integration_spec.rb | 358 ----------- spec/lib/appsignal/transaction_spec.rb | 290 ++++++++- spec/lib/appsignal_spec.rb | 569 +++++++++--------- spec/support/shared_contexts/agent_mode.rb | 6 + 13 files changed, 1071 insertions(+), 1203 deletions(-) delete mode 100644 spec/lib/appsignal/hooks/active_support_notifications/finish_with_state_collector_shared_examples.rb delete mode 100644 spec/lib/appsignal/hooks/active_support_notifications/instrument_collector_shared_examples.rb delete mode 100644 spec/lib/appsignal/hooks/active_support_notifications/start_finish_collector_shared_examples.rb delete mode 100644 spec/lib/appsignal/transaction_integration_spec.rb diff --git a/spec/lib/appsignal/hooks/active_support_notifications/finish_with_state_collector_shared_examples.rb b/spec/lib/appsignal/hooks/active_support_notifications/finish_with_state_collector_shared_examples.rb deleted file mode 100644 index edabaf1d7..000000000 --- a/spec/lib/appsignal/hooks/active_support_notifications/finish_with_state_collector_shared_examples.rb +++ /dev/null @@ -1,23 +0,0 @@ -shared_examples "activesupport finish_with_state override in collector mode" do - let(:instrumenter) { as.instrumenter } - - it "uses the payload provided at finish_with_state" do - listeners_state = instrumenter.start("sql.active_record", {}) - instrumenter.finish_with_state(listeners_state, "sql.active_record", :sql => "SQL") - Appsignal::Transaction.complete_current! - - 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) - expect(span.attributes["db.query.text"]).to eq("SQL") - expect(span.attributes["db.system.name"]).to eq("other_sql") - end - - it "does not emit a span for 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") - Appsignal::Transaction.complete_current! - - expect(event_spans).to be_empty - end -end 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..35c81ccbe 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,63 @@ 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 + it "in agent mode", :agent_mode do + transaction = http_request_transaction + set_current_transaction(transaction) + as.notifier = notifier + + 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" => "" + ) + end + + it "in collector mode", :collector_mode do + transaction = http_request_transaction + set_current_transaction(transaction) + as.notifier = notifier + + listeners_state = instrumenter.start("sql.active_record", {}) + instrumenter.finish_with_state(listeners_state, "sql.active_record", :sql => "SQL") + Appsignal::Transaction.complete_current! + + 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) + 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 + it "in agent mode", :agent_mode do + transaction = http_request_transaction + set_current_transaction(transaction) + as.notifier = notifier + + listeners_state = instrumenter.start("!sql.active_record", {}) + instrumenter.finish_with_state(listeners_state, "!sql.active_record", :sql => "SQL") + + expect(transaction).to_not include_events + end + + it "in collector mode", :collector_mode do + transaction = http_request_transaction + set_current_transaction(transaction) + as.notifier = notifier + + listeners_state = instrumenter.start("!sql.active_record", {}) + instrumenter.finish_with_state(listeners_state, "!sql.active_record", :sql => "SQL") + 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_collector_shared_examples.rb b/spec/lib/appsignal/hooks/active_support_notifications/instrument_collector_shared_examples.rb deleted file mode 100644 index 6a9828601..000000000 --- a/spec/lib/appsignal/hooks/active_support_notifications/instrument_collector_shared_examples.rb +++ /dev/null @@ -1,92 +0,0 @@ -shared_examples "activesupport instrument override in collector mode" do - it "emits a child span for an event with a registered formatter" do - return_value = as.instrument("sql.active_record", :sql => "SQL") do - "value" - end - Appsignal::Transaction.complete_current! - - expect(return_value).to eq "value" - 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) - expect(span.attributes["db.query.text"]).to eq("SQL") - expect(span.attributes["db.system.name"]).to eq("other_sql") - expect(span.attributes).not_to have_key("appsignal.title") - expect(span.attributes).not_to have_key("appsignal.body") - end - - it "emits a child span with no body attributes for an unregistered formatter" do - return_value = as.instrument("no-registered.formatter", :key => "something") do - "value" - end - Appsignal::Transaction.complete_current! - - expect(return_value).to eq "value" - 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) - expect(span.attributes).not_to have_key("appsignal.body") - expect(span.attributes).not_to have_key("appsignal.title") - expect(span.attributes).not_to have_key("db.query.text") - expect(span.attributes).not_to have_key("db.system.name") - end - - it "converts non-string event names to strings" do - as.instrument(:not_a_string) {} # rubocop:disable Lint/EmptyBlock - Appsignal::Transaction.complete_current! - - expect(event_spans.map(&:name)).to include("not_a_string") - end - - it "does not emit a span for events whose name starts with a bang" do - return_value = as.instrument("!sql.active_record", :sql => "SQL") do - "value" - end - Appsignal::Transaction.complete_current! - - expect(return_value).to eq "value" - expect(event_spans).to be_empty - end - - context "when an error is raised in an instrumented block" do - it "emits the child span and re-raises the error" do - expect do - as.instrument("sql.active_record", :sql => "SQL") do - raise ExampleException, "foo" - end - end.to raise_error(ExampleException, "foo") - Appsignal::Transaction.complete_current! - - span = event_spans.find { |s| s.name == "sql.active_record" } - expect(span).not_to be_nil - 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 "emits the child span and propagates the throw" do - expect do - as.instrument("sql.active_record", :sql => "SQL") do - throw :foo - end - end.to throw_symbol(:foo) - Appsignal::Transaction.complete_current! - - span = event_spans.find { |s| s.name == "sql.active_record" } - expect(span).not_to be_nil - expect(span.attributes["db.query.text"]).to eq("SQL") - end - end - - context "when a transaction is completed in an instrumented block" do - it "does not emit a span named after the in-flight event" do - as.instrument("sql.active_record", :sql => "SQL") do - Appsignal::Transaction.complete_current! - end - - 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/instrument_shared_examples.rb b/spec/lib/appsignal/hooks/active_support_notifications/instrument_shared_examples.rb index 5cddcaf12..44c22b0a2 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,67 +1,138 @@ 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" + describe "an event with a registered formatter" do + it "in agent mode", :agent_mode do + transaction = http_request_transaction + set_current_transaction(transaction) + as.notifier = notifier + + return_value = as.instrument("sql.active_record", :sql => "SQL") { "value" } + + 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" => "" + ) 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" => "" - ) + it "in collector mode", :collector_mode do + transaction = http_request_transaction + set_current_transaction(transaction) + as.notifier = notifier + + return_value = as.instrument("sql.active_record", :sql => "SQL") { "value" } + Appsignal::Transaction.complete_current! + + expect(return_value).to eq "value" + 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) + expect(span.attributes["db.query.text"]).to eq("SQL") + expect(span.attributes["db.system.name"]).to eq("other_sql") + expect(span.attributes).not_to have_key("appsignal.title") + 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 + it "in agent mode", :agent_mode do + transaction = http_request_transaction + set_current_transaction(transaction) + as.notifier = notifier + + return_value = as.instrument("no-registered.formatter", :key => "something") { "value" } + + expect(return_value).to eq "value" + expect(transaction).to include_event( + "body" => "", + "body_format" => Appsignal::EventFormatter::DEFAULT, + "count" => 1, + "name" => "no-registered.formatter", + "title" => "" + ) end - expect(return_value).to eq "value" - expect(transaction).to include_event( - "body" => "", - "body_format" => Appsignal::EventFormatter::DEFAULT, - "count" => 1, - "name" => "no-registered.formatter", - "title" => "" - ) + it "in collector mode", :collector_mode do + transaction = http_request_transaction + set_current_transaction(transaction) + as.notifier = notifier + + return_value = as.instrument("no-registered.formatter", :key => "something") { "value" } + Appsignal::Transaction.complete_current! + + expect(return_value).to eq "value" + 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) + expect(span.attributes).not_to have_key("appsignal.body") + expect(span.attributes).not_to have_key("appsignal.title") + 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" => "" - ) - end + describe "an event with a non-string name" do + it "in agent mode", :agent_mode do + transaction = http_request_transaction + set_current_transaction(transaction) + as.notifier = notifier - it "does not instrument events whose name starts with a bang" do - return_value = as.instrument("!sql.active_record", :sql => "SQL") do - "value" + 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" => "" + ) end - expect(return_value).to eq "value" + it "in collector mode", :collector_mode do + transaction = http_request_transaction + set_current_transaction(transaction) + as.notifier = notifier - expect(transaction).to_not include_events + as.instrument(:not_a_string) {} # rubocop:disable Lint/EmptyBlock + Appsignal::Transaction.complete_current! + + expect(event_spans.map(&:name)).to include("not_a_string") + 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 "an event whose name starts with a bang" do + it "in agent mode", :agent_mode do + transaction = http_request_transaction + set_current_transaction(transaction) + as.notifier = notifier + + return_value = as.instrument("!sql.active_record", :sql => "SQL") { "value" } + + expect(return_value).to eq "value" + expect(transaction).to_not include_events end - expect(return_value).to eq "value" + it "in collector mode", :collector_mode do + transaction = http_request_transaction + set_current_transaction(transaction) + as.notifier = notifier - expect(transaction).to_not include_events + return_value = as.instrument("!sql.active_record", :sql => "SQL") { "value" } + Appsignal::Transaction.complete_current! + + expect(return_value).to eq "value" + 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 + it "in agent mode", :agent_mode do + transaction = http_request_transaction + set_current_transaction(transaction) + as.notifier = notifier + expect do as.instrument("sql.active_record", :sql => "SQL") do raise ExampleException, "foo" @@ -76,14 +147,34 @@ "title" => "" ) end - end - context "when a message is thrown in an instrumented block" do - it "instruments an ActiveSupport::Notifications.instrument event" do + it "in collector mode", :collector_mode do + transaction = http_request_transaction + set_current_transaction(transaction) + as.notifier = notifier + expect do as.instrument("sql.active_record", :sql => "SQL") do - throw :foo + raise ExampleException, "foo" end + end.to raise_error(ExampleException, "foo") + Appsignal::Transaction.complete_current! + + span = event_spans.find { |s| s.name == "sql.active_record" } + expect(span).not_to be_nil + expect(span.attributes["db.query.text"]).to eq("SQL") + expect(span.attributes["db.system.name"]).to eq("other_sql") + end + end + + describe "when a message is thrown in an instrumented block" do + it "in agent mode", :agent_mode do + transaction = http_request_transaction + set_current_transaction(transaction) + as.notifier = notifier + + expect do + as.instrument("sql.active_record", :sql => "SQL") { throw :foo } end.to throw_symbol(:foo) expect(transaction).to include_event( @@ -94,10 +185,29 @@ "title" => "" ) end + + it "in collector mode", :collector_mode do + transaction = http_request_transaction + set_current_transaction(transaction) + as.notifier = notifier + + expect do + as.instrument("sql.active_record", :sql => "SQL") { throw :foo } + end.to throw_symbol(:foo) + Appsignal::Transaction.complete_current! + + span = event_spans.find { |s| s.name == "sql.active_record" } + expect(span).not_to be_nil + expect(span.attributes["db.query.text"]).to eq("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 + it "in agent mode", :agent_mode do + transaction = http_request_transaction + set_current_transaction(transaction) + as.notifier = notifier + as.instrument("sql.active_record", :sql => "SQL") do Appsignal::Transaction.complete_current! end @@ -105,5 +215,18 @@ expect(transaction).to_not include_events expect(transaction).to be_completed end + + it "in collector mode", :collector_mode do + transaction = http_request_transaction + set_current_transaction(transaction) + as.notifier = notifier + + as.instrument("sql.active_record", :sql => "SQL") do + Appsignal::Transaction.complete_current! + end + + 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_collector_shared_examples.rb b/spec/lib/appsignal/hooks/active_support_notifications/start_finish_collector_shared_examples.rb deleted file mode 100644 index ecc39096b..000000000 --- a/spec/lib/appsignal/hooks/active_support_notifications/start_finish_collector_shared_examples.rb +++ /dev/null @@ -1,48 +0,0 @@ -shared_examples "activesupport start finish override in collector mode" do - let(:instrumenter) { as.instrumenter } - - it "ignores the payload from the start call and uses the finish payload" do - instrumenter.start("sql.active_record", :sql => "SQL") - instrumenter.finish("sql.active_record", {}) - Appsignal::Transaction.complete_current! - - 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) - # 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 - - it "uses the payload provided at finish" do - instrumenter.start("sql.active_record", {}) - instrumenter.finish("sql.active_record", :sql => "SQL") - Appsignal::Transaction.complete_current! - - 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) - expect(span.attributes["db.query.text"]).to eq("SQL") - expect(span.attributes["db.system.name"]).to eq("other_sql") - end - - it "does not emit a span for events whose name starts with a bang" do - instrumenter.start("!sql.active_record", {}) - instrumenter.finish("!sql.active_record", {}) - Appsignal::Transaction.complete_current! - - expect(event_spans).to be_empty - end - - context "when the transaction is completed between start and finish" do - it "does not emit a span named after the in-flight event" do - instrumenter.start("sql.active_record", {}) - Appsignal::Transaction.complete_current! - instrumenter.finish("sql.active_record", {}) - - 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..21abd2a32 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,41 +1,109 @@ 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 + it "in agent mode", :agent_mode do + transaction = http_request_transaction + set_current_transaction(transaction) + as.notifier = notifier + + 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" => "" + ) + end + + it "in collector mode", :collector_mode do + transaction = http_request_transaction + set_current_transaction(transaction) + as.notifier = notifier + + instrumenter.start("sql.active_record", :sql => "SQL") + instrumenter.finish("sql.active_record", {}) + Appsignal::Transaction.complete_current! + + 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) + # 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 + it "in agent mode", :agent_mode do + transaction = http_request_transaction + set_current_transaction(transaction) + as.notifier = notifier + + 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" => "" + ) + end + + it "in collector mode", :collector_mode do + transaction = http_request_transaction + set_current_transaction(transaction) + as.notifier = notifier + + instrumenter.start("sql.active_record", {}) + instrumenter.finish("sql.active_record", :sql => "SQL") + Appsignal::Transaction.complete_current! + + 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) + 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 + it "in agent mode", :agent_mode do + transaction = http_request_transaction + set_current_transaction(transaction) + as.notifier = notifier + + instrumenter.start("!sql.active_record", {}) + instrumenter.finish("!sql.active_record", {}) + + expect(transaction).to_not include_events + end - expect(transaction).to_not include_events + it "in collector mode", :collector_mode do + transaction = http_request_transaction + set_current_transaction(transaction) + as.notifier = notifier + + instrumenter.start("!sql.active_record", {}) + instrumenter.finish("!sql.active_record", {}) + Appsignal::Transaction.complete_current! + + 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 + it "in agent mode", :agent_mode do + transaction = http_request_transaction + set_current_transaction(transaction) + as.notifier = notifier + instrumenter.start("sql.active_record", {}) Appsignal::Transaction.complete_current! instrumenter.finish("sql.active_record", {}) @@ -43,5 +111,18 @@ expect(transaction).to_not include_events expect(transaction).to be_completed end + + it "in collector mode", :collector_mode do + transaction = http_request_transaction + set_current_transaction(transaction) + as.notifier = notifier + + instrumenter.start("sql.active_record", {}) + Appsignal::Transaction.complete_current! + instrumenter.finish("sql.active_record", {}) + + 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 7fbf05725..0876be643 100644 --- a/spec/lib/appsignal/hooks/active_support_notifications_spec.rb +++ b/spec/lib/appsignal/hooks/active_support_notifications_spec.rb @@ -23,98 +23,24 @@ it { is_expected.to be_truthy } end - describe "in agent mode" do - let(:transaction) { http_request_transaction } - before do - start_agent - set_current_transaction(transaction) - as.notifier = notifier - end - around { |example| keep_transactions { example.run } } + it_behaves_like "activesupport instrument override" - it_behaves_like "activesupport instrument override" + if defined?(::ActiveSupport::Notifications::Fanout::Handle) + require_relative "active_support_notifications/start_finish_shared_examples" - if defined?(::ActiveSupport::Notifications::Fanout::Handle) - require_relative "active_support_notifications/start_finish_shared_examples" - - it_behaves_like "activesupport start finish override" - end - - if ::ActiveSupport::Notifications::Instrumenter.method_defined?(:start) - require_relative "active_support_notifications/start_finish_shared_examples" - - it_behaves_like "activesupport start finish override" - end - - if ::ActiveSupport::Notifications::Instrumenter.method_defined?(:finish_with_state) - require_relative "active_support_notifications/finish_with_state_shared_examples" - - it_behaves_like "activesupport finish_with_state override" - end + it_behaves_like "activesupport start finish override" end - describe "in collector mode" do - require "opentelemetry/sdk" - require_relative "active_support_notifications/instrument_collector_shared_examples" - - 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 - start_agent(:options => { :collector_endpoint => "http://127.0.0.1:9090" }) - # Replace the tracer provider booted by Appsignal::OpenTelemetry.configure - # with one whose processor pushes into our in-memory exporter, so we - # can inspect spans inside the test instead of trying to flush them - # out over OTLP/HTTP. Mirrors transaction_integration_spec.rb. - ::OpenTelemetry.tracer_provider = tracer_provider - @transaction = Appsignal::Transaction.create(Appsignal::Transaction::HTTP_REQUEST) - as.notifier = notifier - end - - # complete_current! clears both the thread-local and the attached OTel - # context. spec_helper's clear_current_transaction! only handles the - # former, so a leaked OTel context would pollute the next test's - # current_span reading. - after { Appsignal::Transaction.complete_current! } + if ::ActiveSupport::Notifications::Instrumenter.method_defined?(:start) + require_relative "active_support_notifications/start_finish_shared_examples" - # Bind to the specific Transaction created in `before` (not to - # Appsignal::Transaction.current, which becomes NilTransaction after - # `complete_current!` and has no backend). - let(:transaction) { @transaction } - - 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 - - it_behaves_like "activesupport instrument override in collector mode" - - if defined?(::ActiveSupport::Notifications::Fanout::Handle) - require_relative "active_support_notifications/start_finish_collector_shared_examples" - - it_behaves_like "activesupport start finish override in collector mode" - end - - if ::ActiveSupport::Notifications::Instrumenter.method_defined?(:start) - require_relative "active_support_notifications/start_finish_collector_shared_examples" - - it_behaves_like "activesupport start finish override in collector mode" - end + it_behaves_like "activesupport start finish override" + end - if ::ActiveSupport::Notifications::Instrumenter.method_defined?(:finish_with_state) - require_relative "active_support_notifications/finish_with_state_collector_shared_examples" + if ::ActiveSupport::Notifications::Instrumenter.method_defined?(:finish_with_state) + require_relative "active_support_notifications/finish_with_state_shared_examples" - it_behaves_like "activesupport finish_with_state override in collector mode" - end + it_behaves_like "activesupport finish_with_state override" end else describe "#dependencies_present?" do diff --git a/spec/lib/appsignal/hooks/dry_monitor_spec.rb b/spec/lib/appsignal/hooks/dry_monitor_spec.rb index e5489e81b..91b945758 100644 --- a/spec/lib/appsignal/hooks/dry_monitor_spec.rb +++ b/spec/lib/appsignal/hooks/dry_monitor_spec.rb @@ -33,133 +33,85 @@ describe "Dry Monitor Integration" do let(:notifications) { Dry::Monitor::Notifications.new(:test) } - let(:transaction) { http_request_transaction } - context "in agent mode" do - before do - start_agent - set_current_transaction(transaction) + describe "a SQL event" do + let(:event_id) { :sql } + let(:payload) do + { + :name => "postgres", + :query => "SELECT * FROM users" + } end - context "when is a dry-sql event" do - let(:event_id) { :sql } - let(:payload) do - { - :name => "postgres", - :query => "SELECT * FROM users" - } - end - - it "creates an sql event" do - notifications.instrument(event_id, payload) - expect(transaction).to include_event( - "body" => "SELECT * FROM users", - "body_format" => Appsignal::EventFormatter::SQL_BODY_FORMAT, - "count" => 1, - "name" => "query.postgres", - "title" => "query.postgres" - ) - end + it "in agent mode", :agent_mode do + transaction = http_request_transaction + set_current_transaction(transaction) + + notifications.instrument(event_id, payload) + + expect(transaction).to include_event( + "body" => "SELECT * FROM users", + "body_format" => Appsignal::EventFormatter::SQL_BODY_FORMAT, + "count" => 1, + "name" => "query.postgres", + "title" => "query.postgres" + ) end - context "when is an unregistered formatter event" do - let(:event_id) { :foo } - let(:payload) do - { - :name => "foo" - } - end - - it "creates a generic event" do - notifications.instrument(event_id, payload) - expect(transaction).to include_event( - "body" => "", - "body_format" => Appsignal::EventFormatter::DEFAULT, - "count" => 1, - "name" => "foo", - "title" => "" - ) - end + it "in collector mode", :collector_mode do + transaction = http_request_transaction + set_current_transaction(transaction) + + notifications.instrument(event_id, payload) + Appsignal::Transaction.complete_current! + + expect(event_spans.size).to eq(1) + span = event_spans.first + expect(span.name).to eq("query.postgres") + expect(span.parent_span_id).to eq(root_span.span_id) + 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.title"]).to eq("query.postgres") + expect(attrs).not_to have_key("appsignal.body") end end - context "in collector mode" do - require "opentelemetry/sdk" + describe "an unregistered formatter event" do + let(:event_id) { :foo } + let(:payload) { { :name => "foo" } } - 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 - start_agent(:options => { :collector_endpoint => "http://127.0.0.1:9090" }) - # Replace the tracer provider booted by Appsignal::OpenTelemetry.configure - # with one whose processor pushes into our in-memory exporter, so we can - # inspect spans inside the test instead of trying to flush them out over - # OTLP/HTTP. - ::OpenTelemetry.tracer_provider = tracer_provider + it "in agent mode", :agent_mode do + transaction = http_request_transaction set_current_transaction(transaction) - end - after { Appsignal::Transaction.complete_current! } - def root_span - span_exporter.finished_spans.find { |s| [:server, :consumer].include?(s.kind) } - end + notifications.instrument(event_id, payload) - def event_spans - span_exporter.finished_spans.reject { |s| [:server, :consumer].include?(s.kind) } + expect(transaction).to include_event( + "body" => "", + "body_format" => Appsignal::EventFormatter::DEFAULT, + "count" => 1, + "name" => "foo", + "title" => "" + ) end - context "when is a dry-sql event" do - let(:event_id) { :sql } - let(:payload) do - { - :name => "postgres", - :query => "SELECT * FROM users" - } - end - - it "emits a child span with SQL semantic attributes" do - notifications.instrument(event_id, payload) - Appsignal::Transaction.complete_current! - - expect(event_spans.size).to eq(1) - span = event_spans.first - expect(span.name).to eq("query.postgres") - expect(span.parent_span_id).to eq(root_span.span_id) - 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.title"]).to eq("query.postgres") - expect(attrs).not_to have_key("appsignal.body") - end - end + it "in collector mode", :collector_mode do + transaction = http_request_transaction + set_current_transaction(transaction) - context "when is an unregistered formatter event" do - let(:event_id) { :foo } - let(:payload) do - { - :name => "foo" - } - end - - it "emits a child span with the event id as the name and no body/title attrs" do - notifications.instrument(event_id, payload) - Appsignal::Transaction.complete_current! - - expect(event_spans.size).to eq(1) - span = event_spans.first - expect(span.name).to eq("foo") - expect(span.parent_span_id).to eq(root_span.span_id) - attrs = span.attributes - expect(attrs).not_to have_key("appsignal.title") - 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 + notifications.instrument(event_id, payload) + Appsignal::Transaction.complete_current! + + expect(event_spans.size).to eq(1) + span = event_spans.first + expect(span.name).to eq("foo") + expect(span.parent_span_id).to eq(root_span.span_id) + attrs = span.attributes + expect(attrs).not_to have_key("appsignal.title") + 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 diff --git a/spec/lib/appsignal/integrations/data_mapper_spec.rb b/spec/lib/appsignal/integrations/data_mapper_spec.rb index ccf1fb3c9..b0a3c28e5 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", @@ -21,140 +20,93 @@ def log_message connection_class.new.log(message) end - context "in agent mode" do + describe "a SQL-like scheme" do + let(:connection_class) { DataObjects::Sqlite3::Connection } before do - start_agent - set_current_transaction(transaction) + stub_const("DataObjects::Sqlite3::Connection", Class.new do + include DataMapperLog + include Appsignal::Hooks::DataMapperLogListener + end) end - around { |example| keep_transactions { example.run } } - - context "when the scheme is SQL-like" do - let(:connection_class) { DataObjects::Sqlite3::Connection } - before do - stub_const("DataObjects::Sqlite3::Connection", Class.new do - include DataMapperLog - include Appsignal::Hooks::DataMapperLogListener - end) - end - it "records the log entry in an event" do - log_message + it "in agent mode", :agent_mode do + transaction = http_request_transaction + set_current_transaction(transaction) - expect(transaction).to include_event( - "name" => "query.data_mapper", - "title" => "DataMapper Query", - "body" => "SELECT * from users", - "body_format" => Appsignal::EventFormatter::SQL_BODY_FORMAT, - "duration" => 100.0 - ) - end - end + keep_transactions { log_message } - context "when the scheme is not SQL-like" do - let(:connection_class) { DataObjects::MongoDB::Connection } - before do - stub_const("DataObjects::MongoDB::Connection", Class.new do - include DataMapperLog - include Appsignal::Hooks::DataMapperLogListener - end) - end + expect(transaction).to include_event( + "name" => "query.data_mapper", + "title" => "DataMapper Query", + "body" => "SELECT * from users", + "body_format" => Appsignal::EventFormatter::SQL_BODY_FORMAT, + "duration" => 100.0 + ) + end - it "records the log entry in an event without body" do - log_message + it "in collector mode", :collector_mode do + transaction = http_request_transaction + set_current_transaction(transaction) - expect(transaction).to include_event( - "name" => "query.data_mapper", - "title" => "DataMapper Query", - "body" => "", - "body_format" => Appsignal::EventFormatter::DEFAULT, - "duration" => 100.0 - ) - end + log_message + Appsignal::Transaction.complete_current! + + expect(event_spans.size).to eq(1) + span = event_spans.first + expect(span.name).to eq("query.data_mapper") + 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.title"]).to eq("DataMapper Query") + 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 "in collector mode" do - require "opentelemetry/sdk" - - 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 + describe "a non-SQL scheme" do + let(:connection_class) { DataObjects::MongoDB::Connection } before do - start_agent(:options => { :collector_endpoint => "http://127.0.0.1:9090" }) - # Replace the tracer provider booted by Appsignal::OpenTelemetry.configure - # with one whose processor pushes into our in-memory exporter, so we can - # inspect spans inside the test instead of trying to flush them out over - # OTLP/HTTP. - ::OpenTelemetry.tracer_provider = tracer_provider - set_current_transaction(transaction) - end - after { Appsignal::Transaction.complete_current! } - - def root_span - span_exporter.finished_spans.find { |s| [:server, :consumer].include?(s.kind) } + stub_const("DataObjects::MongoDB::Connection", Class.new do + include DataMapperLog + include Appsignal::Hooks::DataMapperLogListener + end) end - def event_spans - span_exporter.finished_spans.reject { |s| [:server, :consumer].include?(s.kind) } - end + it "in agent mode", :agent_mode do + transaction = http_request_transaction + set_current_transaction(transaction) - context "when the scheme is SQL-like" do - let(:connection_class) { DataObjects::Sqlite3::Connection } - before do - stub_const("DataObjects::Sqlite3::Connection", Class.new do - include DataMapperLog - include Appsignal::Hooks::DataMapperLogListener - end) - end + keep_transactions { log_message } - it "emits a child span with SQL semantic attributes and the recorded duration" do - log_message - Appsignal::Transaction.complete_current! - - expect(event_spans.size).to eq(1) - span = event_spans.first - expect(span.name).to eq("query.data_mapper") - 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.title"]).to eq("DataMapper Query") - 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 + expect(transaction).to include_event( + "name" => "query.data_mapper", + "title" => "DataMapper Query", + "body" => "", + "body_format" => Appsignal::EventFormatter::DEFAULT, + "duration" => 100.0 + ) end - context "when the scheme is not SQL-like" do - let(:connection_class) { DataObjects::MongoDB::Connection } - before do - stub_const("DataObjects::MongoDB::Connection", Class.new do - include DataMapperLog - include Appsignal::Hooks::DataMapperLogListener - end) - end + it "in collector mode", :collector_mode do + transaction = http_request_transaction + set_current_transaction(transaction) - it "emits a child span with no body and the recorded duration" do - log_message - Appsignal::Transaction.complete_current! - - expect(event_spans.size).to eq(1) - span = event_spans.first - expect(span.name).to eq("query.data_mapper") - expect(span.parent_span_id).to eq(root_span.span_id) - attrs = span.attributes - expect(attrs["appsignal.title"]).to eq("DataMapper Query") - 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 + log_message + Appsignal::Transaction.complete_current! + + expect(event_spans.size).to eq(1) + span = event_spans.first + expect(span.name).to eq("query.data_mapper") + expect(span.parent_span_id).to eq(root_span.span_id) + attrs = span.attributes + expect(attrs["appsignal.title"]).to eq("DataMapper Query") + 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 diff --git a/spec/lib/appsignal/transaction_integration_spec.rb b/spec/lib/appsignal/transaction_integration_spec.rb deleted file mode 100644 index 8b24babab..000000000 --- a/spec/lib/appsignal/transaction_integration_spec.rb +++ /dev/null @@ -1,358 +0,0 @@ -# frozen_string_literal: true - -# Unit-integration tests for `Appsignal::Transaction`. These exercise the -# Transaction object in realistic flows (creation -> current -> complete -> -# clear thread-local) in both agent mode and collector mode, side by side. -# -# The shared examples cover the mode-agnostic structural behavior: the new -# Transaction lives in the thread-local, it carries the namespace and id we -# passed, completion toggles the completed? flag, and `complete_current!` -# clears the thread-local. Each mode then adds its own telemetry-specific -# assertions on top — `to_h` matchers in agent mode, OpenTelemetry span -# exporter assertions in collector mode. -# -# See [[unit-integration-test-rhythm]]. - -RSpec.shared_examples "transaction events (mode-agnostic)" do - it "Transaction#instrument returns the block's value" do - transaction = Appsignal::Transaction.create(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 - - it "Transaction#instrument re-raises a block exception" do - transaction = Appsignal::Transaction.create(Appsignal::Transaction::HTTP_REQUEST) - - expect do - transaction.instrument("x.y", nil, nil, Appsignal::EventFormatter::DEFAULT) { raise "boom" } - end.to raise_error("boom") - end -end - -RSpec.shared_examples "transaction lifecycle (mode-agnostic)" do - it "creates a Transaction with the given namespace and a generated id" do - transaction = Appsignal::Transaction.create(Appsignal::Transaction::HTTP_REQUEST) - - expect(transaction).to be_a(Appsignal::Transaction) - expect(transaction.transaction_id).to be_a(String) - expect(transaction.transaction_id).not_to be_empty - expect(transaction.namespace).to eq(Appsignal::Transaction::HTTP_REQUEST) - end - - it "puts the created transaction in Appsignal::Transaction.current" do - transaction = Appsignal::Transaction.create(Appsignal::Transaction::HTTP_REQUEST) - - expect(Appsignal::Transaction.current).to eq(transaction) - expect(Appsignal::Transaction.current?).to be(true) - end - - it "marks the transaction completed after #complete" do - transaction = Appsignal::Transaction.create(Appsignal::Transaction::HTTP_REQUEST) - transaction.complete - - expect(transaction.completed?).to be(true) - end - - it "clears the thread-local on complete_current!" do - Appsignal::Transaction.create(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 - -describe "Appsignal::Transaction lifecycle in agent mode" do - before { start_agent } - - include_examples "transaction lifecycle (mode-agnostic)" - include_examples "transaction events (mode-agnostic)" - - it "serializes id, namespace and completed? via to_h after completion" do - transaction = Appsignal::Transaction.create(Appsignal::Transaction::HTTP_REQUEST) - keep_transactions { Appsignal::Transaction.complete_current! } - - expect(transaction).to have_id(transaction.transaction_id) - expect(transaction).to have_namespace(Appsignal::Transaction::HTTP_REQUEST) - expect(transaction).to be_completed - end - - describe "events recorded into to_h" do - it "records an instrumented event with name, title, body and SQL body_format" do - transaction = Appsignal::Transaction.create(Appsignal::Transaction::HTTP_REQUEST) - keep_transactions do - transaction.instrument("sql.active_record", "Query", "SELECT 1", - Appsignal::EventFormatter::SQL_BODY_FORMAT) { nil } - Appsignal::Transaction.complete_current! - end - - expect(transaction).to include_event( - "name" => "sql.active_record", - "title" => "Query", - "body" => "SELECT 1", - "body_format" => Appsignal::EventFormatter::SQL_BODY_FORMAT - ) - end - - it "records an instrumented event with the default body_format" do - transaction = Appsignal::Transaction.create(Appsignal::Transaction::HTTP_REQUEST) - keep_transactions do - transaction.instrument("custom.event", "Title", "Body", - Appsignal::EventFormatter::DEFAULT) { nil } - Appsignal::Transaction.complete_current! - end - - expect(transaction).to include_event( - "name" => "custom.event", - "title" => "Title", - "body" => "Body", - "body_format" => Appsignal::EventFormatter::DEFAULT - ) - end - - it "records a record_event call with the given duration" do - transaction = Appsignal::Transaction.create(Appsignal::Transaction::HTTP_REQUEST) - keep_transactions do - transaction.record_event("custom.event", "T", "B", 1_000_000_000, - Appsignal::EventFormatter::DEFAULT) - Appsignal::Transaction.complete_current! - end - - expect(transaction).to include_event( - "name" => "custom.event", - "title" => "T", - "body" => "B" - ) - end - - it "records both events for nested instrument calls" do - transaction = Appsignal::Transaction.create(Appsignal::Transaction::HTTP_REQUEST) - keep_transactions do - transaction.instrument("outer.event", "Outer", "outer body", - Appsignal::EventFormatter::DEFAULT) do - transaction.instrument("inner.event", "Inner", "inner body", - Appsignal::EventFormatter::DEFAULT) { nil } - end - Appsignal::Transaction.complete_current! - end - - 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 - end -end - -describe "Appsignal::Transaction lifecycle in collector mode" do - require "opentelemetry/sdk" - - 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 - start_agent(:options => { :collector_endpoint => "http://127.0.0.1:9090" }) - # Replace the tracer provider booted by Appsignal::OpenTelemetry.configure - # with one whose processor pushes into our in-memory exporter, so we can - # inspect spans inside the test instead of trying to flush them out over - # OTLP/HTTP. - ::OpenTelemetry.tracer_provider = tracer_provider - end - - # Any test that creates a transaction but doesn't complete it leaves an - # OpenTelemetry context attached, which would pollute the next test's - # `Trace.current_span` reading. spec_helper's `clear_current_transaction!` - # clears the thread-local but not the OTel context — `complete_current!` - # does both (it calls `complete` on the backend, which detaches). - after { Appsignal::Transaction.complete_current! } - - include_examples "transaction lifecycle (mode-agnostic)" - include_examples "transaction events (mode-agnostic)" - - # Helper: the root span (kind == :server / :consumer) vs the event spans - # (kind == :internal by default for `tracer.start_span`). - 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 - - describe "OpenTelemetry root span shape" do - it "starts an OTel root span with SpanKind::SERVER for HTTP_REQUEST" do - Appsignal::Transaction.create(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" do - Appsignal::Transaction.create(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" do - Appsignal::Transaction.create(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" do - Appsignal::Transaction.create("my_custom_namespace") - Appsignal::Transaction.complete_current! - - expect(span_exporter.finished_spans.first.kind).to eq(:server) - expect(span_exporter.finished_spans.first.name) - .to eq("appsignal.transaction my_custom_namespace") - end - end - - describe "OpenTelemetry current context" do - it "makes the root span the OpenTelemetry current span between create and complete" do - expect(::OpenTelemetry::Trace.current_span).to eq(::OpenTelemetry::Trace::Span::INVALID) - - Appsignal::Transaction.create(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 span emission timing" do - it "emits no span until complete is called" do - Appsignal::Transaction.create(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 - - describe "events as OpenTelemetry child spans" do - it "creates a child span on instrument with the event name as the span name" do - transaction = Appsignal::Transaction.create(Appsignal::Transaction::HTTP_REQUEST) - transaction.instrument("sql.active_record", "Query", "SELECT 1", - Appsignal::EventFormatter::SQL_BODY_FORMAT) { nil } - Appsignal::Transaction.complete_current! - - expect(event_spans.size).to eq(1) - span = event_spans.first - expect(span.name).to eq("sql.active_record") - expect(span.parent_span_id).to eq(root_span.span_id) - end - - it "writes db.query.text + db.system.name=other_sql for SQL_BODY_FORMAT events" do - transaction = Appsignal::Transaction.create(Appsignal::Transaction::HTTP_REQUEST) - transaction.instrument("sql.active_record", "Query", "SELECT 1", - Appsignal::EventFormatter::SQL_BODY_FORMAT) { nil } - Appsignal::Transaction.complete_current! - - attrs = event_spans.first.attributes - expect(attrs["db.query.text"]).to eq("SELECT 1") - expect(attrs["db.system.name"]).to eq("other_sql") - expect(attrs["appsignal.title"]).to eq("Query") - expect(attrs).not_to have_key("appsignal.body") - end - - it "writes appsignal.body for default-format events (no db.* attrs)" do - transaction = Appsignal::Transaction.create(Appsignal::Transaction::HTTP_REQUEST) - transaction.instrument("custom.event", "Title", "Body", - Appsignal::EventFormatter::DEFAULT) { nil } - Appsignal::Transaction.complete_current! - - attrs = event_spans.first.attributes - expect(attrs["appsignal.body"]).to eq("Body") - expect(attrs["appsignal.title"]).to eq("Title") - expect(attrs).not_to have_key("db.query.text") - expect(attrs).not_to have_key("db.system.name") - end - - it "omits appsignal.title when title is empty" do - transaction = Appsignal::Transaction.create(Appsignal::Transaction::HTTP_REQUEST) - transaction.instrument("custom.event", nil, "Body", - Appsignal::EventFormatter::DEFAULT) { nil } - Appsignal::Transaction.complete_current! - - expect(event_spans.first.attributes).not_to have_key("appsignal.title") - end - - it "omits the body attribute when body is empty" do - transaction = Appsignal::Transaction.create(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 - - it "makes the event span the OTel current span during the block" do - transaction = Appsignal::Transaction.create(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) - - Appsignal::Transaction.complete_current! - end - - it "nests events: the inner span's parent is the outer event span" do - transaction = Appsignal::Transaction.create(Appsignal::Transaction::HTTP_REQUEST) - transaction.instrument("outer.event", "Outer", "outer body", - Appsignal::EventFormatter::DEFAULT) do - transaction.instrument("inner.event", "Inner", "inner body", - Appsignal::EventFormatter::DEFAULT) { nil } - end - Appsignal::Transaction.complete_current! - - outer = event_spans.find { |s| s.name == "outer.event" } - inner = event_spans.find { |s| s.name == "inner.event" } - - expect(inner.parent_span_id).to eq(outer.span_id) - expect(outer.parent_span_id).to eq(root_span.span_id) - end - - it "record_event creates a child span with backdated start_timestamp" do - transaction = Appsignal::Transaction.create(Appsignal::Transaction::HTTP_REQUEST) - duration_ns = 1_000_000_000 # 1 second - transaction.record_event("custom.event", "T", "B", duration_ns, - Appsignal::EventFormatter::DEFAULT) - Appsignal::Transaction.complete_current! - - span = event_spans.first - expect(span.name).to eq("custom.event") - expect(span.parent_span_id).to eq(root_span.span_id) - # OTel timestamps are nanoseconds; allow a small slack for clock jitter. - observed = span.end_timestamp - span.start_timestamp - expect(observed).to be_within(50_000_000).of(duration_ns) - end - end -end diff --git a/spec/lib/appsignal/transaction_spec.rb b/spec/lib/appsignal/transaction_spec.rb index 08f1315ea..adfe2bd05 100644 --- a/spec/lib/appsignal/transaction_spec.rb +++ b/spec/lib/appsignal/transaction_spec.rb @@ -3,8 +3,12 @@ let(:time) { Time.at(fixed_time) } let(:root_path) { nil } - before do - start_agent(:options => options, :root_path => root_path) + before do |example| + # Skip start_agent for :collector_mode examples -- the shared context boots + # Appsignal with the collector endpoint instead, and `Appsignal.start` is a + # no-op once started so the order would otherwise leave the test in agent + # mode. + start_agent(:options => options, :root_path => root_path) unless example.metadata[:collector_mode] Timecop.freeze(time) end after { Timecop.return } @@ -99,6 +103,64 @@ 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 + 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 + 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 + 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 + create_transaction("my_custom_namespace") + Appsignal::Transaction.complete_current! + + expect(span_exporter.finished_spans.first.kind).to eq(:server) + expect(span_exporter.finished_spans.first.name).to eq("appsignal.transaction my_custom_namespace") + end + end + + describe "OpenTelemetry current context" do + it "in collector mode", :collector_mode do + 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 end describe ".current" do @@ -183,6 +245,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 @@ -537,6 +609,25 @@ ) 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 + 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 @@ -2664,6 +2755,37 @@ def to_s ) end end + + describe "recording an event with the given duration" do + it "in agent mode", :agent_mode do + transaction = create_transaction(Appsignal::Transaction::HTTP_REQUEST) + keep_transactions do + transaction.record_event("custom.event", "T", "B", 1_000_000_000, + Appsignal::EventFormatter::DEFAULT) + Appsignal::Transaction.complete_current! + end + + expect(transaction).to include_event( + "name" => "custom.event", + "title" => "T", + "body" => "B" + ) + end + + it "in collector mode", :collector_mode do + transaction = create_transaction(Appsignal::Transaction::HTTP_REQUEST) + duration_ns = 1_000_000_000 + transaction.record_event("custom.event", "T", "B", duration_ns, + Appsignal::EventFormatter::DEFAULT) + Appsignal::Transaction.complete_current! + + span = event_spans.first + expect(span.name).to eq("custom.event") + expect(span.parent_span_id).to eq(root_span.span_id) + observed = span.end_timestamp - span.start_timestamp + expect(observed).to be_within(50_000_000).of(duration_ns) + end + end end describe "#instrument" do @@ -2671,6 +2793,170 @@ 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) { raise "boom" } + end.to raise_error("boom") + end + end + + describe "instrumenting a SQL event" do + it "in agent mode", :agent_mode do + transaction = create_transaction(Appsignal::Transaction::HTTP_REQUEST) + keep_transactions do + transaction.instrument("sql.active_record", "Query", "SELECT 1", + Appsignal::EventFormatter::SQL_BODY_FORMAT) { nil } + Appsignal::Transaction.complete_current! + end + + 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 + transaction = create_transaction(Appsignal::Transaction::HTTP_REQUEST) + transaction.instrument("sql.active_record", "Query", "SELECT 1", + Appsignal::EventFormatter::SQL_BODY_FORMAT) { nil } + Appsignal::Transaction.complete_current! + + span = event_spans.first + expect(span.name).to eq("sql.active_record") + 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.title" => "Query" + ) + expect(span.attributes).not_to have_key("appsignal.body") + end + end + + describe "instrumenting a default-format event" do + it "in agent mode", :agent_mode do + transaction = create_transaction(Appsignal::Transaction::HTTP_REQUEST) + keep_transactions do + transaction.instrument("custom.event", "Title", "Body", + Appsignal::EventFormatter::DEFAULT) { nil } + Appsignal::Transaction.complete_current! + end + + 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 + transaction = create_transaction(Appsignal::Transaction::HTTP_REQUEST) + transaction.instrument("custom.event", "Title", "Body", + Appsignal::EventFormatter::DEFAULT) { nil } + Appsignal::Transaction.complete_current! + + span = event_spans.first + expect(span.attributes).to include( + "appsignal.body" => "Body", + "appsignal.title" => "Title" + ) + 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 + it "in agent mode", :agent_mode do + transaction = create_transaction(Appsignal::Transaction::HTTP_REQUEST) + keep_transactions do + transaction.instrument("outer.event", "Outer", "outer body", + Appsignal::EventFormatter::DEFAULT) do + transaction.instrument("inner.event", "Inner", "inner body", + Appsignal::EventFormatter::DEFAULT) { nil } + end + Appsignal::Transaction.complete_current! + end + + 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 + transaction = create_transaction(Appsignal::Transaction::HTTP_REQUEST) + transaction.instrument("outer.event", "Outer", "outer body", + Appsignal::EventFormatter::DEFAULT) do + transaction.instrument("inner.event", "Inner", "inner body", + Appsignal::EventFormatter::DEFAULT) { nil } + end + Appsignal::Transaction.complete_current! + + outer = event_spans.find { |s| s.name == "outer.event" } + inner = event_spans.find { |s| s.name == "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 "omits the appsignal.title attribute on the span", :collector_mode do + transaction = create_transaction(Appsignal::Transaction::HTTP_REQUEST) + transaction.instrument("custom.event", nil, "Body", + Appsignal::EventFormatter::DEFAULT) { nil } + Appsignal::Transaction.complete_current! + + expect(event_spans.first.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 + 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 + 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 diff --git a/spec/lib/appsignal_spec.rb b/spec/lib/appsignal_spec.rb index 27f9b2b27..2b1103c87 100644 --- a/spec/lib/appsignal_spec.rb +++ b/spec/lib/appsignal_spec.rb @@ -1568,151 +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 - - if DependencyHelper.ruby_3_1_or_newer? - context "when collector mode is active" do - before do - Appsignal.clear! - start_agent(:options => { :collector_endpoint => "http://127.0.0.1:9090" }) - end - - describe ".set_gauge" do - it "routes through the OpenTelemetry backend, not the extension" do - allow(Appsignal::Metrics::OpenTelemetryBackend).to receive(:set_gauge) - expect(Appsignal::Extension).not_to receive(:set_gauge) - - Appsignal.set_gauge("key", 0.1, tags) - - expect(Appsignal::Metrics::OpenTelemetryBackend).to have_received(:set_gauge) - .with("key", 0.1, tags) - end - end - - describe ".increment_counter" do - it "routes through the OpenTelemetry backend, not the extension" do - allow(Appsignal::Metrics::OpenTelemetryBackend).to receive(:increment_counter) - expect(Appsignal::Extension).not_to receive(:increment_counter) - - Appsignal.increment_counter("key", 5, tags) - - expect(Appsignal::Metrics::OpenTelemetryBackend).to have_received(:increment_counter) - .with("key", 5, tags) - end - end - - describe ".add_distribution_value" do - it "routes through the OpenTelemetry backend, not the extension" do - allow(Appsignal::Metrics::OpenTelemetryBackend).to receive(:add_distribution_value) - expect(Appsignal::Extension).not_to receive(:add_distribution_value) - - Appsignal.add_distribution_value("key", 0.1, tags) - - expect(Appsignal::Metrics::OpenTelemetryBackend) - .to have_received(:add_distribution_value).with("key", 0.1, tags) - end - end - end - end - end - describe ".internal_logger" do subject { Appsignal.internal_logger } @@ -2146,63 +2001,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 context "in collector mode" do @@ -2236,111 +2034,336 @@ def event_spans span_exporter.finished_spans.reject { |s| [:server, :consumer].include?(s.kind) } end - describe ".instrument" do - before { set_current_transaction(transaction) } + end + + describe "custom metrics" do + let(:tags) { { :foo => "bar" } } - it "records an OTel child span around the given block" do - return_value = Appsignal.instrument "name", "title", "body" do - :block_result + describe ".set_gauge" do + describe "with a string key and float value" do + it "in agent mode", :agent_mode do + expect(Appsignal::Extension).to receive(:set_gauge) + .with("key", 0.1, Appsignal::Extension.data_map_new) + Appsignal.set_gauge("key", 0.1) end - expect(return_value).to eq :block_result + end + describe "with tags" do + it "in agent mode", :agent_mode 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 "in collector mode", :collector_mode do + allow(Appsignal::Metrics::OpenTelemetryBackend).to receive(:set_gauge) + expect(Appsignal::Extension).not_to receive(:set_gauge) + + Appsignal.set_gauge("key", 0.1, tags) + + 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 + 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 + 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 + 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 + it "in agent mode", :agent_mode do + expect(Appsignal::Extension).to receive(:increment_counter) + .with("key", 1, Appsignal::Utils::Data.generate(tags)) + Appsignal.increment_counter("key", 1, tags) + end + + it "in collector mode", :collector_mode do + allow(Appsignal::Metrics::OpenTelemetryBackend).to receive(:increment_counter) + expect(Appsignal::Extension).not_to receive(:increment_counter) + + Appsignal.increment_counter("key", 5, tags) + + 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 + 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 + 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 + 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 + 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 + it "in agent mode", :agent_mode 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 "in collector mode", :collector_mode do + allow(Appsignal::Metrics::OpenTelemetryBackend).to receive(:add_distribution_value) + expect(Appsignal::Extension).not_to receive(:add_distribution_value) + + Appsignal.add_distribution_value("key", 0.1, tags) + + 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 + 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 + expect(Appsignal::Extension).to receive(:add_distribution_value) + .with("key", 10, Appsignal::Extension.data_map_new).and_raise(RangeError) + expect(Appsignal.internal_logger).to receive(:warn) + .with("The distribution value '10' for metric 'key' is too big") + + Appsignal.add_distribution_value("key", 10) + end + end + end + end + + describe ".instrument" do + describe "block return value" do + it_in_both_modes do + set_current_transaction(transaction) + + result = Appsignal.instrument("name", "title", "body") { "return value" } + + expect(result).to eq("return value") + end + end + + describe "recording an event around the block" do + it "in agent mode", :agent_mode do + set_current_transaction(transaction) + + Appsignal.instrument("name", "title", "body") { :do_nothing } + + expect(transaction).to include_event( + "name" => "name", + "title" => "title", + "body" => "body", + "body_format" => Appsignal::EventFormatter::DEFAULT + ) + end + + it "in collector mode", :collector_mode do + set_current_transaction(transaction) + + Appsignal.instrument("name", "title", "body") { :do_nothing } Appsignal::Transaction.complete_current! - expect(event_spans.size).to eq 1 + expect(event_spans.size).to eq(1) span = event_spans.first - expect(span.name).to eq "name" - expect(span.parent_span_id).to eq root_span.span_id - expect(span.attributes["appsignal.title"]).to eq "title" - expect(span.attributes["appsignal.body"]).to eq "body" + expect(span.name).to eq("name") + expect(span.parent_span_id).to eq(root_span.span_id) + expect(span.attributes["appsignal.title"]).to eq("title") + 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 - context "with an error raised in the passed block" do - it "still records the event span" do - expect do - Appsignal.instrument "name", "title", "body" do - raise ExampleException, "foo" - end - end.to raise_error(ExampleException, "foo") + describe "when an error is raised in the block" do + it "in agent mode", :agent_mode do + set_current_transaction(transaction) - Appsignal::Transaction.complete_current! + expect do + Appsignal.instrument("name", "title", "body") { raise ExampleException, "foo" } + end.to raise_error(ExampleException, "foo") - expect(event_spans.size).to eq 1 - span = event_spans.first - expect(span.name).to eq "name" - expect(span.attributes["appsignal.title"]).to eq "title" - expect(span.attributes["appsignal.body"]).to eq "body" - end + expect(transaction).to include_event( + "name" => "name", "title" => "title", "body" => "body" + ) end - context "with a symbol thrown in the passed block" do - it "still records the event span" do - expect do - Appsignal.instrument "name", "title", "body" do - throw :foo - end - end.to throw_symbol(:foo) + it "in collector mode", :collector_mode do + set_current_transaction(transaction) - Appsignal::Transaction.complete_current! + expect do + Appsignal.instrument("name", "title", "body") { raise ExampleException, "foo" } + end.to raise_error(ExampleException, "foo") + Appsignal::Transaction.complete_current! - expect(event_spans.size).to eq 1 - span = event_spans.first - expect(span.name).to eq "name" - expect(span.attributes["appsignal.title"]).to eq "title" - expect(span.attributes["appsignal.body"]).to eq "body" - end + expect(event_spans.size).to eq(1) + span = event_spans.first + expect(span.name).to eq("name") + expect(span.attributes["appsignal.title"]).to eq("title") + expect(span.attributes["appsignal.body"]).to eq("body") end end - describe ".instrument_sql" do - before { set_current_transaction(transaction) } + describe "when a symbol is thrown in the block" do + it "in agent mode", :agent_mode do + set_current_transaction(transaction) - it "creates an SQL OTel child span on the transaction" do - result = - Appsignal.instrument_sql "name", "title", "body" do - "return value" - end - expect(result).to eq "return value" + expect do + Appsignal.instrument("name", "title", "body") { throw :foo } + end.to throw_symbol(:foo) + expect(transaction).to include_event( + "name" => "name", "title" => "title", "body" => "body" + ) + end + + it "in collector mode", :collector_mode do + set_current_transaction(transaction) + + expect do + Appsignal.instrument("name", "title", "body") { throw :foo } + end.to throw_symbol(:foo) Appsignal::Transaction.complete_current! - expect(event_spans.size).to eq 1 + expect(event_spans.size).to eq(1) span = event_spans.first - expect(span.name).to eq "name" - expect(span.parent_span_id).to eq root_span.span_id - expect(span.attributes["appsignal.title"]).to eq "title" - 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") + expect(span.name).to eq("name") + expect(span.attributes["appsignal.title"]).to eq("title") + expect(span.attributes["appsignal.body"]).to eq("body") end end + end - describe ".ignore_instrumentation_events" do - context "with current transaction" do - before { set_current_transaction(transaction) } + describe ".instrument_sql" do + describe "recording a SQL event around the block" do + it "in agent mode", :agent_mode do + set_current_transaction(transaction) - it "does not emit OTel spans for ignored events but emits them for recorded events" do - Appsignal.instrument("register.this.event") { :do_nothing } - Appsignal.ignore_instrumentation_events do - Appsignal.instrument("dont.register.this.event") { :do_nothing } - end + result = Appsignal.instrument_sql("name", "title", "body") { "return value" } + + 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 - Appsignal::Transaction.complete_current! + it "in collector mode", :collector_mode do + set_current_transaction(transaction) - names = event_spans.map(&:name) - expect(names).to include("register.this.event") - expect(names).not_to include("dont.register.this.event") + result = Appsignal.instrument_sql("name", "title", "body") { "return value" } + Appsignal::Transaction.complete_current! + + expect(result).to eq("return value") + expect(event_spans.size).to eq(1) + span = event_spans.first + expect(span.name).to eq("name") + expect(span.parent_span_id).to eq(root_span.span_id) + expect(span.attributes["appsignal.title"]).to eq("title") + 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 + set_current_transaction(transaction) + expect(transaction).to receive(:pause!).and_call_original + expect(transaction).to receive(:resume!).and_call_original + + Appsignal.instrument("register.this.event") { :do_nothing } + Appsignal.ignore_instrumentation_events do + Appsignal.instrument("dont.register.this.event") { :do_nothing } end + + expect(transaction).to include_event("name" => "register.this.event") + expect(transaction).to_not include_event("name" => "dont.register.this.event") end - context "without current transaction" do - it "does not crash" do - expect do - Appsignal.ignore_instrumentation_events { :do_nothing } - end.not_to raise_error + it "in collector mode", :collector_mode do + 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 diff --git a/spec/support/shared_contexts/agent_mode.rb b/spec/support/shared_contexts/agent_mode.rb index 2f775d2d8..dc55a2f99 100644 --- a/spec/support/shared_contexts/agent_mode.rb +++ b/spec/support/shared_contexts/agent_mode.rb @@ -2,6 +2,12 @@ RSpec.shared_context "agent mode", :agent_mode do before { start_agent } + + # Make completed transactions readable via `to_h` so agent-mode tests 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| From 86808efbcc2375e69425d466c17383727e7c15a2 Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Mon, 1 Jun 2026 20:56:23 +0200 Subject: [PATCH 017/151] Cover Appsignal::Logger in collector mode Pair every agent-mode logger test with a collector-mode peer using the `:agent_mode`/`:collector_mode` metadata pattern. Agent-mode tests mock `Appsignal::Extension.log`; collector-mode tests mock `Appsignal::Logger::OpenTelemetryBackend.emit` -- the OTel backend receives the unmapped `::Logger` severity, the format integer, and attributes as a plain Hash (vs. the extension's mapped severity and wrapped `Appsignal::Utils::Data`). The shared `tagged logging` examples carry the paired tests, so both the standalone invocation and the `ActiveSupport::TaggedLogging` wrap gain collector-mode coverage at once. --- spec/lib/appsignal/logger_spec.rb | 1420 +++++++++++++++++++---------- 1 file changed, 962 insertions(+), 458 deletions(-) diff --git a/spec/lib/appsignal/logger_spec.rb b/spec/lib/appsignal/logger_spec.rb index 39d771f7e..81274eb92 100644 --- a/spec/lib/appsignal/logger_spec.rb +++ b/spec/lib/appsignal/logger_spec.rb @@ -1,132 +1,261 @@ 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 + it "in agent mode", :agent_mode 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") + end 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 + it "in collector mode", :collector_mode do + expect(Appsignal::Logger::OpenTelemetryBackend).to receive(:emit) + .with( + "group", + ::Logger::INFO, + Appsignal::Logger::AUTODETECT, + "[My tag] [My other tag] Some message\n", + {} + ) + + logger.tagged("My tag", "My other tag") do logger.info("Some message") end 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 nested tags from logger.tagged" do + it "in agent mode", :agent_mode 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") + end + end + end + + it "in collector mode", :collector_mode do + 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", + {} + ) + + logger.tagged("My tag", "My other tag") do + logger.tagged("Nested tag", "Nested other tag") do + logger.info("Some message") + end + end + end + end + + describe "with tags from Rails.application.config.log_tags" do + it "in agent mode", :agent_mode do + 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 + 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 + it "in agent mode", :agent_mode do + 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({}) + ) + end + + it "in collector mode", :collector_mode do + 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", + {} + ) + 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 + 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 + 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 + it "in agent mode", :agent_mode 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") + logger.tagged(["My tag", "My other tag"]) do + logger.info("Some message") + end + end + + it "in collector mode", :collector_mode do + expect(Appsignal::Logger::OpenTelemetryBackend).to receive(:emit) + .with( + "group", + ::Logger::INFO, + Appsignal::Logger::AUTODETECT, + "[My tag] [My other tag] Some message\n", + {} + ) + + logger.tagged(["My tag", "My other tag"]) do + logger.info("Some message") + end end end @@ -136,87 +265,177 @@ # 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 + it "in agent mode", :agent_mode 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").info("Some message") + end - logger.tagged("My tag", "My other tag").info("Some message") + it "in collector mode", :collector_mode do + expect(Appsignal::Logger::OpenTelemetryBackend).to receive(:emit) + .with( + "group", + ::Logger::INFO, + Appsignal::Logger::AUTODETECT, + "[My tag] [My other tag] Some message\n", + {} + ) + + logger.tagged("My tag", "My other tag").info("Some message") + 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 + 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 + 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 + it "in agent mode", :agent_mode 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({}) + ) + + 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 collector mode", :collector_mode do + 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", + {} + ) + + logger.tagged("My tag", "My other tag").tagged("My third tag").info("Some message") + 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 + it "in agent mode", :agent_mode 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({}) + ) - # 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") + # 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 + + it "in collector mode", :collector_mode do + 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", + {} + ) + + logger.tagged("My tag", "My other tag").tagged("My third tag") do |logger| + logger.info("Some message") + end 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 + it "in agent mode", :agent_mode 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({}) + ) - logger.tagged("My tag", "My other tag") do - logger.tagged("My third tag").info("Some message") + logger.tagged("My tag", "My other tag") do + logger.tagged("My third tag").info("Some message") + end + end + + it "in collector mode", :collector_mode do + 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", + {} + ) + + logger.tagged("My tag", "My other tag") do + logger.tagged("My third tag").info("Some message") + end end end end @@ -261,248 +480,410 @@ end 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") + describe "#add" do + describe "with a level and message" do + it "in agent mode", :agent_mode 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 collector mode", :collector_mode do + expect(Appsignal::Logger::OpenTelemetryBackend).to receive(:emit) + .with("group", ::Logger::INFO, Appsignal::Logger::AUTODETECT, "Log message", {}) + logger.add(::Logger::INFO, "Log message") + end + end + + describe "with a non-string message" do + it "in agent mode", :agent_mode 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, []) + end + + it "in collector mode", :collector_mode do + 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, "[]", {}) + logger.add(::Logger::INFO, 123) + logger.add(::Logger::INFO, {}) + logger.add(::Logger::INFO, []) + end 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, []) - end + describe "with a block" do + it "in agent mode", :agent_mode 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 "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" + it "in collector mode", :collector_mode do + expect(Appsignal::Logger::OpenTelemetryBackend).to receive(:emit) + .with("group", ::Logger::INFO, Appsignal::Logger::AUTODETECT, "Log message", {}) + logger.add(::Logger::INFO) { "Log message" } 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") + describe "with a level, message and group" do + it "in agent mode", :agent_mode 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") + end + + it "in collector mode", :collector_mode do + expect(Appsignal::Logger::OpenTelemetryBackend).to receive(:emit) + .with("other_group", ::Logger::INFO, Appsignal::Logger::AUTODETECT, "Log message", {}) + logger.add(::Logger::INFO, "Log message", "other_group") + 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 + it "in agent mode", :agent_mode do + expect(Appsignal::Extension).not_to receive(:log) + logger.add(::Logger::DEBUG, "Log message") + end + + it "in collector mode", :collector_mode do + expect(Appsignal::Logger::OpenTelemetryBackend).not_to receive(:emit) + logger.add(::Logger::DEBUG, "Log message") + 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 + it "in agent mode", :agent_mode do expect(Appsignal::Extension).to receive(:log) .with("group", 3, 0, "Log message", instance_of(Appsignal::Extension::Data)) logger.add(::Logger::INFO, "Log message") end + + it "in collector mode", :collector_mode do + expect(Appsignal::Logger::OpenTelemetryBackend).to receive(:emit) + .with("group", ::Logger::INFO, Appsignal::Logger::PLAINTEXT, "Log message", {}) + logger.add(::Logger::INFO, "Log message") + 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 + it "in agent mode", :agent_mode do expect(Appsignal::Extension).to receive(:log) .with("group", 3, 1, "Log message", instance_of(Appsignal::Extension::Data)) logger.add(::Logger::INFO, "Log message") end + + it "in collector mode", :collector_mode do + expect(Appsignal::Logger::OpenTelemetryBackend).to receive(:emit) + .with("group", ::Logger::INFO, Appsignal::Logger::LOGFMT, "Log message", {}) + logger.add(::Logger::INFO, "Log message") + 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 + it "in agent mode", :agent_mode do expect(Appsignal::Extension).to receive(:log) .with("group", 3, 2, "Log message", instance_of(Appsignal::Extension::Data)) logger.add(::Logger::INFO, "Log message") end + + it "in collector mode", :collector_mode do + expect(Appsignal::Logger::OpenTelemetryBackend).to receive(:emit) + .with("group", ::Logger::INFO, Appsignal::Logger::JSON, "Log message", {}) + logger.add(::Logger::INFO, "Log message") + 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 - - it "calls the formatter with the original message" do - expect(Appsignal::Extension).to receive(:log) - .with( - "group", + describe "logs with a level, message and group" do + it "in agent mode", :agent_mode do + 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" }) + logger.add(::Logger::INFO, "Log message", "other_group") + end + + it "in collector mode", :collector_mode do + expect(Appsignal::Logger::OpenTelemetryBackend).to receive(:emit).with( + "other_group", + ::Logger::INFO, + Appsignal::Logger::AUTODETECT, + "formatted: 'Log message'", + {} + ) + logger.add(::Logger::INFO, "Log message", "other_group") + 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 + it "in agent mode", :agent_mode do + 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 + logger.add(::Logger::INFO, { :a => "b" }) + end + + it "in collector mode", :collector_mode do + 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 + logger.add(::Logger::INFO, { :a => "b" }) + end + end + + describe "calls #to_s on the formatter output if it is not a string" do + it "in agent mode", :agent_mode 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) + end + + it "in collector mode", :collector_mode do + 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) + logger.add(::Logger::INFO, 123) + 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 + it "in agent mode", :agent_mode do + 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)) - # Expect to receive warn - expect(Appsignal::Extension).to receive(:log) - .with("group", 5, 3, "Log message", instance_of(Appsignal::Extension::Data)) + logger.silence(::Logger::WARN) do + logger.info("Log message") + logger.warn("Log message") + end + end - logger.silence(::Logger::WARN) do - logger.info("Log message") - logger.warn("Log message") + it "in collector mode", :collector_mode do + 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", {}) + + logger.silence(::Logger::WARN) do + logger.info("Log message") + logger.warn("Log message") + end 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)) - end + describe "silences the logger to error level by default" do + it "in agent mode", :agent_mode do + [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 - # 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)) + 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 - 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 + [::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 + + 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 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) + describe "broadcasts the message to the given logger" do + it "in agent mode", :agent_mode do + other_device = StringIO.new + other_logger = ::Logger.new(other_device) + logger.broadcast_to(other_logger) - logger.broadcast_to(other_logger) + expect(Appsignal::Extension).to receive(:log) + .with("group", 3, 3, "Log message", instance_of(Appsignal::Extension::Data)) - expect(Appsignal::Extension).to receive(:log) - .with("group", 3, 3, "Log message", instance_of(Appsignal::Extension::Data)) + logger.info("Log message") - logger.info("Log message") + expect(other_device.string).to include("INFO -- group: Log message") + end - expect(other_device.string).to include("INFO -- group: Log message") - end + it "in collector mode", :collector_mode do + other_device = StringIO.new + other_logger = ::Logger.new(other_device) + logger.broadcast_to(other_logger) - it "broadcasts the message to the given logger when it's below the log level" do - logger = Appsignal::Logger.new("group", :level => ::Logger::INFO) + expect(Appsignal::Logger::OpenTelemetryBackend).to receive(:emit) + .with("group", ::Logger::INFO, Appsignal::Logger::AUTODETECT, "Log message", {}) - other_device = StringIO.new - other_logger = ::Logger.new(other_device) + logger.info("Log message") - logger.broadcast_to(other_logger) + expect(other_device.string).to include("INFO -- group: Log message") + end + end - expect(Appsignal::Extension).not_to receive(:log) + describe "broadcasts the message to the given logger when it's below the log level" do + it "in agent mode", :agent_mode do + logger = Appsignal::Logger.new("group", :level => ::Logger::INFO) + other_device = StringIO.new + other_logger = ::Logger.new(other_device) + logger.broadcast_to(other_logger) - logger.debug("Log message") + expect(Appsignal::Extension).not_to receive(:log) - expect(other_device.string).to include("DEBUG -- group: Log message") - end + logger.debug("Log message") - it "does not broadcast the message to the given logger when silenced" do - other_device = StringIO.new - other_logger = ::Logger.new(other_device) + expect(other_device.string).to include("DEBUG -- group: Log message") + end - logger.broadcast_to(other_logger) + it "in collector mode", :collector_mode do + logger = Appsignal::Logger.new("group", :level => ::Logger::INFO) + other_device = StringIO.new + other_logger = ::Logger.new(other_device) + logger.broadcast_to(other_logger) - expect(Appsignal::Extension).not_to receive(:log) + expect(Appsignal::Logger::OpenTelemetryBackend).not_to receive(:emit) - logger.silence do - logger.info("Log message") - end + logger.debug("Log message") - expect(other_device.string).to eq("") + expect(other_device.string).to include("DEBUG -- group: Log message") + end end - context "with a formatter" do - it "sets the formatter on broadcasted loggers that support it" do + describe "does not broadcast the message to the given logger when silenced" do + it "in agent mode", :agent_mode do other_device = StringIO.new other_logger = ::Logger.new(other_device) + logger.broadcast_to(other_logger) + + expect(Appsignal::Extension).not_to receive(:log) + + logger.silence { logger.info("Log message") } + + expect(other_device.string).to eq("") + end + it "in collector mode", :collector_mode do + other_device = StringIO.new + other_logger = ::Logger.new(other_device) logger.broadcast_to(other_logger) - formatter = proc do |_level, _timestamp, _appname, message| - "custom: #{message}" - end + expect(Appsignal::Logger::OpenTelemetryBackend).not_to receive(:emit) - logger.formatter = formatter + logger.silence { logger.info("Log message") } - expect(logger.formatter).to eq(formatter) - expect(other_logger.formatter).to eq(formatter) + expect(other_device.string).to eq("") end + 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) + context "with a formatter" do + 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(logger_without_formatter) + 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 + end + + 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) - # Does not raise an error - logger.formatter = formatter - expect(logger.formatter).to eq(formatter) + formatter = proc { |_level, _timestamp, _appname, message| "custom: #{message}" } + logger.formatter = formatter + expect(logger.formatter).to eq(formatter) + end end end @@ -517,71 +898,125 @@ 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({}) - ) - - logger.tagged("My tag", "My other tag") do - logger.info("Some message") + describe "broadcasts a tagged message to the given logger" do + it "in agent mode", :agent_mode 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") + end + + expect(other_stream.string).to eq("[My tag] [My other tag] Some message\n") end - expect(other_stream.string) - .to eq("[My tag] [My other tag] Some message\n") + it "in collector mode", :collector_mode do + expect(Appsignal::Logger::OpenTelemetryBackend).to receive(:emit) + .with( + "group", + ::Logger::INFO, + Appsignal::Logger::AUTODETECT, + "[My tag] [My other tag] Some message\n", + {} + ) + + 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 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 + it "in agent mode", :agent_mode 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)) + + logger.send(method, "Log message", :attribute => "value") + end + + it "in collector mode", :collector_mode do + expect(Appsignal::Logger::OpenTelemetryBackend).to receive(:emit) + .with( + "group", + logger_level, + Appsignal::Logger::AUTODETECT, + "Log message", + { :attribute => "value" } + ) - logger.send(method, "Log message", :attribute => "value") + logger.send(method, "Log message", :attribute => "value") + 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 + it "in agent mode", :agent_mode 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)) - logger.send(method) do - "Log message" + logger.send(method) { "Log message" } + end + + it "in collector mode", :collector_mode do + expect(Appsignal::Logger::OpenTelemetryBackend).to receive(:emit) + .with("group", logger_level, Appsignal::Logger::AUTODETECT, "Log message", {}) + + logger.send(method) { "Log message" } 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 + it "in agent mode", :agent_mode do + expect(Appsignal::Extension).not_to receive(:log) + logger.send(method) + end + + it "in collector mode", :collector_mode do + expect(Appsignal::Logger::OpenTelemetryBackend).not_to receive(:emit) + logger.send(method) + 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 + it "in agent mode", :agent_mode do + expect(Appsignal::Extension).not_to receive(:log) + logger.send(method, "Log message") + end + + it "in collector mode", :collector_mode do + expect(Appsignal::Logger::OpenTelemetryBackend).not_to receive(:emit) + logger.send(method, "Log message") + end end end end @@ -589,108 +1024,192 @@ 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 + it "in agent mode", :agent_mode 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") + 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 + expect(Appsignal::Logger::OpenTelemetryBackend).to receive(:emit) + .with( + "group", + logger_level, + Appsignal::Logger::AUTODETECT, + "formatted: 2023-01-01T00:00:00.000000 'Log message'", + {} + ) + logger.send(method, "Log message") + 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" }) + describe "adds the attributes when a message is logged" do + it "in agent mode", :agent_mode do + 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" }) + end - 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" }) + it "in collector mode", :collector_mode do + logger = Appsignal::Logger.new("group", :attributes => { :some_key => "some_value" }) + + expect(Appsignal::Logger::OpenTelemetryBackend).to receive(:emit).with( + "group", + ::Logger::ERROR, + Appsignal::Logger::AUTODETECT, + "Some message", + { :other_key => "other_value", :some_key => "some_value" } + ) + logger.error("Some message", { :other_key => "other_value" }) + 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 + it "in agent mode", :agent_mode do + 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({ :some_key => "other_value" }) + ) + 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 collector mode", :collector_mode do + logger = Appsignal::Logger.new("group", :attributes => { :some_key => "some_value" }) - logger.error("Some message", { :some_key => "other_value" }) + expect(Appsignal::Logger::OpenTelemetryBackend).to receive(:emit).with( + "group", + ::Logger::ERROR, + Appsignal::Logger::AUTODETECT, + "Some message", + { :some_key => "other_value" } + ) + logger.error("Some message", { :some_key => "other_value" }) + 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 + it "in agent mode", :agent_mode do + logger = Appsignal::Logger.new("group", :attributes => { :some_key => "some_value" }) + + 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") + end + + it "in collector mode", :collector_mode do + logger = Appsignal::Logger.new("group", :attributes => { :some_key => "some_value" }) - 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") + expect(Appsignal::Logger::OpenTelemetryBackend).to receive(:emit).with( + "group", + ::Logger::INFO, + Appsignal::Logger::AUTODETECT, + "Log message", + { :some_key => "some_value" } + ) + logger.add(::Logger::INFO, "Log message") + end end end describe "#error with exception object" do - it "logs the exception class and its message" do - error = + describe "logs the exception class and its message" do + let(:error) do begin raise ExampleStandardError, "oh no!" rescue => e - # This makes the exception include a backtrace, so we can assert its - # first line is included + # Re-raise capture so the exception carries a backtrace, letting + # us assert that its first line is part of the logged string. 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) + end + + it "in agent mode", :agent_mode do + 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) + end + + it "in collector mode", :collector_mode do + expect(Appsignal::Logger::OpenTelemetryBackend).to receive(:emit) + .with( + "group", + ::Logger::ERROR, + Appsignal::Logger::AUTODETECT, + a_string_matching(/ExampleStandardError: oh no! \(.*logger_spec.rb.*\)/), + {} + ) + logger.error(error) + 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 + it "in agent mode", :agent_mode do + expect(Appsignal::Extension).to receive(:log) + .with("group", 3, 3, "hello there", instance_of(Appsignal::Extension::Data)) - message = "hello there" - result = logger << message - expect(result).to eq(message.length) + message = "hello there" + result = logger << message + expect(result).to eq(message.length) + end + + it "in collector mode", :collector_mode do + expect(Appsignal::Logger::OpenTelemetryBackend).to receive(:emit) + .with("group", ::Logger::INFO, Appsignal::Logger::AUTODETECT, "hello there", {}) + + message = "hello there" + result = logger << message + expect(result).to eq(message.length) + end end context "with a formatter set" do @@ -700,18 +1219,23 @@ 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 + it "in agent mode", :agent_mode do + expect(Appsignal::Extension).to receive(:log).with( + "group", 3, 3, "formatted: 'Log message'", instance_of(Appsignal::Extension::Data) + ) + logger << "Log message" + end + + it "in collector mode", :collector_mode do + expect(Appsignal::Logger::OpenTelemetryBackend).to receive(:emit).with( + "group", ::Logger::INFO, Appsignal::Logger::AUTODETECT, "formatted: 'Log message'", {} + ) + logger << "Log message" + end end end end @@ -726,24 +1250,4 @@ it_behaves_like "tagged logging" end end - - if DependencyHelper.ruby_3_1_or_newer? - context "when collector mode is active" do - before do - Appsignal.clear! - start_agent(:options => { :collector_endpoint => "http://127.0.0.1:9090" }) - end - - it "routes through the OpenTelemetry backend, not the extension" do - allow(Appsignal::Logger::OpenTelemetryBackend).to receive(:emit) - expect(Appsignal::Extension).not_to receive(:log) - - logger.info("Hello", :tag => "value") - - expect(Appsignal::Logger::OpenTelemetryBackend).to have_received(:emit) - .with("group", ::Logger::INFO, Appsignal::Logger::AUTODETECT, "Hello", - { :tag => "value" }) - end - end - end end From 0cdc16e39a06bcd36bdcc5894c09f95f642cadf6 Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Mon, 1 Jun 2026 20:44:25 +0200 Subject: [PATCH 018/151] Add collector-mode trace integration spec Mirrors the metrics and logs integration specs: spawn a runner subprocess that drives Appsignal.monitor with nested Appsignal.instrument calls, force-flush the tracer provider, and decode the OTLP request posted to the mock collector at /v1/traces. Asserts on span tree shape: SERVER root, nested parent_span_id chain, shared trace_id, SQL formatter attributes on the active_record.sql event. Closes the integration-coverage gap that opened when metrics and logs got dedicated end-to-end specs but traces only had a trivial wiring check inside collector_mode_spec.rb. --- .../integration/collector_mode_traces_spec.rb | 47 +++++++++++++++++++ .../runners/collector_mode_traces.rb | 30 ++++++++++++ 2 files changed, 77 insertions(+) create mode 100644 spec/integration/collector_mode_traces_spec.rb create mode 100644 spec/integration/runners/collector_mode_traces.rb diff --git a/spec/integration/collector_mode_traces_spec.rb b/spec/integration/collector_mode_traces_spec.rb new file mode 100644 index 000000000..bbdcc58b2 --- /dev/null +++ b/spec/integration/collector_mode_traces_spec.rb @@ -0,0 +1,47 @@ +if DependencyHelper.ruby_3_1_or_newer? + 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) + + # Event spans for each instrumented block are present. + expect(by_name.keys).to include("active_record.sql", "template.render", "partial.render") + + # 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(by_name["active_record.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. + sql = by_name["active_record.sql"] + 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_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" From e7f4cc13b06fe7f1d5b28c6e22995e8d18fa45d5 Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Tue, 2 Jun 2026 12:03:51 +0200 Subject: [PATCH 019/151] Share the action between paired mode tests Each scenario describe with paired `:agent_mode` / `:collector_mode` its now defines `perform` (or `define_method(:perform)` where the body needs to capture loop variables); both modes call it. Drift between the modes becomes structural rather than editorial. Multi-step scenarios that interleave actions and assertions are left as-is. Side effects: unified `.increment_counter "with tags"` count, which had drifted to 1 in agent mode and 5 in collector mode; dropped redundant `keep_transactions {...}` wraps in agent-mode its now that the shared context's `around` covers them. --- .../finish_with_state_shared_examples.rb | 22 +- .../instrument_shared_examples.rb | 89 ++-- .../start_finish_shared_examples.rb | 47 +- spec/lib/appsignal/hooks/dry_monitor_spec.rb | 22 +- .../integrations/data_mapper_spec.rb | 22 +- spec/lib/appsignal/logger_spec.rb | 499 ++++++++++-------- spec/lib/appsignal/transaction_spec.rb | 72 +-- spec/lib/appsignal_spec.rb | 80 +-- 8 files changed, 472 insertions(+), 381 deletions(-) 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 35c81ccbe..bfc2c0636 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 @@ -2,13 +2,17 @@ let(:instrumenter) { as.instrumenter } 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 transaction = http_request_transaction set_current_transaction(transaction) as.notifier = notifier - listeners_state = instrumenter.start("sql.active_record", {}) - instrumenter.finish_with_state(listeners_state, "sql.active_record", :sql => "SQL") + perform expect(transaction).to include_event( "body" => "SQL", @@ -24,8 +28,7 @@ set_current_transaction(transaction) as.notifier = notifier - listeners_state = instrumenter.start("sql.active_record", {}) - instrumenter.finish_with_state(listeners_state, "sql.active_record", :sql => "SQL") + perform Appsignal::Transaction.complete_current! span = event_spans.find { |s| s.name == "sql.active_record" } @@ -37,13 +40,17 @@ end 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 transaction = http_request_transaction set_current_transaction(transaction) as.notifier = notifier - listeners_state = instrumenter.start("!sql.active_record", {}) - instrumenter.finish_with_state(listeners_state, "!sql.active_record", :sql => "SQL") + perform expect(transaction).to_not include_events end @@ -53,8 +60,7 @@ set_current_transaction(transaction) as.notifier = notifier - listeners_state = instrumenter.start("!sql.active_record", {}) - instrumenter.finish_with_state(listeners_state, "!sql.active_record", :sql => "SQL") + perform Appsignal::Transaction.complete_current! expect(event_spans).to be_empty 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 44c22b0a2..075b0ad11 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,13 +1,15 @@ shared_examples "activesupport instrument override" do 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 transaction = http_request_transaction set_current_transaction(transaction) as.notifier = notifier - return_value = as.instrument("sql.active_record", :sql => "SQL") { "value" } - - expect(return_value).to eq "value" + expect(perform).to eq "value" expect(transaction).to include_event( "body" => "SQL", "body_format" => Appsignal::EventFormatter::SQL_BODY_FORMAT, @@ -22,10 +24,9 @@ set_current_transaction(transaction) as.notifier = notifier - return_value = as.instrument("sql.active_record", :sql => "SQL") { "value" } + expect(perform).to eq "value" Appsignal::Transaction.complete_current! - expect(return_value).to eq "value" 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) @@ -37,14 +38,16 @@ end describe "an event with no registered formatter" do + def perform + as.instrument("no-registered.formatter", :key => "something") { "value" } + end + it "in agent mode", :agent_mode do transaction = http_request_transaction set_current_transaction(transaction) as.notifier = notifier - return_value = as.instrument("no-registered.formatter", :key => "something") { "value" } - - expect(return_value).to eq "value" + expect(perform).to eq "value" expect(transaction).to include_event( "body" => "", "body_format" => Appsignal::EventFormatter::DEFAULT, @@ -59,10 +62,9 @@ set_current_transaction(transaction) as.notifier = notifier - return_value = as.instrument("no-registered.formatter", :key => "something") { "value" } + expect(perform).to eq "value" Appsignal::Transaction.complete_current! - expect(return_value).to eq "value" 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) @@ -74,12 +76,16 @@ end describe "an event with a non-string name" do + def perform + as.instrument(:not_a_string) {} # rubocop:disable Lint/EmptyBlock + end + it "in agent mode", :agent_mode do transaction = http_request_transaction set_current_transaction(transaction) as.notifier = notifier - as.instrument(:not_a_string) {} # rubocop:disable Lint/EmptyBlock + perform expect(transaction).to include_event( "body" => "", @@ -95,7 +101,7 @@ set_current_transaction(transaction) as.notifier = notifier - as.instrument(:not_a_string) {} # rubocop:disable Lint/EmptyBlock + perform Appsignal::Transaction.complete_current! expect(event_spans.map(&:name)).to include("not_a_string") @@ -103,14 +109,16 @@ end describe "an event whose name starts with a bang" do + def perform + as.instrument("!sql.active_record", :sql => "SQL") { "value" } + end + it "in agent mode", :agent_mode do transaction = http_request_transaction set_current_transaction(transaction) as.notifier = notifier - return_value = as.instrument("!sql.active_record", :sql => "SQL") { "value" } - - expect(return_value).to eq "value" + expect(perform).to eq "value" expect(transaction).to_not include_events end @@ -119,25 +127,28 @@ set_current_transaction(transaction) as.notifier = notifier - return_value = as.instrument("!sql.active_record", :sql => "SQL") { "value" } + expect(perform).to eq "value" Appsignal::Transaction.complete_current! - expect(return_value).to eq "value" expect(event_spans).to be_empty end end describe "when an error is raised in an instrumented block" do - it "in agent mode", :agent_mode do - transaction = http_request_transaction - set_current_transaction(transaction) - as.notifier = notifier - + 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 + transaction = http_request_transaction + set_current_transaction(transaction) + as.notifier = notifier + + perform expect(transaction).to include_event( "body" => "SQL", @@ -153,11 +164,7 @@ set_current_transaction(transaction) as.notifier = notifier - expect do - as.instrument("sql.active_record", :sql => "SQL") do - raise ExampleException, "foo" - end - end.to raise_error(ExampleException, "foo") + perform Appsignal::Transaction.complete_current! span = event_spans.find { |s| s.name == "sql.active_record" } @@ -168,14 +175,18 @@ end describe "when a message is thrown in an instrumented block" do + def perform + expect do + as.instrument("sql.active_record", :sql => "SQL") { throw :foo } + end.to throw_symbol(:foo) + end + it "in agent mode", :agent_mode do transaction = http_request_transaction set_current_transaction(transaction) as.notifier = notifier - expect do - as.instrument("sql.active_record", :sql => "SQL") { throw :foo } - end.to throw_symbol(:foo) + perform expect(transaction).to include_event( "body" => "SQL", @@ -191,9 +202,7 @@ set_current_transaction(transaction) as.notifier = notifier - expect do - as.instrument("sql.active_record", :sql => "SQL") { throw :foo } - end.to throw_symbol(:foo) + perform Appsignal::Transaction.complete_current! span = event_spans.find { |s| s.name == "sql.active_record" } @@ -203,14 +212,18 @@ end 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 transaction = http_request_transaction set_current_transaction(transaction) as.notifier = notifier - as.instrument("sql.active_record", :sql => "SQL") do - Appsignal::Transaction.complete_current! - end + perform expect(transaction).to_not include_events expect(transaction).to be_completed @@ -221,9 +234,7 @@ set_current_transaction(transaction) as.notifier = notifier - as.instrument("sql.active_record", :sql => "SQL") do - Appsignal::Transaction.complete_current! - end + perform expect(transaction).to be_completed expect(event_spans.map(&:name)).not_to include("sql.active_record") 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 21abd2a32..a4faff8bc 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 @@ -2,13 +2,17 @@ let(:instrumenter) { as.instrumenter } 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 transaction = http_request_transaction set_current_transaction(transaction) as.notifier = notifier - instrumenter.start("sql.active_record", :sql => "SQL") - instrumenter.finish("sql.active_record", {}) + perform expect(transaction).to include_event( "body" => "", @@ -24,8 +28,7 @@ set_current_transaction(transaction) as.notifier = notifier - instrumenter.start("sql.active_record", :sql => "SQL") - instrumenter.finish("sql.active_record", {}) + perform Appsignal::Transaction.complete_current! span = event_spans.find { |s| s.name == "sql.active_record" } @@ -39,13 +42,17 @@ end 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 transaction = http_request_transaction set_current_transaction(transaction) as.notifier = notifier - instrumenter.start("sql.active_record", {}) - instrumenter.finish("sql.active_record", :sql => "SQL") + perform expect(transaction).to include_event( "body" => "SQL", @@ -61,8 +68,7 @@ set_current_transaction(transaction) as.notifier = notifier - instrumenter.start("sql.active_record", {}) - instrumenter.finish("sql.active_record", :sql => "SQL") + perform Appsignal::Transaction.complete_current! span = event_spans.find { |s| s.name == "sql.active_record" } @@ -74,13 +80,17 @@ end 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 transaction = http_request_transaction set_current_transaction(transaction) as.notifier = notifier - instrumenter.start("!sql.active_record", {}) - instrumenter.finish("!sql.active_record", {}) + perform expect(transaction).to_not include_events end @@ -90,8 +100,7 @@ set_current_transaction(transaction) as.notifier = notifier - instrumenter.start("!sql.active_record", {}) - instrumenter.finish("!sql.active_record", {}) + perform Appsignal::Transaction.complete_current! expect(event_spans).to be_empty @@ -99,14 +108,18 @@ end 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 transaction = http_request_transaction set_current_transaction(transaction) as.notifier = notifier - instrumenter.start("sql.active_record", {}) - Appsignal::Transaction.complete_current! - instrumenter.finish("sql.active_record", {}) + perform expect(transaction).to_not include_events expect(transaction).to be_completed @@ -117,9 +130,7 @@ set_current_transaction(transaction) as.notifier = notifier - instrumenter.start("sql.active_record", {}) - Appsignal::Transaction.complete_current! - instrumenter.finish("sql.active_record", {}) + perform expect(transaction).to be_completed expect(event_spans.map(&:name)).not_to include("sql.active_record") diff --git a/spec/lib/appsignal/hooks/dry_monitor_spec.rb b/spec/lib/appsignal/hooks/dry_monitor_spec.rb index 91b945758..71aeeec02 100644 --- a/spec/lib/appsignal/hooks/dry_monitor_spec.rb +++ b/spec/lib/appsignal/hooks/dry_monitor_spec.rb @@ -43,12 +43,14 @@ } end + def perform + notifications.instrument(event_id, payload) + end + it "in agent mode", :agent_mode do transaction = http_request_transaction set_current_transaction(transaction) - - notifications.instrument(event_id, payload) - + perform expect(transaction).to include_event( "body" => "SELECT * FROM users", "body_format" => Appsignal::EventFormatter::SQL_BODY_FORMAT, @@ -61,8 +63,7 @@ it "in collector mode", :collector_mode do transaction = http_request_transaction set_current_transaction(transaction) - - notifications.instrument(event_id, payload) + perform Appsignal::Transaction.complete_current! expect(event_spans.size).to eq(1) @@ -81,12 +82,14 @@ let(:event_id) { :foo } let(:payload) { { :name => "foo" } } + def perform + notifications.instrument(event_id, payload) + end + it "in agent mode", :agent_mode do transaction = http_request_transaction set_current_transaction(transaction) - - notifications.instrument(event_id, payload) - + perform expect(transaction).to include_event( "body" => "", "body_format" => Appsignal::EventFormatter::DEFAULT, @@ -99,8 +102,7 @@ it "in collector mode", :collector_mode do transaction = http_request_transaction set_current_transaction(transaction) - - notifications.instrument(event_id, payload) + perform Appsignal::Transaction.complete_current! expect(event_spans.size).to eq(1) diff --git a/spec/lib/appsignal/integrations/data_mapper_spec.rb b/spec/lib/appsignal/integrations/data_mapper_spec.rb index b0a3c28e5..1f8c1480b 100644 --- a/spec/lib/appsignal/integrations/data_mapper_spec.rb +++ b/spec/lib/appsignal/integrations/data_mapper_spec.rb @@ -29,12 +29,14 @@ def log_message end) end + def perform + log_message + end + it "in agent mode", :agent_mode do transaction = http_request_transaction set_current_transaction(transaction) - - keep_transactions { log_message } - + perform expect(transaction).to include_event( "name" => "query.data_mapper", "title" => "DataMapper Query", @@ -47,8 +49,7 @@ def log_message it "in collector mode", :collector_mode do transaction = http_request_transaction set_current_transaction(transaction) - - log_message + perform Appsignal::Transaction.complete_current! expect(event_spans.size).to eq(1) @@ -74,12 +75,14 @@ def log_message end) end + def perform + log_message + end + it "in agent mode", :agent_mode do transaction = http_request_transaction set_current_transaction(transaction) - - keep_transactions { log_message } - + perform expect(transaction).to include_event( "name" => "query.data_mapper", "title" => "DataMapper Query", @@ -92,8 +95,7 @@ def log_message it "in collector mode", :collector_mode do transaction = http_request_transaction set_current_transaction(transaction) - - log_message + perform Appsignal::Transaction.complete_current! expect(event_spans.size).to eq(1) diff --git a/spec/lib/appsignal/logger_spec.rb b/spec/lib/appsignal/logger_spec.rb index 81274eb92..423f564c1 100644 --- a/spec/lib/appsignal/logger_spec.rb +++ b/spec/lib/appsignal/logger_spec.rb @@ -1,5 +1,11 @@ shared_examples "tagged logging" do 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 expect(Appsignal::Extension).to receive(:log) .with( @@ -9,10 +15,7 @@ "[My tag] [My other tag] Some message\n", Appsignal::Utils::Data.generate({}) ) - - logger.tagged("My tag", "My other tag") do - logger.info("Some message") - end + perform end it "in collector mode", :collector_mode do @@ -24,14 +27,19 @@ "[My tag] [My other tag] Some message\n", {} ) + perform + end + end + describe "with nested tags from logger.tagged" do + def perform logger.tagged("My tag", "My other tag") do - logger.info("Some message") + logger.tagged("Nested tag", "Nested other tag") do + logger.info("Some message") + end end end - end - describe "with nested tags from logger.tagged" do it "in agent mode", :agent_mode do expect(Appsignal::Extension).to receive(:log) .with( @@ -41,12 +49,7 @@ "[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") - end - end + perform end it "in collector mode", :collector_mode do @@ -58,12 +61,7 @@ "[My tag] [My other tag] [Nested tag] [Nested other tag] Some message\n", {} ) - - logger.tagged("My tag", "My other tag") do - logger.tagged("Nested tag", "Nested other tag") do - logger.info("Some message") - end - end + perform end end @@ -142,11 +140,14 @@ end describe "with tags from Rails 8 application.config.log_tags" do - it "in agent mode", :agent_mode do - allow(Appsignal::Extension).to receive(:log) - + 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 + allow(Appsignal::Extension).to receive(:log) + perform expect(Appsignal::Extension).to have_received(:log) .with( "group", @@ -159,9 +160,7 @@ it "in collector mode", :collector_mode do 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") } + perform expect(Appsignal::Logger::OpenTelemetryBackend).to have_received(:emit) .with( "group", @@ -228,6 +227,12 @@ end 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 expect(Appsignal::Extension).to receive(:log) .with( @@ -237,10 +242,7 @@ "[My tag] [My other tag] Some message\n", Appsignal::Utils::Data.generate({}) ) - - logger.tagged(["My tag", "My other tag"]) do - logger.info("Some message") - end + perform end it "in collector mode", :collector_mode do @@ -252,10 +254,7 @@ "[My tag] [My other tag] Some message\n", {} ) - - logger.tagged(["My tag", "My other tag"]) do - logger.info("Some message") - end + perform end end @@ -266,6 +265,10 @@ if !DependencyHelper.rails_present? || DependencyHelper.rails7_present? describe "when calling #tagged without a block" do describe "returns a new logger with the tags added" do + def perform + logger.tagged("My tag", "My other tag").info("Some message") + end + it "in agent mode", :agent_mode do expect(Appsignal::Extension).to receive(:log) .with( @@ -275,8 +278,7 @@ "[My tag] [My other tag] Some message\n", Appsignal::Utils::Data.generate({}) ) - - logger.tagged("My tag", "My other tag").info("Some message") + perform end it "in collector mode", :collector_mode do @@ -288,8 +290,7 @@ "[My tag] [My other tag] Some message\n", {} ) - - logger.tagged("My tag", "My other tag").info("Some message") + perform end end @@ -346,6 +347,10 @@ end describe "can be chained" do + def perform + logger.tagged("My tag", "My other tag").tagged("My third tag").info("Some message") + end + it "in agent mode", :agent_mode do expect(Appsignal::Extension).to receive(:log) .with( @@ -355,8 +360,7 @@ "[My tag] [My other tag] [My third tag] Some message\n", Appsignal::Utils::Data.generate({}) ) - - logger.tagged("My tag", "My other tag").tagged("My third tag").info("Some message") + perform end it "in collector mode", :collector_mode do @@ -368,12 +372,19 @@ "[My tag] [My other tag] [My third tag] Some message\n", {} ) - - logger.tagged("My tag", "My other tag").tagged("My third tag").info("Some message") + perform end end 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 + it "in agent mode", :agent_mode do expect(Appsignal::Extension).to receive(:log) .with( @@ -383,12 +394,7 @@ "[My tag] [My other tag] [My third tag] Some message\n", Appsignal::Utils::Data.generate({}) ) - - # 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 + perform end it "in collector mode", :collector_mode do @@ -400,14 +406,17 @@ "[My tag] [My other tag] [My third tag] Some message\n", {} ) - - logger.tagged("My tag", "My other tag").tagged("My third tag") do |logger| - logger.info("Some message") - end + perform end end 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 + it "in agent mode", :agent_mode do expect(Appsignal::Extension).to receive(:log) .with( @@ -417,10 +426,7 @@ "[My tag] [My other tag] [My third tag] Some message\n", Appsignal::Utils::Data.generate({}) ) - - logger.tagged("My tag", "My other tag") do - logger.tagged("My third tag").info("Some message") - end + perform end it "in collector mode", :collector_mode do @@ -432,10 +438,7 @@ "[My tag] [My other tag] [My third tag] Some message\n", {} ) - - logger.tagged("My tag", "My other tag") do - logger.tagged("My third tag").info("Some message") - end + perform end end end @@ -482,20 +485,30 @@ 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 expect(Appsignal::Extension).to receive(:log) .with("group", 3, 3, "Log message", instance_of(Appsignal::Extension::Data)) - logger.add(::Logger::INFO, "Log message") + perform end it "in collector mode", :collector_mode do expect(Appsignal::Logger::OpenTelemetryBackend).to receive(:emit) .with("group", ::Logger::INFO, Appsignal::Logger::AUTODETECT, "Log message", {}) - logger.add(::Logger::INFO, "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 expect(Appsignal::Extension).to receive(:log) .with("group", 3, 3, "123", instance_of(Appsignal::Extension::Data)) @@ -503,9 +516,7 @@ .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, []) + perform end it "in collector mode", :collector_mode do @@ -515,37 +526,43 @@ .with("group", ::Logger::INFO, Appsignal::Logger::AUTODETECT, "{}", {}) expect(Appsignal::Logger::OpenTelemetryBackend).to receive(:emit) .with("group", ::Logger::INFO, Appsignal::Logger::AUTODETECT, "[]", {}) - logger.add(::Logger::INFO, 123) - logger.add(::Logger::INFO, {}) - logger.add(::Logger::INFO, []) + perform end end describe "with a block" do + def perform + logger.add(::Logger::INFO) { "Log message" } + end + it "in agent mode", :agent_mode do expect(Appsignal::Extension).to receive(:log) .with("group", 3, 3, "Log message", instance_of(Appsignal::Extension::Data)) - logger.add(::Logger::INFO) { "Log message" } + perform end it "in collector mode", :collector_mode do expect(Appsignal::Logger::OpenTelemetryBackend).to receive(:emit) .with("group", ::Logger::INFO, Appsignal::Logger::AUTODETECT, "Log message", {}) - logger.add(::Logger::INFO) { "Log message" } + perform end end describe "with a level, message and group" do + def perform + logger.add(::Logger::INFO, "Log message", "other_group") + end + it "in agent mode", :agent_mode 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") + perform end it "in collector mode", :collector_mode do expect(Appsignal::Logger::OpenTelemetryBackend).to receive(:emit) .with("other_group", ::Logger::INFO, Appsignal::Logger::AUTODETECT, "Log message", {}) - logger.add(::Logger::INFO, "Log message", "other_group") + perform end end @@ -553,14 +570,18 @@ let(:logger) { Appsignal::Logger.new("group", :level => ::Logger::INFO) } 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 expect(Appsignal::Extension).not_to receive(:log) - logger.add(::Logger::DEBUG, "Log message") + perform end it "in collector mode", :collector_mode do expect(Appsignal::Logger::OpenTelemetryBackend).not_to receive(:emit) - logger.add(::Logger::DEBUG, "Log message") + perform end end end @@ -568,48 +589,60 @@ describe "with the PLAINTEXT format set" do let(:logger) { Appsignal::Logger.new("group", :format => Appsignal::Logger::PLAINTEXT) } + def perform + logger.add(::Logger::INFO, "Log message") + end + it "in agent mode", :agent_mode do 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 expect(Appsignal::Logger::OpenTelemetryBackend).to receive(:emit) .with("group", ::Logger::INFO, Appsignal::Logger::PLAINTEXT, "Log message", {}) - logger.add(::Logger::INFO, "Log message") + perform end end describe "with the logfmt format set" do let(:logger) { Appsignal::Logger.new("group", :format => Appsignal::Logger::LOGFMT) } + def perform + logger.add(::Logger::INFO, "Log message") + end + it "in agent mode", :agent_mode do 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 expect(Appsignal::Logger::OpenTelemetryBackend).to receive(:emit) .with("group", ::Logger::INFO, Appsignal::Logger::LOGFMT, "Log message", {}) - logger.add(::Logger::INFO, "Log message") + perform end end describe "with the JSON format set" do let(:logger) { Appsignal::Logger.new("group", :format => Appsignal::Logger::JSON) } + def perform + logger.add(::Logger::INFO, "Log message") + end + it "in agent mode", :agent_mode do 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 expect(Appsignal::Logger::OpenTelemetryBackend).to receive(:emit) .with("group", ::Logger::INFO, Appsignal::Logger::JSON, "Log message", {}) - logger.add(::Logger::INFO, "Log message") + perform end end @@ -621,6 +654,10 @@ end describe "logs with a level, message and group" do + def perform + logger.add(::Logger::INFO, "Log message", "other_group") + end + it "in agent mode", :agent_mode do expect(Appsignal::Extension).to receive(:log).with( "other_group", @@ -629,7 +666,7 @@ "formatted: 'Log message'", instance_of(Appsignal::Extension::Data) ) - logger.add(::Logger::INFO, "Log message", "other_group") + perform end it "in collector mode", :collector_mode do @@ -640,11 +677,15 @@ "formatted: 'Log message'", {} ) - logger.add(::Logger::INFO, "Log message", "other_group") + perform end end 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 expect(Appsignal::Extension).to receive(:log) .with( @@ -657,7 +698,7 @@ 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 @@ -672,18 +713,22 @@ 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 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 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) + perform end it "in collector mode", :collector_mode do @@ -692,7 +737,7 @@ expect(logger.formatter).to receive(:call) .with(::Logger::INFO, instance_of(Time), "group", 123) .and_return(123) - logger.add(::Logger::INFO, 123) + perform end end end @@ -708,16 +753,19 @@ end 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 + it "in agent mode", :agent_mode do 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)) - - logger.silence(::Logger::WARN) do - logger.info("Log message") - logger.warn("Log message") - end + perform end it "in collector mode", :collector_mode do @@ -725,15 +773,21 @@ .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 - logger.silence(::Logger::WARN) do + 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 - end - describe "silences the logger to error level by default" do it "in agent mode", :agent_mode do [2, 3, 5].each do |severity| expect(Appsignal::Extension).not_to receive(:log) @@ -743,14 +797,7 @@ expect(Appsignal::Extension).to receive(:log) .with("group", severity, 3, "Log message", instance_of(Appsignal::Extension::Data)) end - - logger.silence do - logger.debug("Log message") - logger.info("Log message") - logger.warn("Log message") - logger.error("Log message") - logger.fatal("Log message") - end + perform end it "in collector mode", :collector_mode do @@ -762,98 +809,75 @@ expect(Appsignal::Logger::OpenTelemetryBackend).to receive(:emit) .with("group", severity, Appsignal::Logger::AUTODETECT, "Log message", {}) end - - logger.silence do - logger.debug("Log message") - logger.info("Log message") - logger.warn("Log message") - logger.error("Log message") - logger.fatal("Log message") - end + perform end end end describe "#broadcast_to" do describe "broadcasts the message to the given logger" do - it "in agent mode", :agent_mode do - other_device = StringIO.new - other_logger = ::Logger.new(other_device) - logger.broadcast_to(other_logger) - - expect(Appsignal::Extension).to receive(:log) - .with("group", 3, 3, "Log message", instance_of(Appsignal::Extension::Data)) + let(:other_device) { StringIO.new } + let(:other_logger) { ::Logger.new(other_device) } + before { logger.broadcast_to(other_logger) } + def perform logger.info("Log message") - expect(other_device.string).to include("INFO -- group: Log message") end - it "in collector mode", :collector_mode do - other_device = StringIO.new - other_logger = ::Logger.new(other_device) - logger.broadcast_to(other_logger) + it "in agent mode", :agent_mode do + 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 expect(Appsignal::Logger::OpenTelemetryBackend).to receive(:emit) .with("group", ::Logger::INFO, Appsignal::Logger::AUTODETECT, "Log message", {}) - - logger.info("Log message") - - expect(other_device.string).to include("INFO -- group: Log message") + perform end end describe "broadcasts the message to the given logger when it's below the log level" do - it "in agent mode", :agent_mode do - logger = Appsignal::Logger.new("group", :level => ::Logger::INFO) - other_device = StringIO.new - other_logger = ::Logger.new(other_device) - logger.broadcast_to(other_logger) - - expect(Appsignal::Extension).not_to receive(:log) + 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) } + def perform logger.debug("Log message") - expect(other_device.string).to include("DEBUG -- group: Log message") end - it "in collector mode", :collector_mode do - logger = Appsignal::Logger.new("group", :level => ::Logger::INFO) - other_device = StringIO.new - other_logger = ::Logger.new(other_device) - logger.broadcast_to(other_logger) + it "in agent mode", :agent_mode do + expect(Appsignal::Extension).not_to receive(:log) + perform + end + it "in collector mode", :collector_mode do expect(Appsignal::Logger::OpenTelemetryBackend).not_to receive(:emit) - - logger.debug("Log message") - - expect(other_device.string).to include("DEBUG -- group: Log message") + perform end end describe "does not broadcast the message to the given logger when silenced" do - it "in agent mode", :agent_mode do - other_device = StringIO.new - other_logger = ::Logger.new(other_device) - logger.broadcast_to(other_logger) - - expect(Appsignal::Extension).not_to receive(:log) + let(:other_device) { StringIO.new } + let(:other_logger) { ::Logger.new(other_device) } + before { logger.broadcast_to(other_logger) } + def perform logger.silence { logger.info("Log message") } - expect(other_device.string).to eq("") end - it "in collector mode", :collector_mode do - other_device = StringIO.new - other_logger = ::Logger.new(other_device) - logger.broadcast_to(other_logger) + it "in agent mode", :agent_mode do + expect(Appsignal::Extension).not_to receive(:log) + perform + end + it "in collector mode", :collector_mode do expect(Appsignal::Logger::OpenTelemetryBackend).not_to receive(:emit) - - logger.silence { logger.info("Log message") } - - expect(other_device.string).to eq("") + perform end end @@ -875,7 +899,8 @@ 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(:respond_to?) + .with(:formatter=).and_return(false) allow(logger_without_formatter).to receive(:add) logger.broadcast_to(logger_without_formatter) @@ -899,6 +924,13 @@ end 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 + it "in agent mode", :agent_mode do expect(Appsignal::Extension).to receive(:log) .with( @@ -908,12 +940,7 @@ "[My tag] [My other tag] Some message\n", Appsignal::Utils::Data.generate({}) ) - - 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") + perform end it "in collector mode", :collector_mode do @@ -925,12 +952,7 @@ "[My tag] [My other tag] Some message\n", {} ) - - 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") + perform end end end @@ -948,14 +970,23 @@ describe "##{method}" do 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 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)) - - logger.send(method, "Log message", :attribute => "value") + .with( + "group", extension_level, 3, "Log message", + instance_of(Appsignal::Extension::Data) + ) + perform end it "in collector mode", :collector_mode do @@ -967,39 +998,45 @@ "Log message", { :attribute => "value" } ) - - logger.send(method, "Log message", :attribute => "value") + perform end end describe "with a block" do + define_method(:perform) do + logger.send(method) { "Log message" } + end + it "in agent mode", :agent_mode 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)) - - logger.send(method) { "Log message" } + .with( + "group", extension_level, 3, "Log message", + instance_of(Appsignal::Extension::Data) + ) + perform end it "in collector mode", :collector_mode do expect(Appsignal::Logger::OpenTelemetryBackend).to receive(:emit) .with("group", logger_level, Appsignal::Logger::AUTODETECT, "Log message", {}) - - logger.send(method) { "Log message" } + perform end end describe "with a nil message" do + define_method(:perform) { logger.send(method) } + it "in agent mode", :agent_mode do expect(Appsignal::Extension).not_to receive(:log) - logger.send(method) + perform end it "in collector mode", :collector_mode do expect(Appsignal::Logger::OpenTelemetryBackend).not_to receive(:emit) - logger.send(method) + perform end end @@ -1008,14 +1045,16 @@ let(:logger) { Appsignal::Logger.new("group", :level => higher_level) } 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 expect(Appsignal::Extension).not_to receive(:log) - logger.send(method, "Log message") + perform end it "in collector mode", :collector_mode do expect(Appsignal::Logger::OpenTelemetryBackend).not_to receive(:emit) - logger.send(method, "Log message") + perform end end end @@ -1035,6 +1074,8 @@ after { Timecop.return } describe "logs the formatted message" do + define_method(:perform) { logger.send(method, "Log message") } + it "in agent mode", :agent_mode do expect(Appsignal::Extension).to receive(:log) .with( @@ -1044,7 +1085,7 @@ "formatted: 2023-01-01T00:00:00.000000 'Log message'", instance_of(Appsignal::Extension::Data) ) - logger.send(method, "Log message") + perform end it "in collector mode", :collector_mode do @@ -1056,7 +1097,7 @@ "formatted: 2023-01-01T00:00:00.000000 'Log message'", {} ) - logger.send(method, "Log message") + perform end end end @@ -1064,20 +1105,24 @@ end describe "a logger with default attributes" do + let(:logger) { Appsignal::Logger.new("group", :attributes => { :some_key => "some_value" }) } + describe "adds the attributes when a message is logged" do - it "in agent mode", :agent_mode do - logger = Appsignal::Logger.new("group", :attributes => { :some_key => "some_value" }) + def perform + logger.error("Some message", { :other_key => "other_value" }) + end + it "in agent mode", :agent_mode do expect(Appsignal::Extension).to receive(:log).with( "group", 6, 3, "Some message", - Appsignal::Utils::Data.generate({ :other_key => "other_value", :some_key => "some_value" }) + Appsignal::Utils::Data.generate( + { :other_key => "other_value", :some_key => "some_value" } + ) ) - logger.error("Some message", { :other_key => "other_value" }) + perform end it "in collector mode", :collector_mode do - logger = Appsignal::Logger.new("group", :attributes => { :some_key => "some_value" }) - expect(Appsignal::Logger::OpenTelemetryBackend).to receive(:emit).with( "group", ::Logger::ERROR, @@ -1085,7 +1130,7 @@ "Some message", { :other_key => "other_value", :some_key => "some_value" } ) - logger.error("Some message", { :other_key => "other_value" }) + perform end end @@ -1103,19 +1148,19 @@ end describe "prioritises line attributes over default attributes" do - it "in agent mode", :agent_mode do - logger = Appsignal::Logger.new("group", :attributes => { :some_key => "some_value" }) + def perform + logger.error("Some message", { :some_key => "other_value" }) + end + it "in agent mode", :agent_mode do expect(Appsignal::Extension).to receive(:log).with( "group", 6, 3, "Some message", Appsignal::Utils::Data.generate({ :some_key => "other_value" }) ) - logger.error("Some message", { :some_key => "other_value" }) + perform end it "in collector mode", :collector_mode do - logger = Appsignal::Logger.new("group", :attributes => { :some_key => "some_value" }) - expect(Appsignal::Logger::OpenTelemetryBackend).to receive(:emit).with( "group", ::Logger::ERROR, @@ -1123,24 +1168,24 @@ "Some message", { :some_key => "other_value" } ) - logger.error("Some message", { :some_key => "other_value" }) + perform end end describe "adds the default attributes when #add is called" do - it "in agent mode", :agent_mode do - logger = Appsignal::Logger.new("group", :attributes => { :some_key => "some_value" }) + def perform + logger.add(::Logger::INFO, "Log message") + end + it "in agent mode", :agent_mode do 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") + perform end it "in collector mode", :collector_mode do - logger = Appsignal::Logger.new("group", :attributes => { :some_key => "some_value" }) - expect(Appsignal::Logger::OpenTelemetryBackend).to receive(:emit).with( "group", ::Logger::INFO, @@ -1148,7 +1193,7 @@ "Log message", { :some_key => "some_value" } ) - logger.add(::Logger::INFO, "Log message") + perform end end end @@ -1156,13 +1201,15 @@ describe "#error with exception object" do describe "logs the exception class and its message" do let(:error) do - begin - 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 + 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 @@ -1174,7 +1221,7 @@ a_string_matching(/ExampleStandardError: oh no! \(.*logger_spec.rb.*\)/), instance_of(Appsignal::Extension::Data) ) - logger.error(error) + perform end it "in collector mode", :collector_mode do @@ -1186,29 +1233,29 @@ a_string_matching(/ExampleStandardError: oh no! \(.*logger_spec.rb.*\)/), {} ) - logger.error(error) + perform end end end describe "#<<" do describe "writes an info message and returns the number of characters written" do - it "in agent mode", :agent_mode do - expect(Appsignal::Extension).to receive(:log) - .with("group", 3, 3, "hello there", instance_of(Appsignal::Extension::Data)) - + def perform message = "hello there" result = logger << message expect(result).to eq(message.length) end + it "in agent mode", :agent_mode do + 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 expect(Appsignal::Logger::OpenTelemetryBackend).to receive(:emit) .with("group", ::Logger::INFO, Appsignal::Logger::AUTODETECT, "hello there", {}) - - message = "hello there" - result = logger << message - expect(result).to eq(message.length) + perform end end @@ -1223,18 +1270,22 @@ # 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 expect(Appsignal::Extension).to receive(:log).with( "group", 3, 3, "formatted: 'Log message'", instance_of(Appsignal::Extension::Data) ) - logger << "Log message" + perform end it "in collector mode", :collector_mode do expect(Appsignal::Logger::OpenTelemetryBackend).to receive(:emit).with( "group", ::Logger::INFO, Appsignal::Logger::AUTODETECT, "formatted: 'Log message'", {} ) - logger << "Log message" + perform end end end diff --git a/spec/lib/appsignal/transaction_spec.rb b/spec/lib/appsignal/transaction_spec.rb index adfe2bd05..0cebf450e 100644 --- a/spec/lib/appsignal/transaction_spec.rb +++ b/spec/lib/appsignal/transaction_spec.rb @@ -2757,13 +2757,17 @@ def to_s 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 transaction = create_transaction(Appsignal::Transaction::HTTP_REQUEST) - keep_transactions do - transaction.record_event("custom.event", "T", "B", 1_000_000_000, - Appsignal::EventFormatter::DEFAULT) - Appsignal::Transaction.complete_current! - end + perform(transaction) + Appsignal::Transaction.complete_current! expect(transaction).to include_event( "name" => "custom.event", @@ -2774,9 +2778,7 @@ def to_s it "in collector mode", :collector_mode do transaction = create_transaction(Appsignal::Transaction::HTTP_REQUEST) - duration_ns = 1_000_000_000 - transaction.record_event("custom.event", "T", "B", duration_ns, - Appsignal::EventFormatter::DEFAULT) + perform(transaction) Appsignal::Transaction.complete_current! span = event_spans.first @@ -2815,13 +2817,15 @@ def to_s 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 transaction = create_transaction(Appsignal::Transaction::HTTP_REQUEST) - keep_transactions do - transaction.instrument("sql.active_record", "Query", "SELECT 1", - Appsignal::EventFormatter::SQL_BODY_FORMAT) { nil } - Appsignal::Transaction.complete_current! - end + perform(transaction) + Appsignal::Transaction.complete_current! expect(transaction).to include_event( "name" => "sql.active_record", @@ -2833,8 +2837,7 @@ def to_s it "in collector mode", :collector_mode do transaction = create_transaction(Appsignal::Transaction::HTTP_REQUEST) - transaction.instrument("sql.active_record", "Query", "SELECT 1", - Appsignal::EventFormatter::SQL_BODY_FORMAT) { nil } + perform(transaction) Appsignal::Transaction.complete_current! span = event_spans.first @@ -2850,13 +2853,15 @@ def to_s 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 transaction = create_transaction(Appsignal::Transaction::HTTP_REQUEST) - keep_transactions do - transaction.instrument("custom.event", "Title", "Body", - Appsignal::EventFormatter::DEFAULT) { nil } - Appsignal::Transaction.complete_current! - end + perform(transaction) + Appsignal::Transaction.complete_current! expect(transaction).to include_event( "name" => "custom.event", @@ -2868,8 +2873,7 @@ def to_s it "in collector mode", :collector_mode do transaction = create_transaction(Appsignal::Transaction::HTTP_REQUEST) - transaction.instrument("custom.event", "Title", "Body", - Appsignal::EventFormatter::DEFAULT) { nil } + perform(transaction) Appsignal::Transaction.complete_current! span = event_spans.first @@ -2883,16 +2887,18 @@ def to_s 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 transaction = create_transaction(Appsignal::Transaction::HTTP_REQUEST) - keep_transactions do - transaction.instrument("outer.event", "Outer", "outer body", - Appsignal::EventFormatter::DEFAULT) do - transaction.instrument("inner.event", "Inner", "inner body", - Appsignal::EventFormatter::DEFAULT) { nil } - end - Appsignal::Transaction.complete_current! - end + perform(transaction) + Appsignal::Transaction.complete_current! expect(transaction).to include_event( "name" => "outer.event", "title" => "Outer", "body" => "outer body" @@ -2904,11 +2910,7 @@ def to_s it "in collector mode", :collector_mode do transaction = create_transaction(Appsignal::Transaction::HTTP_REQUEST) - transaction.instrument("outer.event", "Outer", "outer body", - Appsignal::EventFormatter::DEFAULT) do - transaction.instrument("inner.event", "Inner", "inner body", - Appsignal::EventFormatter::DEFAULT) { nil } - end + perform(transaction) Appsignal::Transaction.complete_current! outer = event_spans.find { |s| s.name == "outer.event" } diff --git a/spec/lib/appsignal_spec.rb b/spec/lib/appsignal_spec.rb index 2b1103c87..b380cf22d 100644 --- a/spec/lib/appsignal_spec.rb +++ b/spec/lib/appsignal_spec.rb @@ -2049,18 +2049,20 @@ def event_spans end describe "with tags" do + def perform + Appsignal.set_gauge("key", 0.1, tags) + end + it "in agent mode", :agent_mode do expect(Appsignal::Extension).to receive(:set_gauge) .with("key", 0.1, Appsignal::Utils::Data.generate(tags)) - Appsignal.set_gauge("key", 0.1, tags) + perform end it "in collector mode", :collector_mode do allow(Appsignal::Metrics::OpenTelemetryBackend).to receive(:set_gauge) expect(Appsignal::Extension).not_to receive(:set_gauge) - - Appsignal.set_gauge("key", 0.1, tags) - + perform expect(Appsignal::Metrics::OpenTelemetryBackend).to have_received(:set_gauge) .with("key", 0.1, tags) end @@ -2099,18 +2101,20 @@ def event_spans end describe "with tags" do + def perform + Appsignal.increment_counter("key", 5, tags) + end + it "in agent mode", :agent_mode do expect(Appsignal::Extension).to receive(:increment_counter) - .with("key", 1, Appsignal::Utils::Data.generate(tags)) - Appsignal.increment_counter("key", 1, tags) + .with("key", 5, Appsignal::Utils::Data.generate(tags)) + perform end it "in collector mode", :collector_mode do allow(Appsignal::Metrics::OpenTelemetryBackend).to receive(:increment_counter) expect(Appsignal::Extension).not_to receive(:increment_counter) - - Appsignal.increment_counter("key", 5, tags) - + perform expect(Appsignal::Metrics::OpenTelemetryBackend).to have_received(:increment_counter) .with("key", 5, tags) end @@ -2154,18 +2158,20 @@ def event_spans end describe "with tags" do + def perform + Appsignal.add_distribution_value("key", 0.1, tags) + end + it "in agent mode", :agent_mode 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) + perform end it "in collector mode", :collector_mode do allow(Appsignal::Metrics::OpenTelemetryBackend).to receive(:add_distribution_value) expect(Appsignal::Extension).not_to receive(:add_distribution_value) - - Appsignal.add_distribution_value("key", 0.1, tags) - + perform expect(Appsignal::Metrics::OpenTelemetryBackend).to have_received(:add_distribution_value) .with("key", 0.1, tags) end @@ -2204,11 +2210,13 @@ def event_spans end describe "recording an event around the block" do - it "in agent mode", :agent_mode do - set_current_transaction(transaction) - + def perform Appsignal.instrument("name", "title", "body") { :do_nothing } + end + it "in agent mode", :agent_mode do + set_current_transaction(transaction) + perform expect(transaction).to include_event( "name" => "name", "title" => "title", @@ -2219,8 +2227,7 @@ def event_spans it "in collector mode", :collector_mode do set_current_transaction(transaction) - - Appsignal.instrument("name", "title", "body") { :do_nothing } + perform Appsignal::Transaction.complete_current! expect(event_spans.size).to eq(1) @@ -2235,13 +2242,15 @@ def event_spans end describe "when an error is raised in the block" do - it "in agent mode", :agent_mode do - set_current_transaction(transaction) - + 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 + set_current_transaction(transaction) + perform expect(transaction).to include_event( "name" => "name", "title" => "title", "body" => "body" ) @@ -2249,10 +2258,7 @@ def event_spans it "in collector mode", :collector_mode do set_current_transaction(transaction) - - expect do - Appsignal.instrument("name", "title", "body") { raise ExampleException, "foo" } - end.to raise_error(ExampleException, "foo") + perform Appsignal::Transaction.complete_current! expect(event_spans.size).to eq(1) @@ -2264,13 +2270,15 @@ def event_spans end describe "when a symbol is thrown in the block" do - it "in agent mode", :agent_mode do - set_current_transaction(transaction) - + def perform expect do Appsignal.instrument("name", "title", "body") { throw :foo } end.to throw_symbol(:foo) + end + it "in agent mode", :agent_mode do + set_current_transaction(transaction) + perform expect(transaction).to include_event( "name" => "name", "title" => "title", "body" => "body" ) @@ -2278,10 +2286,7 @@ def event_spans it "in collector mode", :collector_mode do set_current_transaction(transaction) - - expect do - Appsignal.instrument("name", "title", "body") { throw :foo } - end.to throw_symbol(:foo) + perform Appsignal::Transaction.complete_current! expect(event_spans.size).to eq(1) @@ -2295,12 +2300,14 @@ def event_spans 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 set_current_transaction(transaction) - result = Appsignal.instrument_sql("name", "title", "body") { "return value" } - - expect(result).to eq("return value") + expect(perform).to eq("return value") expect(transaction).to include_event( "name" => "name", "title" => "title", @@ -2312,10 +2319,9 @@ def event_spans it "in collector mode", :collector_mode do set_current_transaction(transaction) - result = Appsignal.instrument_sql("name", "title", "body") { "return value" } + expect(perform).to eq("return value") Appsignal::Transaction.complete_current! - expect(result).to eq("return value") expect(event_spans.size).to eq(1) span = event_spans.first expect(span.name).to eq("name") From fb100086af7d86063e15da04ce083cae23820594 Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Tue, 2 Jun 2026 14:23:14 +0200 Subject: [PATCH 020/151] Test log/trace correlation in collector mode Add an integration spec that emits log lines from three positions: in the monitor block before any event span, inside an Appsignal.instrument event, and again after the event closes. Verifies each log record carries the trace_id/span_id of whichever span was current at emit time (root span for before/after, event span for inside). No production change: the OTel logger SDK already pulls trace_id and span_id from `Context.current` when neither is passed explicitly, and the transaction backend attaches the right span as current at each point. --- ...llector_mode_log_trace_correlation_spec.rb | 46 +++++++++++++++++++ .../collector_mode_log_trace_correlation.rb | 27 +++++++++++ 2 files changed, 73 insertions(+) create mode 100644 spec/integration/collector_mode_log_trace_correlation_spec.rb create mode 100644 spec/integration/runners/collector_mode_log_trace_correlation.rb 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..75d2b1d4b --- /dev/null +++ b/spec/integration/collector_mode_log_trace_correlation_spec.rb @@ -0,0 +1,46 @@ +if DependencyHelper.ruby_3_1_or_newer? + 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/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" From ced142e57ebc3d596ae3f0c107d7fa213e7fd931 Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Tue, 2 Jun 2026 14:29:40 +0200 Subject: [PATCH 021/151] Start monitor's root span as a new trace root `Appsignal.monitor` opens a new transaction, which is its own unit of work. The transaction backend was creating its root span with `tracer.start_span`, which defaults to `Context.current` as the parent -- so calling `Appsignal.monitor` while any OpenTelemetry span was active made the transaction's root a child of that span and folded the two into one trace. Use `tracer.start_root_span` instead. The transaction's root now ignores ambient OTel context and starts a fresh trace. The previously active context is still restored when the transaction completes. Add an integration spec that exercises the mixed-API contracts: - `Appsignal.monitor` ignores ambient OTel context. - Events created by `Appsignal.instrument` nest under whichever span is current (monitor root, or a raw OTel span the caller opened inside the transaction). - A raw OTel span opened inside an `Appsignal.instrument` block is a child of the event span. --- .../transaction/opentelemetry_backend.rb | 6 ++- .../collector_mode_mixed_api_spec.rb | 50 +++++++++++++++++++ .../runners/collector_mode_mixed_api.rb | 38 ++++++++++++++ .../transaction/opentelemetry_backend_spec.rb | 24 ++++++++- 4 files changed, 115 insertions(+), 3 deletions(-) create mode 100644 spec/integration/collector_mode_mixed_api_spec.rb create mode 100644 spec/integration/runners/collector_mode_mixed_api.rb diff --git a/lib/appsignal/transaction/opentelemetry_backend.rb b/lib/appsignal/transaction/opentelemetry_backend.rb index 85492c932..ebe5f330a 100644 --- a/lib/appsignal/transaction/opentelemetry_backend.rb +++ b/lib/appsignal/transaction/opentelemetry_backend.rb @@ -55,7 +55,11 @@ def initialize(transaction_id, namespace, gc_duration, **) @completed = false @event_stack = [] - @span = tracer.start_span( + # `start_root_span` (not `start_span`) so the transaction's root + # ignores any ambient OTel context. A transaction is a unit of work + # in its own right -- nesting it under whatever span happens to be + # current would lose that boundary and mix unrelated traces. + @span = tracer.start_root_span( placeholder_span_name(namespace), :kind => SPAN_KIND_BY_NAMESPACE.fetch(namespace, DEFAULT_SPAN_KIND) ) 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..902a09b0c --- /dev/null +++ b/spec/integration/collector_mode_mixed_api_spec.rb @@ -0,0 +1,50 @@ +if DependencyHelper.ruby_3_1_or_newer? + 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") + monitor_root = by_name.fetch("appsignal.transaction http_request") + 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/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/lib/appsignal/transaction/opentelemetry_backend_spec.rb b/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb index c8d9098f0..e5e05b3f4 100644 --- a/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb +++ b/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb @@ -57,7 +57,7 @@ def create_backend(namespace = "http_request") .to eq(backend.instance_variable_get(:@span)) end - it "nests under the current OpenTelemetry context (free distributed-tracing inheritance)" do + 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 = @@ -65,7 +65,27 @@ def create_backend(namespace = "http_request") begin backend = create_backend backend_span = backend.instance_variable_get(:@span) - expect(backend_span.parent_span_id).to eq(outer.context.span_id) + 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) + 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 From 5b4f8cef4f127054fa5455a232233159f66b65b2 Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Wed, 3 Jun 2026 17:21:50 +0200 Subject: [PATCH 022/151] Make OpenTelemetry gems optional Collector mode's OpenTelemetry gems are not gemspec dependencies. They require Ruby 3.0+, so declaring them as hard dependencies would force the gem to drop Ruby 2.7. Apps that enable collector mode add the gems to their own Gemfile instead, and a boot-time check warns and falls back to the agent when they're missing or older than the supported minimum. Each test gemfile gains a -collector variant, run on Ruby 3.1+ in CI, so collector mode stays covered now that the gems aren't installed by default. --- .changesets/add-collector-mode.md | 13 ++++ Rakefile | 70 +++++++++++++++++++ appsignal.gemspec | 14 ++-- 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/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/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-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/sidekiq-7-collector.gemfile | 6 ++ gemfiles/sidekiq-8-collector.gemfile | 6 ++ gemfiles/sinatra-collector.gemfile | 6 ++ gemfiles/webmachine1-collector.gemfile | 6 ++ gemfiles/webmachine2-collector.gemfile | 6 ++ lib/appsignal/opentelemetry.rb | 45 +++++++++++- lib/appsignal/opentelemetry/dependencies.rb | 33 +++++++++ spec/integration/collector_mode_fork_spec.rb | 2 +- ...llector_mode_log_trace_correlation_spec.rb | 2 +- spec/integration/collector_mode_logs_spec.rb | 2 +- .../collector_mode_metrics_spec.rb | 2 +- .../collector_mode_mixed_api_spec.rb | 2 +- spec/integration/collector_mode_spec.rb | 3 +- .../collector_mode_stop_flush_spec.rb | 2 +- .../integration/collector_mode_traces_spec.rb | 2 +- .../logger/opentelemetry_backend_spec.rb | 6 +- .../metrics/opentelemetry_backend_spec.rb | 6 +- spec/lib/appsignal/opentelemetry_spec.rb | 3 +- .../transaction/opentelemetry_backend_spec.rb | 11 +-- spec/lib/appsignal_spec.rb | 9 ++- spec/spec_helper.rb | 21 ++++-- spec/support/helpers/dependency_helper.rb | 18 +++++ .../support/shared_contexts/collector_mode.rb | 7 +- 57 files changed, 456 insertions(+), 44 deletions(-) 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/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/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-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/sidekiq-7-collector.gemfile create mode 100644 gemfiles/sidekiq-8-collector.gemfile create mode 100644 gemfiles/sinatra-collector.gemfile create mode 100644 gemfiles/webmachine1-collector.gemfile create mode 100644 gemfiles/webmachine2-collector.gemfile create mode 100644 lib/appsignal/opentelemetry/dependencies.rb diff --git a/.changesets/add-collector-mode.md b/.changesets/add-collector-mode.md index 90190152d..b10465b5f 100644 --- a/.changesets/add-collector-mode.md +++ b/.changesets/add-collector-mode.md @@ -4,3 +4,16 @@ 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/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 0e8145305..07969a87f 100644 --- a/appsignal.gemspec +++ b/appsignal.gemspec @@ -60,14 +60,12 @@ Gem::Specification.new do |gem| # Needs 2.0+ because we rely on Rack::Events gem.add_dependency "rack", ">= 2.0.0" - # OpenTelemetry SDK and OTLP exporters. Only loaded when collector mode is - # active (`Appsignal.config.collector_mode?`); see `lib/appsignal/opentelemetry.rb`. - gem.add_dependency "opentelemetry-exporter-otlp" - gem.add_dependency "opentelemetry-exporter-otlp-logs" - gem.add_dependency "opentelemetry-exporter-otlp-metrics" - gem.add_dependency "opentelemetry-logs-sdk" - gem.add_dependency "opentelemetry-metrics-sdk" - gem.add_dependency "opentelemetry-sdk" + # 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" diff --git a/build_matrix.yml b/build_matrix.yml index 6b4637b42..eb11c7c28 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/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/no_dependencies-collector.gemfile b/gemfiles/no_dependencies-collector.gemfile new file mode 100644 index 000000000..04f8ff2ba --- /dev/null +++ b/gemfiles/no_dependencies-collector.gemfile @@ -0,0 +1,6 @@ +# DO NOT EDIT +# This is a generated file by the `rake build_matrix:gemfiles:generate` task. +# It layers the optional OpenTelemetry gems (gemfiles/collector.rb) on top of no_dependencies.gemfile. + +eval_gemfile File.expand_path("no_dependencies.gemfile", __dir__) +eval_gemfile File.expand_path("collector.rb", __dir__) diff --git a/gemfiles/ownership-collector.gemfile b/gemfiles/ownership-collector.gemfile new file mode 100644 index 000000000..964401c18 --- /dev/null +++ b/gemfiles/ownership-collector.gemfile @@ -0,0 +1,6 @@ +# DO NOT EDIT +# This is a generated file by the `rake build_matrix:gemfiles:generate` task. +# It layers the optional OpenTelemetry gems (gemfiles/collector.rb) on top of ownership.gemfile. + +eval_gemfile File.expand_path("ownership.gemfile", __dir__) +eval_gemfile File.expand_path("collector.rb", __dir__) diff --git a/gemfiles/padrino-collector.gemfile b/gemfiles/padrino-collector.gemfile new file mode 100644 index 000000000..8c04046d8 --- /dev/null +++ b/gemfiles/padrino-collector.gemfile @@ -0,0 +1,6 @@ +# DO NOT EDIT +# This is a generated file by the `rake build_matrix:gemfiles:generate` task. +# It layers the optional OpenTelemetry gems (gemfiles/collector.rb) on top of padrino.gemfile. + +eval_gemfile File.expand_path("padrino.gemfile", __dir__) +eval_gemfile File.expand_path("collector.rb", __dir__) diff --git a/gemfiles/psych-3-collector.gemfile b/gemfiles/psych-3-collector.gemfile new file mode 100644 index 000000000..570c8704c --- /dev/null +++ b/gemfiles/psych-3-collector.gemfile @@ -0,0 +1,6 @@ +# DO NOT EDIT +# This is a generated file by the `rake build_matrix:gemfiles:generate` task. +# It layers the optional OpenTelemetry gems (gemfiles/collector.rb) on top of psych-3.gemfile. + +eval_gemfile File.expand_path("psych-3.gemfile", __dir__) +eval_gemfile File.expand_path("collector.rb", __dir__) diff --git a/gemfiles/psych-4-collector.gemfile b/gemfiles/psych-4-collector.gemfile new file mode 100644 index 000000000..55546c9e4 --- /dev/null +++ b/gemfiles/psych-4-collector.gemfile @@ -0,0 +1,6 @@ +# DO NOT EDIT +# This is a generated file by the `rake build_matrix:gemfiles:generate` task. +# It layers the optional OpenTelemetry gems (gemfiles/collector.rb) on top of psych-4.gemfile. + +eval_gemfile File.expand_path("psych-4.gemfile", __dir__) +eval_gemfile File.expand_path("collector.rb", __dir__) diff --git a/gemfiles/que-1-collector.gemfile b/gemfiles/que-1-collector.gemfile new file mode 100644 index 000000000..df75ac571 --- /dev/null +++ b/gemfiles/que-1-collector.gemfile @@ -0,0 +1,6 @@ +# DO NOT EDIT +# This is a generated file by the `rake build_matrix:gemfiles:generate` task. +# It layers the optional OpenTelemetry gems (gemfiles/collector.rb) on top of que-1.gemfile. + +eval_gemfile File.expand_path("que-1.gemfile", __dir__) +eval_gemfile File.expand_path("collector.rb", __dir__) diff --git a/gemfiles/que-2-collector.gemfile b/gemfiles/que-2-collector.gemfile new file mode 100644 index 000000000..ed1b038b1 --- /dev/null +++ b/gemfiles/que-2-collector.gemfile @@ -0,0 +1,6 @@ +# DO NOT EDIT +# This is a generated file by the `rake build_matrix:gemfiles:generate` task. +# It layers the optional OpenTelemetry gems (gemfiles/collector.rb) on top of que-2.gemfile. + +eval_gemfile File.expand_path("que-2.gemfile", __dir__) +eval_gemfile File.expand_path("collector.rb", __dir__) diff --git a/gemfiles/rails-6.0-collector.gemfile b/gemfiles/rails-6.0-collector.gemfile new file mode 100644 index 000000000..5e4a313e9 --- /dev/null +++ b/gemfiles/rails-6.0-collector.gemfile @@ -0,0 +1,6 @@ +# DO NOT EDIT +# This is a generated file by the `rake build_matrix:gemfiles:generate` task. +# It layers the optional OpenTelemetry gems (gemfiles/collector.rb) on top of rails-6.0.gemfile. + +eval_gemfile File.expand_path("rails-6.0.gemfile", __dir__) +eval_gemfile File.expand_path("collector.rb", __dir__) diff --git a/gemfiles/rails-6.1-collector.gemfile b/gemfiles/rails-6.1-collector.gemfile new file mode 100644 index 000000000..b690ee546 --- /dev/null +++ b/gemfiles/rails-6.1-collector.gemfile @@ -0,0 +1,6 @@ +# DO NOT EDIT +# This is a generated file by the `rake build_matrix:gemfiles:generate` task. +# It layers the optional OpenTelemetry gems (gemfiles/collector.rb) on top of rails-6.1.gemfile. + +eval_gemfile File.expand_path("rails-6.1.gemfile", __dir__) +eval_gemfile File.expand_path("collector.rb", __dir__) diff --git a/gemfiles/rails-7.0-collector.gemfile b/gemfiles/rails-7.0-collector.gemfile new file mode 100644 index 000000000..63b692381 --- /dev/null +++ b/gemfiles/rails-7.0-collector.gemfile @@ -0,0 +1,6 @@ +# DO NOT EDIT +# This is a generated file by the `rake build_matrix:gemfiles:generate` task. +# It layers the optional OpenTelemetry gems (gemfiles/collector.rb) on top of rails-7.0.gemfile. + +eval_gemfile File.expand_path("rails-7.0.gemfile", __dir__) +eval_gemfile File.expand_path("collector.rb", __dir__) diff --git a/gemfiles/rails-7.1-collector.gemfile b/gemfiles/rails-7.1-collector.gemfile new file mode 100644 index 000000000..733c2dba5 --- /dev/null +++ b/gemfiles/rails-7.1-collector.gemfile @@ -0,0 +1,6 @@ +# DO NOT EDIT +# This is a generated file by the `rake build_matrix:gemfiles:generate` task. +# It layers the optional OpenTelemetry gems (gemfiles/collector.rb) on top of rails-7.1.gemfile. + +eval_gemfile File.expand_path("rails-7.1.gemfile", __dir__) +eval_gemfile File.expand_path("collector.rb", __dir__) diff --git a/gemfiles/rails-7.2-collector.gemfile b/gemfiles/rails-7.2-collector.gemfile new file mode 100644 index 000000000..458c31cf4 --- /dev/null +++ b/gemfiles/rails-7.2-collector.gemfile @@ -0,0 +1,6 @@ +# DO NOT EDIT +# This is a generated file by the `rake build_matrix:gemfiles:generate` task. +# It layers the optional OpenTelemetry gems (gemfiles/collector.rb) on top of rails-7.2.gemfile. + +eval_gemfile File.expand_path("rails-7.2.gemfile", __dir__) +eval_gemfile File.expand_path("collector.rb", __dir__) diff --git a/gemfiles/rails-8.0-collector.gemfile b/gemfiles/rails-8.0-collector.gemfile new file mode 100644 index 000000000..7ea545dc3 --- /dev/null +++ b/gemfiles/rails-8.0-collector.gemfile @@ -0,0 +1,6 @@ +# DO NOT EDIT +# This is a generated file by the `rake build_matrix:gemfiles:generate` task. +# It layers the optional OpenTelemetry gems (gemfiles/collector.rb) on top of rails-8.0.gemfile. + +eval_gemfile File.expand_path("rails-8.0.gemfile", __dir__) +eval_gemfile File.expand_path("collector.rb", __dir__) diff --git a/gemfiles/rails-8.1-collector.gemfile b/gemfiles/rails-8.1-collector.gemfile new file mode 100644 index 000000000..2d67888a2 --- /dev/null +++ b/gemfiles/rails-8.1-collector.gemfile @@ -0,0 +1,6 @@ +# DO NOT EDIT +# This is a generated file by the `rake build_matrix:gemfiles:generate` task. +# It layers the optional OpenTelemetry gems (gemfiles/collector.rb) on top of rails-8.1.gemfile. + +eval_gemfile File.expand_path("rails-8.1.gemfile", __dir__) +eval_gemfile File.expand_path("collector.rb", __dir__) diff --git a/gemfiles/redis-4-collector.gemfile b/gemfiles/redis-4-collector.gemfile new file mode 100644 index 000000000..2e5d42742 --- /dev/null +++ b/gemfiles/redis-4-collector.gemfile @@ -0,0 +1,6 @@ +# DO NOT EDIT +# This is a generated file by the `rake build_matrix:gemfiles:generate` task. +# It layers the optional OpenTelemetry gems (gemfiles/collector.rb) on top of redis-4.gemfile. + +eval_gemfile File.expand_path("redis-4.gemfile", __dir__) +eval_gemfile File.expand_path("collector.rb", __dir__) diff --git a/gemfiles/redis-5-collector.gemfile b/gemfiles/redis-5-collector.gemfile new file mode 100644 index 000000000..dfeb0015a --- /dev/null +++ b/gemfiles/redis-5-collector.gemfile @@ -0,0 +1,6 @@ +# DO NOT EDIT +# This is a generated file by the `rake build_matrix:gemfiles:generate` task. +# It layers the optional OpenTelemetry gems (gemfiles/collector.rb) on top of redis-5.gemfile. + +eval_gemfile File.expand_path("redis-5.gemfile", __dir__) +eval_gemfile File.expand_path("collector.rb", __dir__) diff --git a/gemfiles/resque-2-collector.gemfile b/gemfiles/resque-2-collector.gemfile new file mode 100644 index 000000000..49486175a --- /dev/null +++ b/gemfiles/resque-2-collector.gemfile @@ -0,0 +1,6 @@ +# DO NOT EDIT +# This is a generated file by the `rake build_matrix:gemfiles:generate` task. +# It layers the optional OpenTelemetry gems (gemfiles/collector.rb) on top of resque-2.gemfile. + +eval_gemfile File.expand_path("resque-2.gemfile", __dir__) +eval_gemfile File.expand_path("collector.rb", __dir__) diff --git a/gemfiles/resque-3-collector.gemfile b/gemfiles/resque-3-collector.gemfile new file mode 100644 index 000000000..f88745316 --- /dev/null +++ b/gemfiles/resque-3-collector.gemfile @@ -0,0 +1,6 @@ +# DO NOT EDIT +# This is a generated file by the `rake build_matrix:gemfiles:generate` task. +# It layers the optional OpenTelemetry gems (gemfiles/collector.rb) on top of resque-3.gemfile. + +eval_gemfile File.expand_path("resque-3.gemfile", __dir__) +eval_gemfile File.expand_path("collector.rb", __dir__) diff --git a/gemfiles/sequel-collector.gemfile b/gemfiles/sequel-collector.gemfile new file mode 100644 index 000000000..884a85b96 --- /dev/null +++ b/gemfiles/sequel-collector.gemfile @@ -0,0 +1,6 @@ +# DO NOT EDIT +# This is a generated file by the `rake build_matrix:gemfiles:generate` task. +# It layers the optional OpenTelemetry gems (gemfiles/collector.rb) on top of sequel.gemfile. + +eval_gemfile File.expand_path("sequel.gemfile", __dir__) +eval_gemfile File.expand_path("collector.rb", __dir__) diff --git a/gemfiles/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/webmachine1-collector.gemfile b/gemfiles/webmachine1-collector.gemfile new file mode 100644 index 000000000..aae5d862d --- /dev/null +++ b/gemfiles/webmachine1-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 webmachine1.gemfile. + +eval_gemfile File.expand_path("webmachine1.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/opentelemetry.rb b/lib/appsignal/opentelemetry.rb index 89ba934dd..4e383fff8 100644 --- a/lib/appsignal/opentelemetry.rb +++ b/lib/appsignal/opentelemetry.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require "appsignal/opentelemetry/attributes" +require "appsignal/opentelemetry/dependencies" module Appsignal # @!visibility private @@ -30,8 +31,10 @@ def configure(config) # providers with our own right after, which would orphan those # threads -- unreachable by any shutdown. Suppress the auto-setup; # the exporters we build below are the only ones that should run. - ENV["OTEL_METRICS_EXPORTER"] ||= "none" - ENV["OTEL_LOGS_EXPORTER"] ||= "none" + # Set unconditionally: a user-set "otlp" here would otherwise slip + # past and reintroduce the orphaned threads. + ENV["OTEL_METRICS_EXPORTER"] = "none" + ENV["OTEL_LOGS_EXPORTER"] = "none" require "opentelemetry/sdk" require "opentelemetry/exporter/otlp" @@ -40,6 +43,12 @@ def configure(config) require "opentelemetry-logs-sdk" require "opentelemetry-exporter-otlp-logs" + # 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 @@ -144,7 +153,6 @@ def build_resource(config) "appsignal.config.language_integration" => "ruby", "service.name" => service_name, "host.name" => host_name, - "appsignal.service.process_id" => Process.pid, "appsignal.config.filter_attributes" => config[:filter_attributes], "appsignal.config.filter_function_parameters" => config[:filter_function_parameters], "appsignal.config.filter_request_query_parameters" => @@ -165,6 +173,37 @@ def build_resource(config) attrs.reject! { |_, v| v.nil? || (v.respond_to?(:empty?) && v.empty?) } ::OpenTelemetry::SDK::Resources::Resource.create(attrs) end + + private + + # 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/dependencies.rb b/lib/appsignal/opentelemetry/dependencies.rb new file mode 100644 index 000000000..4e10cb61c --- /dev/null +++ b/lib/appsignal/opentelemetry/dependencies.rb @@ -0,0 +1,33 @@ +# 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-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/spec/integration/collector_mode_fork_spec.rb b/spec/integration/collector_mode_fork_spec.rb index a60474b77..404df3b2d 100644 --- a/spec/integration/collector_mode_fork_spec.rb +++ b/spec/integration/collector_mode_fork_spec.rb @@ -2,7 +2,7 @@ # 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.ruby_3_1_or_newer? && !DependencyHelper.running_jruby? +if DependencyHelper.opentelemetry_present? && !DependencyHelper.running_jruby? require "opentelemetry/exporter/otlp" require "opentelemetry/proto/collector/metrics/v1/metrics_service_pb" diff --git a/spec/integration/collector_mode_log_trace_correlation_spec.rb b/spec/integration/collector_mode_log_trace_correlation_spec.rb index 75d2b1d4b..3fa339794 100644 --- a/spec/integration/collector_mode_log_trace_correlation_spec.rb +++ b/spec/integration/collector_mode_log_trace_correlation_spec.rb @@ -1,4 +1,4 @@ -if DependencyHelper.ruby_3_1_or_newer? +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" diff --git a/spec/integration/collector_mode_logs_spec.rb b/spec/integration/collector_mode_logs_spec.rb index 96bd953d5..ed0a164e0 100644 --- a/spec/integration/collector_mode_logs_spec.rb +++ b/spec/integration/collector_mode_logs_spec.rb @@ -1,4 +1,4 @@ -if DependencyHelper.ruby_3_1_or_newer? +if DependencyHelper.opentelemetry_present? require "opentelemetry/exporter/otlp" require "opentelemetry/proto/collector/logs/v1/logs_service_pb" diff --git a/spec/integration/collector_mode_metrics_spec.rb b/spec/integration/collector_mode_metrics_spec.rb index b31789442..fa40a4be4 100644 --- a/spec/integration/collector_mode_metrics_spec.rb +++ b/spec/integration/collector_mode_metrics_spec.rb @@ -1,4 +1,4 @@ -if DependencyHelper.ruby_3_1_or_newer? +if DependencyHelper.opentelemetry_present? require "opentelemetry/exporter/otlp" require "opentelemetry/proto/collector/metrics/v1/metrics_service_pb" diff --git a/spec/integration/collector_mode_mixed_api_spec.rb b/spec/integration/collector_mode_mixed_api_spec.rb index 902a09b0c..caf852517 100644 --- a/spec/integration/collector_mode_mixed_api_spec.rb +++ b/spec/integration/collector_mode_mixed_api_spec.rb @@ -1,4 +1,4 @@ -if DependencyHelper.ruby_3_1_or_newer? +if DependencyHelper.opentelemetry_present? require "opentelemetry/exporter/otlp" require "opentelemetry/proto/collector/trace/v1/trace_service_pb" diff --git a/spec/integration/collector_mode_spec.rb b/spec/integration/collector_mode_spec.rb index 984be55ff..ab326261b 100644 --- a/spec/integration/collector_mode_spec.rb +++ b/spec/integration/collector_mode_spec.rb @@ -2,7 +2,7 @@ # `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.ruby_3_1_or_newer? +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. @@ -32,7 +32,6 @@ def expect_appsignal_resource(resource) # rubocop:disable Metrics/AbcSize .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.service.process_id"].int_value).to be > 0 expect(attrs["appsignal.config.filter_attributes"].array_value.values.map(&:string_value)) .to eq(["password", "secret"]) diff --git a/spec/integration/collector_mode_stop_flush_spec.rb b/spec/integration/collector_mode_stop_flush_spec.rb index 8f025c114..0a384820d 100644 --- a/spec/integration/collector_mode_stop_flush_spec.rb +++ b/spec/integration/collector_mode_stop_flush_spec.rb @@ -1,4 +1,4 @@ -if DependencyHelper.ruby_3_1_or_newer? +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" diff --git a/spec/integration/collector_mode_traces_spec.rb b/spec/integration/collector_mode_traces_spec.rb index bbdcc58b2..5ac393106 100644 --- a/spec/integration/collector_mode_traces_spec.rb +++ b/spec/integration/collector_mode_traces_spec.rb @@ -1,4 +1,4 @@ -if DependencyHelper.ruby_3_1_or_newer? +if DependencyHelper.opentelemetry_present? require "opentelemetry/exporter/otlp" require "opentelemetry/proto/collector/trace/v1/trace_service_pb" diff --git a/spec/lib/appsignal/logger/opentelemetry_backend_spec.rb b/spec/lib/appsignal/logger/opentelemetry_backend_spec.rb index c95e9c953..ce19f573e 100644 --- a/spec/lib/appsignal/logger/opentelemetry_backend_spec.rb +++ b/spec/lib/appsignal/logger/opentelemetry_backend_spec.rb @@ -1,9 +1,9 @@ # frozen_string_literal: true -require "opentelemetry/sdk" -require "opentelemetry-logs-sdk" +require "opentelemetry/sdk" if DependencyHelper.opentelemetry_present? +require "opentelemetry-logs-sdk" if DependencyHelper.opentelemetry_present? -describe Appsignal::Logger::OpenTelemetryBackend do +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 diff --git a/spec/lib/appsignal/metrics/opentelemetry_backend_spec.rb b/spec/lib/appsignal/metrics/opentelemetry_backend_spec.rb index 2e3d0d143..a8aa30b91 100644 --- a/spec/lib/appsignal/metrics/opentelemetry_backend_spec.rb +++ b/spec/lib/appsignal/metrics/opentelemetry_backend_spec.rb @@ -1,9 +1,9 @@ # frozen_string_literal: true -require "opentelemetry/sdk" -require "opentelemetry-metrics-sdk" +require "opentelemetry/sdk" if DependencyHelper.opentelemetry_present? +require "opentelemetry-metrics-sdk" if DependencyHelper.opentelemetry_present? -describe Appsignal::Metrics::OpenTelemetryBackend do +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 diff --git a/spec/lib/appsignal/opentelemetry_spec.rb b/spec/lib/appsignal/opentelemetry_spec.rb index de835f974..5d31e7ab9 100644 --- a/spec/lib/appsignal/opentelemetry_spec.rb +++ b/spec/lib/appsignal/opentelemetry_spec.rb @@ -3,7 +3,7 @@ # 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.ruby_3_1_or_newer? +if DependencyHelper.opentelemetry_present? require "opentelemetry/sdk" require "opentelemetry-metrics-sdk" require "opentelemetry-logs-sdk" @@ -235,7 +235,6 @@ 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.service.process_id"]).to eq(Process.pid) expect(attrs["appsignal.config.filter_attributes"]).to eq(["password"]) expect(attrs["appsignal.config.ignore_actions"]) .to eq(["IgnoredController#action"]) diff --git a/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb b/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb index e5e05b3f4..3fbe5f174 100644 --- a/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb +++ b/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb @@ -1,8 +1,9 @@ # frozen_string_literal: true -require "opentelemetry/sdk" +require "opentelemetry/sdk" if DependencyHelper.opentelemetry_present? -describe Appsignal::Transaction::OpenTelemetryBackend do +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 @@ -40,9 +41,9 @@ def create_backend(namespace = "http_request") end { - "http_request" => :server, + "http_request" => :server, "background_job" => :consumer, - "action_cable" => :server, + "action_cable" => :server, "some_custom_ns" => :server }.each do |namespace, expected_kind| it "maps namespace #{namespace.inspect} to SpanKind #{expected_kind.inspect}" do @@ -298,7 +299,7 @@ def new_transaction_with_otel_backend(namespace = Appsignal::Transaction::HTTP_R 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 + duration_ns = 1_000_000_000 # 1 second backend.record_event("custom.event", "T", "B", Appsignal::EventFormatter::DEFAULT, duration_ns, 0) diff --git a/spec/lib/appsignal_spec.rb b/spec/lib/appsignal_spec.rb index b380cf22d..016d9144b 100644 --- a/spec/lib/appsignal_spec.rb +++ b/spec/lib/appsignal_spec.rb @@ -868,7 +868,7 @@ def on_start end end - if DependencyHelper.ruby_3_1_or_newer? + 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 } @@ -984,7 +984,7 @@ def on_start Appsignal.stop end - if DependencyHelper.ruby_3_1_or_newer? + if DependencyHelper.opentelemetry_present? context "in collector mode" do before do Appsignal.clear! @@ -2003,8 +2003,8 @@ def on_start end end - context "in collector mode" do - require "opentelemetry/sdk" + context "in collector mode", :if => DependencyHelper.opentelemetry_present? do + require "opentelemetry/sdk" if DependencyHelper.opentelemetry_present? let(:span_exporter) { ::OpenTelemetry::SDK::Trace::Export::InMemorySpanExporter.new } let(:tracer_provider) do @@ -2033,7 +2033,6 @@ def root_span def event_spans span_exporter.finished_spans.reject { |s| [:server, :consumer].include?(s.kind) } end - end describe "custom metrics" do diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index aef78c143..9eaa71a87 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -29,7 +29,7 @@ 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")].each do |f| +Dir[File.join(APPSIGNAL_SPEC_DIR, "support/shared_contexts", "*.rb")].sort.each do |f| require f end if DependencyHelper.rails_present? @@ -81,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 @@ -95,9 +99,16 @@ def spec_system_tmp_dir end config.before :suite do - # Allow connections to the OTLP mock server bound by `OTLPCollectorServer`. - WebMock.disable_net_connect!(:allow => "127.0.0.1:#{OTLPCollectorServer::PORT}") - OTLPCollectorServer.boot! + # 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? + WebMock.disable_net_connect!(:allow => "127.0.0.1:#{OTLPCollectorServer::PORT}") + OTLPCollectorServer.boot! + else + WebMock.disable_net_connect! + end end config.after do diff --git a/spec/support/helpers/dependency_helper.rb b/spec/support/helpers/dependency_helper.rb index 52ddc968e..a30fb3ee4 100644 --- a/spec/support/helpers/dependency_helper.rb +++ b/spec/support/helpers/dependency_helper.rb @@ -25,6 +25,24 @@ def running_jruby? Appsignal::System.jruby? end + # Whether the optional OpenTelemetry gems collector mode needs are + # installable in this bundle. They're no longer gemspec dependencies, so + # the OTel specs only run under the `-collector` gemfiles (Ruby 3.1+). This + # actually requires the gems (idempotent) so the guarded specs can use them. + def opentelemetry_present? + return @opentelemetry_present if defined?(@opentelemetry_present) + + @opentelemetry_present = + begin + require "opentelemetry/sdk" + require "opentelemetry-metrics-sdk" + require "opentelemetry-logs-sdk" + true + rescue LoadError + false + end + end + def rails_present? dependency_present? "rails" end diff --git a/spec/support/shared_contexts/collector_mode.rb b/spec/support/shared_contexts/collector_mode.rb index 1089459d7..2b432164b 100644 --- a/spec/support/shared_contexts/collector_mode.rb +++ b/spec/support/shared_contexts/collector_mode.rb @@ -1,6 +1,11 @@ # frozen_string_literal: true -require "opentelemetry/sdk" +# 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. +require "opentelemetry/sdk" if DependencyHelper.opentelemetry_present? RSpec.shared_context "collector mode", :collector_mode do let(:span_exporter) { ::OpenTelemetry::SDK::Trace::Export::InMemorySpanExporter.new } From 885d80d0fa1f17379ac0966da55757ed938ee833 Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Tue, 2 Jun 2026 16:32:58 +0200 Subject: [PATCH 023/151] Set action and namespace on OTel transaction span In collector mode the transaction backend now sets the action as the root span name plus an appsignal.action_name attribute, and the namespace as appsignal.namespace -- emitted at span creation so a transaction that never calls set_namespace still carries it. Renaming the root span means it can no longer be found by its placeholder name; the mixed-API integration spec looks the monitor root up by SpanKind instead (SERVER is the subtrace root the collector keys on). Also make the backend's complete idempotent and fix the backend spec's context cleanup, so completing a transaction twice no longer logs OpenTelemetry detach/ended-span errors. --- .../transaction/extension_backend.rb | 2 +- .../transaction/opentelemetry_backend.rb | 33 +++- .../collector_mode_mixed_api_spec.rb | 6 +- .../transaction/opentelemetry_backend_spec.rb | 63 ++++++-- spec/lib/appsignal/transaction_spec.rb | 148 +++++++++++++++--- 5 files changed, 214 insertions(+), 38 deletions(-) diff --git a/lib/appsignal/transaction/extension_backend.rb b/lib/appsignal/transaction/extension_backend.rb index 3cb021537..98590bb74 100644 --- a/lib/appsignal/transaction/extension_backend.rb +++ b/lib/appsignal/transaction/extension_backend.rb @@ -26,7 +26,7 @@ def finish_event(name, title, body, body_format, gc_duration) @handle.finish_event(name, title, body, body_format, gc_duration) end - def record_event(name, title, body, body_format, duration, gc_duration) + def record_event(name, title, body, body_format, duration, gc_duration) # rubocop:disable Metrics/ParameterLists @handle.record_event(name, title, body, body_format, duration, gc_duration) end diff --git a/lib/appsignal/transaction/opentelemetry_backend.rb b/lib/appsignal/transaction/opentelemetry_backend.rb index ebe5f330a..5bf1b3a55 100644 --- a/lib/appsignal/transaction/opentelemetry_backend.rb +++ b/lib/appsignal/transaction/opentelemetry_backend.rb @@ -27,8 +27,8 @@ class OpenTelemetryBackend # `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, + "http_request" => :server, + "action_cable" => :server, "background_job" => :consumer }.freeze @@ -66,6 +66,12 @@ def initialize(transaction_id, namespace, gc_duration, **) @context_token = ::OpenTelemetry::Context.attach( ::OpenTelemetry::Trace.context_with_span(@span) ) + + # `Appsignal::Transaction#initialize` sets `@namespace` directly and + # never calls `set_namespace`, so emit the attribute here from the + # constructor namespace -- otherwise a transaction that never calls + # `set_namespace` would carry no namespace for the collector. + @span.set_attribute("appsignal.namespace", namespace) if namespace end def start_event(_gc_duration) @@ -87,7 +93,7 @@ def finish_event(name, title, body, body_format, _gc_duration) span.finish end - def record_event(name, title, body, body_format, duration, _gc_duration) + def record_event(name, title, body, body_format, duration, _gc_duration) # rubocop:disable Metrics/ParameterLists start_time = Time.now - (duration / 1_000_000_000.0) span = tracer.start_span(name, :start_timestamp => start_time) write_event_body_attributes(span, body, body_format) @@ -95,10 +101,21 @@ def record_event(name, title, body, body_format, duration, _gc_duration) span.finish end - def set_action(_action) # rubocop:disable Naming/AccessorMethodName + def set_action(action) # rubocop:disable Naming/AccessorMethodName + # 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) # rubocop:disable Naming/AccessorMethodName + def set_namespace(namespace) # rubocop:disable Naming/AccessorMethodName + # 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. + @span.set_attribute("appsignal.namespace", namespace) end def set_queue_start(_start) # rubocop:disable Naming/AccessorMethodName @@ -121,6 +138,12 @@ def finish(_gc_duration) end def complete + # Idempotent: completing twice would re-detach an already-detached + # context (a mismatched-detach error) and re-finish an ended span (a + # warning). The Transaction can be completed directly and then again + # by a `complete_current!` cleanup path, so guard against it. + return if @completed + @completed = true # Drain unfinished event spans defensively: if `start_event` was # called without a matching `finish_event` (caller bug or aborted diff --git a/spec/integration/collector_mode_mixed_api_spec.rb b/spec/integration/collector_mode_mixed_api_spec.rb index caf852517..81089d79f 100644 --- a/spec/integration/collector_mode_mixed_api_spec.rb +++ b/spec/integration/collector_mode_mixed_api_spec.rb @@ -17,7 +17,11 @@ by_name = spans.to_h { |s| [s.name, s] } outer = by_name.fetch("outer.otel") - monitor_root = by_name.fetch("appsignal.transaction http_request") + # `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") diff --git a/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb b/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb index 3fbe5f174..370a35570 100644 --- a/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb +++ b/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb @@ -21,9 +21,10 @@ # 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. + # 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.each { |backend| backend.complete unless backend._completed? } + @backends_created.reverse_each { |backend| backend.complete unless backend._completed? } end def create_backend(namespace = "http_request") @@ -68,6 +69,9 @@ def create_backend(namespace = "http_request") 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 @@ -107,14 +111,6 @@ def create_backend(namespace = "http_request") expect { create_backend.record_event("name", "title", "body", 1, 1000, 0) }.not_to raise_error end - it "accepts #set_action without raising" do - expect { create_backend.set_action("MyAction") }.not_to raise_error - end - - it "accepts #set_namespace without raising" do - expect { create_backend.set_namespace("background_job") }.not_to raise_error - end - it "accepts #set_queue_start without raising" do expect { create_backend.set_queue_start(123_456) }.not_to raise_error end @@ -132,6 +128,53 @@ def create_backend(namespace = "http_request") 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 + it "is set from the constructor namespace" do + create_backend("background_job").complete + + expect(span_exporter.finished_spans.first.attributes["appsignal.namespace"]) + .to eq("background_job") + 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 "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 "#finish" do it "returns false so Transaction#complete does not run the sample_data path" do expect(create_backend.finish(0)).to eq(false) diff --git a/spec/lib/appsignal/transaction_spec.rb b/spec/lib/appsignal/transaction_spec.rb index 0cebf450e..5d686b255 100644 --- a/spec/lib/appsignal/transaction_spec.rb +++ b/spec/lib/appsignal/transaction_spec.rb @@ -8,7 +8,9 @@ # Appsignal with the collector endpoint instead, and `Appsignal.start` is a # no-op once started so the order would otherwise leave the test in agent # mode. - start_agent(:options => options, :root_path => root_path) unless example.metadata[:collector_mode] + unless example.metadata[:collector_mode] + start_agent(:options => options, :root_path => root_path) + end Timecop.freeze(time) end after { Timecop.return } @@ -142,8 +144,9 @@ create_transaction("my_custom_namespace") Appsignal::Transaction.complete_current! - expect(span_exporter.finished_spans.first.kind).to eq(:server) - expect(span_exporter.finished_spans.first.name).to eq("appsignal.transaction my_custom_namespace") + span = span_exporter.finished_spans.first + expect(span.kind).to eq(:server) + expect(span.name).to eq("appsignal.transaction my_custom_namespace") end end @@ -1490,26 +1493,51 @@ 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 + perform expect(transaction.action).to eq(action_name) expect(transaction).to have_action(action_name) end + + it "in collector mode", :collector_mode do + 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 + perform expect(transaction.action).to eq(action_name) expect(transaction).to have_action(action_name) end + + it "in collector mode", :collector_mode do + 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 @@ -1517,63 +1545,139 @@ 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 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 + 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 + perform expect(transaction.action).to eq(action_name) expect(transaction).to have_action(action_name) end + + it "in collector mode", :collector_mode do + 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 + perform expect(transaction.action).to eq(action_name) expect(transaction).to have_action(action_name) end + + it "in collector mode", :collector_mode do + 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 + perform expect(transaction.namespace).to eq namespace expect(transaction).to have_namespace(namespace) end + + it "in collector mode", :collector_mode do + 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 + perform expect(transaction.namespace).to eq(namespace) expect(transaction).to have_namespace(namespace) end + + it "in collector mode", :collector_mode do + 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" do + transaction = http_request_transaction + transaction.complete + + expect(root_span.attributes["appsignal.namespace"]) + .to eq(Appsignal::Transaction::HTTP_REQUEST) + end end end @@ -2811,7 +2915,9 @@ def perform(transaction) transaction = create_transaction(Appsignal::Transaction::HTTP_REQUEST) expect do - transaction.instrument("x.y", nil, nil, Appsignal::EventFormatter::DEFAULT) { raise "boom" } + transaction.instrument("x.y", nil, nil, Appsignal::EventFormatter::DEFAULT) do + raise "boom" + end end.to raise_error("boom") end end From 111710529ad844976c7af457537c872bc92d5f87 Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Tue, 2 Jun 2026 16:37:33 +0200 Subject: [PATCH 024/151] Cover event-only integrations in collector mode Mirror the excon, net_http, redis and redis-client specs into the dual-mode pattern so each runs against both the C-extension and OpenTelemetry transaction backends. These integrations only emit instrumentation events, which the OTel backend already supports, so no library changes are needed. --- spec/lib/appsignal/hooks/excon_spec.rb | 93 +++++--- spec/lib/appsignal/hooks/redis_client_spec.rb | 200 +++++++++++++----- spec/lib/appsignal/hooks/redis_spec.rb | 101 ++++++--- .../appsignal/integrations/net_http_spec.rb | 81 +++++-- 4 files changed, 344 insertions(+), 131 deletions(-) diff --git a/spec/lib/appsignal/hooks/excon_spec.rb b/spec/lib/appsignal/hooks/excon_spec.rb index bf18e522a..5524af0b7 100644 --- a/spec/lib/appsignal/hooks/excon_spec.rb +++ b/spec/lib/appsignal/hooks/excon_spec.rb @@ -1,6 +1,4 @@ describe Appsignal::Hooks::ExconHook do - before { start_agent } - context "with Excon" do before do stub_const("Excon", Class.new do @@ -12,6 +10,7 @@ def self.defaults end describe "#dependencies_present?" do + before { start_agent } subject { described_class.new.dependencies_present? } it { is_expected.to be_truthy } @@ -24,34 +23,74 @@ def self.defaults end describe "instrumentation" do - let(:transaction) { http_request_transaction } - before { set_current_transaction(transaction) } - around { |example| keep_transactions { example.run } } - - it "instruments a http request" do - data = { - :host => "www.google.com", - :method => :get, - :scheme => "http" - } - Excon.defaults[:instrumentor].instrument("excon.request", data) {} # rubocop:disable Lint/EmptyBlock - - expect(transaction).to include_event( - "name" => "request.excon", - "title" => "GET http://www.google.com", - "body" => "" - ) + describe "a http request" do + def perform + data = { + :host => "www.google.com", + :method => :get, + :scheme => "http" + } + Excon.defaults[:instrumentor].instrument("excon.request", data) {} # rubocop:disable Lint/EmptyBlock + end + + it "in agent mode", :agent_mode do + transaction = http_request_transaction + set_current_transaction(transaction) + perform + + expect(transaction).to include_event( + "name" => "request.excon", + "title" => "GET http://www.google.com", + "body" => "" + ) + end + + it "in collector mode", :collector_mode do + transaction = http_request_transaction + set_current_transaction(transaction) + perform + Appsignal::Transaction.complete_current! + + expect(event_spans.size).to eq(1) + span = event_spans.first + expect(span.name).to eq("request.excon") + expect(span.parent_span_id).to eq(root_span.span_id) + expect(span.attributes["appsignal.title"]).to eq("GET http://www.google.com") + expect(span.attributes).not_to have_key("appsignal.body") + end end - it "instruments a http response" do - data = { :host => "www.google.com" } - Excon.defaults[:instrumentor].instrument("excon.response", data) {} # rubocop:disable Lint/EmptyBlock + describe "a http response" do + def perform + data = { :host => "www.google.com" } + Excon.defaults[:instrumentor].instrument("excon.response", data) {} # rubocop:disable Lint/EmptyBlock + end + + it "in agent mode", :agent_mode do + transaction = http_request_transaction + set_current_transaction(transaction) + perform - expect(transaction).to include_event( - "name" => "response.excon", - "title" => "www.google.com", - "body" => "" - ) + expect(transaction).to include_event( + "name" => "response.excon", + "title" => "www.google.com", + "body" => "" + ) + end + + it "in collector mode", :collector_mode do + 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("response.excon") + expect(span.parent_span_id).to eq(root_span.span_id) + expect(span.attributes["appsignal.title"]).to eq("www.google.com") + expect(span.attributes).not_to have_key("appsignal.body") + end end end end diff --git a/spec/lib/appsignal/hooks/redis_client_spec.rb b/spec/lib/appsignal/hooks/redis_client_spec.rb index 888af0737..03338c19c 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,76 @@ 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 + 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 + transaction = http_request_transaction + set_current_transaction(transaction) + expect(perform).to eql("stub_write") + Appsignal::Transaction.complete_current! + + expect(event_spans.size).to eq(1) + span = event_spans.first + expect(span.name).to eq("query.redis") + expect(span.parent_span_id).to eq(root_span.span_id) + expect(span.attributes["appsignal.body"]).to eq("get ?") + expect(span.attributes["appsignal.title"]).to eq("stub_id") + 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 + 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 + transaction = http_request_transaction + set_current_transaction(transaction) + expect(perform).to eql("stub_write") + Appsignal::Transaction.complete_current! + + expect(event_spans.size).to eq(1) + span = event_spans.first + expect(span.name).to eq("query.redis") + expect(span.parent_span_id).to eq(root_span.span_id) + expect(span.attributes["appsignal.body"]).to eq("#{script} ? ?") + expect(span.attributes["appsignal.title"]).to eq("stub_id") + expect(span.attributes).not_to have_key("db.query.text") + end end end end @@ -118,6 +160,7 @@ def write(_commands) context "install" do before do + start_agent(:options => options) Appsignal::Hooks.load_hooks end @@ -131,6 +174,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 +186,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 +205,76 @@ 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 + 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 + transaction = http_request_transaction + set_current_transaction(transaction) + expect(perform).to eql("stub_write") + Appsignal::Transaction.complete_current! + + expect(event_spans.size).to eq(1) + span = event_spans.first + expect(span.name).to eq("query.redis") + expect(span.parent_span_id).to eq(root_span.span_id) + expect(span.attributes["appsignal.body"]).to eq("get ?") + expect(span.attributes["appsignal.title"]).to eq("stub_id") + 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 + 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 + transaction = http_request_transaction + set_current_transaction(transaction) + expect(perform).to eql("stub_write") + Appsignal::Transaction.complete_current! + + expect(event_spans.size).to eq(1) + span = event_spans.first + expect(span.name).to eq("query.redis") + expect(span.parent_span_id).to eq(root_span.span_id) + expect(span.attributes["appsignal.body"]).to eq("#{script} ? ?") + expect(span.attributes["appsignal.title"]).to eq("stub_id") + expect(span.attributes).not_to have_key("db.query.text") + end end end end @@ -200,6 +286,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 +296,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..a8721a1eb 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,75 @@ 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 + 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 + transaction = http_request_transaction + set_current_transaction(transaction) + expect(perform).to eql("stub_write") + Appsignal::Transaction.complete_current! + + expect(event_spans.size).to eq(1) + span = event_spans.first + expect(span.name).to eq("query.redis") + expect(span.parent_span_id).to eq(root_span.span_id) + expect(span.attributes["appsignal.body"]).to eq("get ?") + expect(span.attributes["appsignal.title"]).to eq("stub_id") + 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 + 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 + transaction = http_request_transaction + set_current_transaction(transaction) + expect(perform).to eql("stub_write") + Appsignal::Transaction.complete_current! + + expect(event_spans.size).to eq(1) + span = event_spans.first + expect(span.name).to eq("query.redis") + expect(span.parent_span_id).to eq(root_span.span_id) + expect(span.attributes["appsignal.body"]).to eq("#{script} ? ?") + expect(span.attributes["appsignal.title"]).to eq("stub_id") + expect(span.attributes).not_to have_key("db.query.text") + end end end end @@ -105,6 +150,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 +161,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/integrations/net_http_spec.rb b/spec/lib/appsignal/integrations/net_http_spec.rb index 24138e2aa..d636a33fc 100644 --- a/spec/lib/appsignal/integrations/net_http_spec.rb +++ b/spec/lib/appsignal/integrations/net_http_spec.rb @@ -1,33 +1,72 @@ 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 + 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" + ) + end + + it "in collector mode", :collector_mode do + transaction = http_request_transaction + set_current_transaction(transaction) + perform + Appsignal::Transaction.complete_current! + + expect(event_spans.size).to eq(1) + span = event_spans.first + expect(span.name).to eq("request.net_http") + expect(span.parent_span_id).to eq(root_span.span_id) + expect(span.attributes["appsignal.title"]).to eq("GET http://www.google.com") + expect(span.attributes).not_to have_key("appsignal.body") + 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 + transaction = http_request_transaction + set_current_transaction(transaction) + perform + + expect(transaction).to include_event( + "name" => "request.net_http", + "title" => "GET https://www.google.com" + ) + end - uri = URI.parse("https://www.google.com") - http = Net::HTTP.new(uri.host, uri.port) - http.use_ssl = true - http.get(uri.request_uri) + it "in collector mode", :collector_mode do + transaction = http_request_transaction + set_current_transaction(transaction) + perform + Appsignal::Transaction.complete_current! - expect(transaction).to include_event( - "name" => "request.net_http", - "title" => "GET https://www.google.com" - ) + expect(event_spans.size).to eq(1) + span = event_spans.first + expect(span.name).to eq("request.net_http") + expect(span.parent_span_id).to eq(root_span.span_id) + expect(span.attributes["appsignal.title"]).to eq("GET https://www.google.com") + expect(span.attributes).not_to have_key("appsignal.body") + end end end From 466464876b9756bb36cab227af31e67b992898a5 Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Wed, 3 Jun 2026 09:48:57 +0200 Subject: [PATCH 025/151] Capture metrics and logs in collector-mode specs Extend the collector-mode shared context with in-memory OpenTelemetry metric and log exporters, plus helpers to read back emitted metrics and log records. This lets integration specs assert real OTel output in collector mode, the way span capture already does. --- .../support/shared_contexts/collector_mode.rb | 72 +++++++++++++++---- 1 file changed, 60 insertions(+), 12 deletions(-) diff --git a/spec/support/shared_contexts/collector_mode.rb b/spec/support/shared_contexts/collector_mode.rb index 2b432164b..40f1e9fe9 100644 --- a/spec/support/shared_contexts/collector_mode.rb +++ b/spec/support/shared_contexts/collector_mode.rb @@ -5,7 +5,11 @@ # 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. -require "opentelemetry/sdk" if DependencyHelper.opentelemetry_present? +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 } @@ -17,26 +21,53 @@ 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 + before do start_agent(:options => { :collector_endpoint => "http://127.0.0.1:9090" }) - # `Appsignal.start` booted a full SDK tracer provider backed by a - # BatchSpanProcessor (a background export thread). Shut it down before - # swapping in the threadless in-memory provider: after the swap it is - # unreachable and its thread would leak across examples. - ::OpenTelemetry.tracer_provider.shutdown + # `Appsignal.start` booted a full OTel SDK whose providers each carry a + # background export thread (batch span and log processors, periodic + # metric reader). Shut it down before the swaps below: after the swap + # the booted providers are unreachable and their threads would leak + # across examples. + Appsignal::OpenTelemetry.shutdown + # Swap in the in-memory providers so the test can read spans/metrics/ + # logs back, and reset the metrics/logger backends so their cached + # meter/logger re-resolve against these providers on the next emit. ::OpenTelemetry.tracer_provider = tracer_provider + ::OpenTelemetry.meter_provider = meter_provider + ::OpenTelemetry.logger_provider = logger_provider + Appsignal::Metrics::OpenTelemetryBackend.reset! + Appsignal::Logger::OpenTelemetryBackend.reset! end 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 the OTel SDK down so the meter and logger providers' background - # threads don't 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. + # 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 end @@ -47,6 +78,23 @@ def root_span def event_spans span_exporter.finished_spans.reject { |s| [:server, :consumer].include?(s.kind) } 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| From 7099dba4422aa8e2e6c5a5faa3080bd5ab7f1fba Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Wed, 3 Jun 2026 09:48:57 +0200 Subject: [PATCH 026/151] Emit MongoDB event body as a JSON string The MongoDB integration was the only one to pass a structured Data object as an event body. The agent serializes that to JSON before storing it, so the output is unchanged there, while letting the OpenTelemetry backend (which only takes scalar attribute values) handle it too. Cover the integration in both modes. --- .../integrations/mongo_ruby_driver.rb | 9 +- .../integrations/mongo_ruby_driver_spec.rb | 120 ++++++++++++++++-- 2 files changed, 116 insertions(+), 13 deletions(-) diff --git a/lib/appsignal/integrations/mongo_ruby_driver.rb b/lib/appsignal/integrations/mongo_ruby_driver.rb index e5950223c..f353df3b4 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 @@ -47,12 +49,13 @@ 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}", - Appsignal::Utils::JSON.generate(command), + JSON.generate(command), Appsignal::EventFormatter::DEFAULT ) diff --git a/spec/lib/appsignal/integrations/mongo_ruby_driver_spec.rb b/spec/lib/appsignal/integrations/mongo_ruby_driver_spec.rb index 0e5934ab6..c242567ad 100644 --- a/spec/lib/appsignal/integrations/mongo_ruby_driver_spec.rb +++ b/spec/lib/appsignal/integrations/mongo_ruby_driver_spec.rb @@ -63,20 +63,120 @@ def perform transaction = http_request_transaction set_current_transaction(transaction) - expect(Appsignal).to receive(:add_distribution_value).with( - "mongodb_query_duration", - 0.9919, - :database => "test" - ).and_call_original + it "should store command on the transaction" do + subscriber.started(event) - perform + expect(transaction.store("mongo_driver")).to eq(1 => { "foo" => "?" }) + end - expect(transaction).to include_event( - "name" => "query.mongodb", - "title" => "find | test | SUCCEEDED", - "body" => "{\"foo\":\"?\"}" + it "should start an event in the extension" do + expect(transaction).to receive(:start_event) + + subscriber.started(event) + end + end + + describe "#succeeded" do + let(:event) { double } + + it "should finish the event" do + expect(subscriber).to receive(:finish).with("SUCCEEDED", event) + + subscriber.succeeded(event) + end + end + + describe "#failed" do + let(:event) { double } + + it "should finish the event" do + expect(subscriber).to receive(:finish).with("FAILED", event) + + subscriber.failed(event) + end + end + + describe "#finish" do + let(:command) { { "foo" => "?" } } + let(:event) do + double( + :request_id => 2, + :command_name => :find, + :database_name => "test", + :duration => 0.9919 ) end + + before do + store = transaction.store("mongo_driver") + store[2] = command + end + + it "should get the query from the store" do + expect(transaction).to receive(:store).with("mongo_driver").and_return(command) + + subscriber.finish("SUCCEEDED", event) + end + end + end + + describe "instrumenting a finished query" do + let(:started_event) do + double( + :request_id => 2, + :command_name => "find", + :command => { "foo" => "bar" } + ) + end + let(:finish_event) do + double( + :request_id => 2, + :command_name => :find, + :database_name => "test", + :duration => 0.9919 + ) + end + + def perform + subscriber.started(started_event) + subscriber.finish("SUCCEEDED", finish_event) + end + + it "in agent mode", :agent_mode do + transaction = http_request_transaction + set_current_transaction(transaction) + + expect(Appsignal).to receive(:add_distribution_value).with( + "mongodb_query_duration", + 0.9919, + :database => "test" + ).and_call_original + + perform + + expect(transaction).to include_event( + "name" => "query.mongodb", + "title" => "find | test | SUCCEEDED", + "body" => "{\"foo\":\"?\"}" + ) + end + + it "in collector mode", :collector_mode do + transaction = http_request_transaction + set_current_transaction(transaction) + perform + Appsignal::Transaction.complete_current! + + span = event_spans.find { |s| s.name == "query.mongodb" } + expect(span).not_to be_nil + expect(span.parent_span_id).to eq(root_span.span_id) + expect(span.attributes["appsignal.title"]).to eq("find | test | SUCCEEDED") + 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 describe "instrumenting a failed query" do From 1abbc3826f36666e9e51805b7131610e31f89a06 Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Wed, 3 Jun 2026 09:48:57 +0200 Subject: [PATCH 027/151] Cover more integrations in collector mode Add collector-mode coverage to the Sequel, ActiveSupport event reporter, MRI and GVL probe, and Action Mailer specs, asserting real OpenTelemetry spans, metrics and log records alongside the existing agent-mode assertions. --- .../lib/appsignal/hooks/action_mailer_spec.rb | 18 +++++-- spec/lib/appsignal/hooks/sequel_spec.rb | 42 ++++++++++------ .../active_support_event_reporter_spec.rb | 39 ++++++++++----- spec/lib/appsignal/probes/gvl_spec.rb | 50 ++++++++++++------- spec/lib/appsignal/probes/mri_spec.rb | 25 ++++++++-- 5 files changed, 123 insertions(+), 51 deletions(-) diff --git a/spec/lib/appsignal/hooks/action_mailer_spec.rb b/spec/lib/appsignal/hooks/action_mailer_spec.rb index 299f760b2..d1818e391 100644 --- a/spec/lib/appsignal/hooks/action_mailer_spec.rb +++ b/spec/lib/appsignal/hooks/action_mailer_spec.rb @@ -25,12 +25,8 @@ def welcome end describe ".install" do - before do - start_agent + it "in agent mode", :agent_mode do expect(Appsignal.active?).to be_truthy - end - - it "is subscribed to 'process.action_mailer' and processes instrumentation" do expect(Appsignal).to receive(:increment_counter).with( :action_mailer_process, 1, @@ -39,6 +35,18 @@ def welcome UserMailer.welcome.deliver_now end + + it "in collector mode", :collector_mode do + 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/sequel_spec.rb b/spec/lib/appsignal/hooks/sequel_spec.rb index 9e758fbca..ef0dd78e7 100644 --- a/spec/lib/appsignal/hooks/sequel_spec.rb +++ b/spec/lib/appsignal/hooks/sequel_spec.rb @@ -8,30 +8,44 @@ 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 + 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 + 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.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") 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..220ca4ddd 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,39 @@ 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" } - } + it "in agent mode", :agent_mode do + 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" }) + expect(logger).to receive(:info).with( + "user.created", + { :id => 123, :email => "user@example.com" } + ) subscriber.emit(event) end + + it "in collector mode", :collector_mode do + subscriber.emit(event) + + 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/probes/gvl_spec.rb b/spec/lib/appsignal/probes/gvl_spec.rb index 8a9341525..74805bae5 100644 --- a/spec/lib/appsignal/probes/gvl_spec.rb +++ b/spec/lib/appsignal/probes/gvl_spec.rb @@ -23,23 +23,39 @@ def gauges_for(metric) 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 - - FakeGVLTools::GlobalTimer.monotonic_time = 300_000_000 - probe.call - - expect(gauges_for("gvl_global_timer")).to eq [ - [200, { - :hostname => hostname, - :process_name => "rspec", - :process_id => Process.pid - }], - [200, { :hostname => hostname }] - ] + 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 + + it "in agent mode", :agent_mode do + # 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 eq [ + [200, { + :hostname => hostname, + :process_name => "rspec", + :process_id => Process.pid + }], + [200, { :hostname => hostname }] + ] + end + + it "in collector mode", :collector_mode do + # Inject the real Appsignal so `set_gauge` routes through the OTel + # metrics backend instead of the in-memory AppsignalMock. + perform(described_class.new(:appsignal => Appsignal, :gvl_tools => FakeGVLTools)) + + snapshot = metric_snapshot("gvl_global_timer") + expect(snapshot).not_to be_nil + expect(snapshot.instrument_kind).to eq(:gauge) + expect(snapshot.data_points.map(&:value)).to all(eq(200)) + end end context "when the delta is negative" do diff --git a/spec/lib/appsignal/probes/mri_spec.rb b/spec/lib/appsignal/probes/mri_spec.rb index 90d584aaa..0e90b8b02 100644 --- a/spec/lib/appsignal/probes/mri_spec.rb +++ b/spec/lib/appsignal/probes/mri_spec.rb @@ -36,9 +36,28 @@ end end - it "tracks thread counts" do - probe.call - expect_gauge_value("thread_count") + describe "the thread count gauge" do + def perform(probe) + probe.call + end + + it "in agent mode", :agent_mode do + perform(probe) + expect_gauge_value("thread_count") + end + + it "in collector mode", :collector_mode do + # Inject the real Appsignal so `set_gauge` routes through the OTel + # metrics backend instead of the in-memory AppsignalMock. + perform(described_class.new(:appsignal => Appsignal, :gc_profiler => gc_profiler_mock)) + + 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 From 54db8aa2eec7614a199bb71e0fbb6a556d74a2dd Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Wed, 3 Jun 2026 09:52:55 +0200 Subject: [PATCH 028/151] Cover Active Job metrics in collector mode Assert the Active Job queue job count counter reaches the OpenTelemetry metrics backend, as an agent/collector example pair sharing a single perform with the existing increment_counter mocks. --- spec/lib/appsignal/hooks/activejob_spec.rb | 49 ++++++++++++++++++++-- 1 file changed, 45 insertions(+), 4 deletions(-) diff --git a/spec/lib/appsignal/hooks/activejob_spec.rb b/spec/lib/appsignal/hooks/activejob_spec.rb index 9d2a8e8ff..5b79259e8 100644 --- a/spec/lib/appsignal/hooks/activejob_spec.rb +++ b/spec/lib/appsignal/hooks/activejob_spec.rb @@ -105,11 +105,11 @@ def perform(*_args) end around { |example| keep_transactions { example.run } } + # The queue job count counter is covered (in both modes) by the + # "emitting the queue job count metric" describe below; this stays + # agent-only because the transaction shape it asserts (action, namespace, + # tags, events) isn't implemented in collector mode yet. 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 @@ -688,4 +688,45 @@ 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 + 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 + 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 end From 675c4deec959fc2205545c65560babe4ea996503 Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Wed, 3 Jun 2026 17:01:26 +0200 Subject: [PATCH 029/151] Add collector coverage for two more metrics Cover the EventHandler response_status counter and the Sidekiq probe gauges in collector mode, as agent/collector example pairs. Both were previously deferred. --- spec/lib/appsignal/probes/sidekiq_spec.rb | 91 +++++++++++++------ spec/lib/appsignal/rack/event_handler_spec.rb | 54 +++++++++-- 2 files changed, 110 insertions(+), 35 deletions(-) diff --git a/spec/lib/appsignal/probes/sidekiq_spec.rb b/spec/lib/appsignal/probes/sidekiq_spec.rb index 712fb31b3..a4cb7cea3 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,7 @@ def with_sidekiq6! end end - it "loads Sidekiq::API" do + it "loads Sidekiq::API", :agent_mode do with_sidekiq! # Hide the Sidekiq constant if it was already loaded. It will be # redefined by loading "sidekiq/api" in the probe. @@ -215,7 +216,7 @@ 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 with_sidekiq! log = capture_logs { probe } expect(log).to contains_log(:debug, "Initializing Sidekiq probe\n") @@ -224,7 +225,7 @@ 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 log = capture_logs { probe.call } expect(log).to contains_log( :debug, @@ -235,31 +236,67 @@ 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 + 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 + perform + end + + it "in collector mode", :collector_mode do + perform + + # The agent has no in-memory metric readout, so collector mode asserts + # that representative gauges reach the OpenTelemetry backend: a plain + # gauge, a delta-based gauge carrying a status tag, and a per-queue + # gauge. The agent-mode example above covers the full set of values. + # Pull the snapshots once: each `metric_snapshots` call resets the + # in-memory reader, so a second pull would come back empty. + snapshots = metric_snapshots + + worker_count = snapshots.find { |snapshot| snapshot.name == "sidekiq_worker_count" } + expect(worker_count).not_to be_nil + expect(worker_count.instrument_kind).to eq(:gauge) + expect(worker_count.data_points.first.value).to eq(24) + expect(worker_count.data_points.first.attributes).to eq("hostname" => "localhost") + + job_count = snapshots.find { |snapshot| snapshot.name == "sidekiq_job_count" } + processed = job_count.data_points.find do |point| + point.attributes["status"] == "processed" + end + expect(processed.value).to eq(5) + + queue_length = snapshots.find { |snapshot| snapshot.name == "sidekiq_queue_length" } + default_queue = queue_length.data_points.find do |point| + point.attributes["queue"] == "default" + end + expect(default_queue.value).to eq(10) + 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 + it "doesn't create metrics for nil values", :agent_mode do expect_gauge("connection_count").never expect_gauge("memory_usage").never expect_gauge("memory_usage_rss").never @@ -270,7 +307,7 @@ def with_sidekiq6! end end - context "with Sidekiq 6" do + context "with Sidekiq 6", :agent_mode do before { with_sidekiq6! } it "logs used hostname on call once" do @@ -319,7 +356,7 @@ def with_sidekiq6! end end - context "when hostname is configured for probe" do + context "when hostname is configured for probe", :agent_mode do let(:redis_hostname) { "my_redis_server" } let(:probe) { described_class.new(:hostname => redis_hostname) } diff --git a/spec/lib/appsignal/rack/event_handler_spec.rb b/spec/lib/appsignal/rack/event_handler_spec.rb index 7a20aaaf6..93939e039 100644 --- a/spec/lib/appsignal/rack/event_handler_spec.rb +++ b/spec/lib/appsignal/rack/event_handler_spec.rb @@ -442,14 +442,6 @@ def on_finish(given_request = request, given_response = response) expect(last_transaction).to include_tags("response_status" => 200) 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) - - on_start - on_finish - end - context "with an error previously recorded by on_error" do it "sets response status from the response as a tag" do on_start @@ -484,3 +476,49 @@ 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 + + def perform + event_handler_instance.on_start(request, response) + event_handler_instance.on_finish(request, response) + end + + it "in agent mode", :agent_mode do + expect(Appsignal).to receive(:increment_counter) + .with(:response_status, 1, :status => 200, :namespace => :web) + + perform + end + + it "in collector mode", :collector_mode do + 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 From deb690411fd19e7d33dab72b698e4a421fe37356 Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Thu, 4 Jun 2026 17:12:13 +0200 Subject: [PATCH 030/151] Emit OpenTelemetry errors in collector mode Route Transaction#set_error through the OpenTelemetry backend. Each error is recorded as an "exception" span-event on the span that is current when the error is added (which may be an event span, not the root), with exception.type/message/stacktrace, an error status, and a boolean appsignal.alert_this_error so the collector reports it even on a non-root span. The collector computes the error digest. The error cause chain rides on a single appsignal.error_causes JSON event attribute ([{name, message, lines}], matching the processor's ErrorSubCause), rather than as separate exception events that the collector would each turn into their own incident. Multiple errors on one transaction collapse onto a single trace: each is recorded as its own exception event (its own incident) instead of the agent's duplicate-transaction mechanism, which is unchanged behind a backend supports_multiple_errors? predicate. before_complete fires once. Error blocks run in the order their errors were added, so a later error's block overrides an earlier one on a shared key. This came with a seam change: the backend's set_error now takes raw Ruby data (a backtrace array and a causes list) instead of a pre-serialized extension Data object, which the extension backend serializes itself. complete is now idempotent so a complete + complete_current! sequence does not report the errors twice. --- lib/appsignal/transaction.rb | 173 +++++++++++++----- .../transaction/extension_backend.rb | 17 +- .../transaction/opentelemetry_backend.rb | 51 +++++- .../transaction/extension_backend_spec.rb | 18 +- .../transaction/opentelemetry_backend_spec.rb | 123 ++++++++++++- spec/lib/appsignal/transaction_spec.rb | 166 ++++++++++++++++- 6 files changed, 494 insertions(+), 54 deletions(-) diff --git a/lib/appsignal/transaction.rb b/lib/appsignal/transaction.rb index 85c5994a5..ab57fed06 100644 --- a/lib/appsignal/transaction.rb +++ b/lib/appsignal/transaction.rb @@ -205,6 +205,12 @@ 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." @@ -223,29 +229,7 @@ def complete should_sample = @backend.finish(0) 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 @@ -706,14 +690,25 @@ def to_h # @!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.supports_multiple_errors? + # Record additional errors as they are added, so the exception event + # lands on the span that is current now rather than the root span at + # completion time. The agent backend instead reports extra errors as + # duplicate transactions at completion (see `#report_errors`). + _send_error_to_backend(error) + end + @error_blocks[error] << block @error_blocks[error].compact! end @@ -734,34 +729,74 @@ def run_before_complete_hooks end end - def _set_error(error) - backtrace = cleaned_backtrace(error.backtrace) - @backend.set_error( - error.class.name, - cleaned_error_message(error), - backtrace ? Appsignal::Utils::Data.generate(backtrace) : Appsignal::Extension.data_array_new - ) - @error_set = error + # Reports every error stored on the transaction at completion time. How + # additional errors (beyond the first, `@error_set`) are reported depends on + # the backend. + def report_errors + if @backend.supports_multiple_errors? + report_errors_on_one_trace + else + report_errors_as_duplicates + end + end - root_cause_missing = false + # Collector mode: every error is recorded as its own `exception` event (on + # the span current when it was added) at `add_error` time, so a single trace + # carries all the errors -- no duplicate transactions. Here we only run the + # error blocks. + # + # Blocks run in the order their errors were added (the first error's blocks + # first), so a later error's block overrides an earlier one on a shared key. + # Every block runs against this transaction, so block-set metadata merges + # onto the root span. This intentionally deviates from the per-error metadata + # isolation in `transaction-otel-backend.md` §6.9: isolating further errors' + # metadata onto their own exception event is deferred, as the processor/UI + # does not read those event attributes yet. + def report_errors_on_one_trace + @error_blocks.each_value do |blocks| + self.class.with_transaction(self) do + blocks.each { |block| block.call(self) } + end + end + end - causes = [] - while error - error = error.cause + # 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 - break unless error + 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 - 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} " \ - "will be reported." - root_cause_missing = true - break + transaction.complete end + end + + return unless @error_set && @error_blocks[@error_set].any? - causes << error + self.class.with_transaction(self) do + @error_blocks[@error_set].each do |block| + block.call(self) + end end + end + + def _set_error(error) + @error_set = error + + _send_error_to_backend(error) + # Agent-mode cause channel: a `first_line`-only projection sent as sample + # data. The OpenTelemetry backend stubs `set_sample_data`, so in collector + # mode this is a no-op (causes ride on `appsignal.error_causes` instead). + causes, root_cause_missing = _error_causes(error) causes_sample_data = causes.map do |e| { :name => e.class.name, @@ -778,6 +813,54 @@ def _set_error(error) ) end + # Records an error on the backend without touching `@error_set` or the + # `error_causes` sample data, so it can be called both for the first error + # (from `_set_error`) and for additional errors when collapsing multiple + # errors onto one trace (see `#complete`). + # + # The backend receives raw Ruby inputs (a backtrace Array, not a + # pre-serialized Data object) and the rich cause list. The agent backend + # serializes the backtrace to Data itself; the OpenTelemetry backend uses + # the causes to emit the `appsignal.error_causes` attribute. The `:lines` + # key matches the processor's `ErrorSubCause { name, message, lines }`. + def _send_error_to_backend(error) + causes, = _error_causes(error) + @backend.set_error( + error.class.name, + cleaned_error_message(error), + cleaned_backtrace(error.backtrace), + causes.map do |e| + { + :name => e.class.name, + :message => cleaned_error_message(e), + :lines => cleaned_backtrace(e.backtrace) + } + end + ) + 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 = [] + 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} " \ + "will be reported." + root_cause_missing = true + break + end + + causes << cause + end + + [causes, root_cause_missing] + end + BACKTRACE_REGEX = %r{(?[\w-]+ \(.+\) )?(?:?/?\w+?.+?):(?:?\d+)(?::in `(?.+)')?$}.freeze private_constant :BACKTRACE_REGEX diff --git a/lib/appsignal/transaction/extension_backend.rb b/lib/appsignal/transaction/extension_backend.rb index 98590bb74..64cccd9b1 100644 --- a/lib/appsignal/transaction/extension_backend.rb +++ b/lib/appsignal/transaction/extension_backend.rb @@ -50,7 +50,16 @@ def set_sample_data(key, data) @handle.set_sample_data(key, data) end - def set_error(class_name, message, backtrace_data) + # `backtrace` is a raw Array (or nil); the C extension wants a `Data` + # object, so serialize it here. `causes` is unused in agent mode -- the + # Transaction reports causes separately via the `error_causes` sample data. + def set_error(class_name, message, backtrace, _causes) + backtrace_data = + if backtrace + Appsignal::Utils::Data.generate(backtrace) + else + Appsignal::Extension.data_array_new + end @handle.set_error(class_name, message, backtrace_data) end @@ -62,6 +71,12 @@ def complete @handle.complete end + # The extension transaction holds a single error, so the Transaction + # reports additional errors as duplicate transactions. + def supports_multiple_errors? + false + end + def duplicate(new_transaction_id) self.class.new(new_transaction_id, nil, 0, :handle => @handle.duplicate(new_transaction_id)) end diff --git a/lib/appsignal/transaction/opentelemetry_backend.rb b/lib/appsignal/transaction/opentelemetry_backend.rb index 5bf1b3a55..e53f04fdb 100644 --- a/lib/appsignal/transaction/opentelemetry_backend.rb +++ b/lib/appsignal/transaction/opentelemetry_backend.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require "json" + module Appsignal class Transaction # @!visibility private @@ -127,7 +129,47 @@ def set_metadata(_key, _value) def set_sample_data(_key, _data) end - def set_error(_class_name, _message, _backtrace_data) + # Records the error as an OpenTelemetry `exception` span-event on the + # span that is current *now* -- which may be an event span, not the root -- + # so the error attaches to the operation that raised it. The Transaction + # records each error at the moment it is added, so the current span is the + # one active at that point. + # + # `appsignal.alert_this_error` makes the collector report the exception + # even when it is on a non-root span: the collector reports every exception + # on the root span, but only flagged ones on child spans. The collector + # computes `appsignal.error_digest` itself, so we don't set it here. + # + # Causes are emitted as a single `appsignal.error_causes` JSON attribute on + # the exception event (not on the span): separate cause events would each + # be stamped with a digest and become their own incident. The processor + # reads `appsignal.error_causes` off the event and expands it into cause + # subdata. The JSON keys (`name`/`message`/`lines`) match the processor's + # `ErrorSubCause` struct. + def set_error(class_name, message, backtrace, causes) + span = ::OpenTelemetry::Trace.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[:lines] || [] + } + end + ) + end + + span.add_event("exception", :attributes => attributes) + span.status = ::OpenTelemetry::Trace::Status.error end # Always returns `false` for now: there is no sample data flush in @@ -162,6 +204,13 @@ def duplicate(new_transaction_id) self.class.new(new_transaction_id, @namespace, 0) end + # Multiple errors are recorded as multiple `exception` events on the one + # root span (each its own incident), so the Transaction does not need to + # duplicate itself per error. + def supports_multiple_errors? + true + end + # Returned so `Transaction#to_h` (which does `JSON.parse(@backend.to_json)`) # produces an empty Hash instead of raising. The existing agent-mode # transaction matchers all read from `to_h`; in collector mode they diff --git a/spec/lib/appsignal/transaction/extension_backend_spec.rb b/spec/lib/appsignal/transaction/extension_backend_spec.rb index c1928d580..34708b204 100644 --- a/spec/lib/appsignal/transaction/extension_backend_spec.rb +++ b/spec/lib/appsignal/transaction/extension_backend_spec.rb @@ -92,10 +92,18 @@ backend.set_sample_data("params", data) end - it "forwards #set_error to the handle" do + it "serializes the backtrace Array to Data and forwards #set_error to the handle" do + 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"], []) + end + + it "forwards an empty Data array when the backtrace is nil" do 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", data) + backend.set_error("RuntimeError", "boom", nil, []) end it "forwards #finish to the handle and returns its value" do @@ -124,4 +132,10 @@ expect(backend._completed?).to eq(true) end end + + describe "#supports_multiple_errors?" do + it "returns false (extra errors are reported as duplicate transactions)" do + expect(backend.supports_multiple_errors?).to eq(false) + end + end end diff --git a/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb b/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb index 370a35570..1b4b6785c 100644 --- a/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb +++ b/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb @@ -122,10 +122,6 @@ def create_backend(namespace = "http_request") it "accepts #set_sample_data without raising" do expect { create_backend.set_sample_data("params", "anything") }.not_to raise_error end - - it "accepts #set_error without raising" do - expect { create_backend.set_error("RuntimeError", "boom", "backtrace") }.not_to raise_error - end end describe "#set_action" do @@ -175,6 +171,124 @@ def create_backend(namespace = "http_request") 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"], []) + + 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"], []) + 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, []) + + 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", :lines => ["cause 1", "cause 2"] }, + { :name => "KeyError", :message => "missing", :lines => ["cause 3"] } + ] + backend.set_error("RuntimeError", "boom", ["line 1"], causes) + + 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", :lines => nil }] + ) + + 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"], []) + + 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"], []) + + 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(0) + backend.set_error("RuntimeError", "boom", ["line 1"], []) + backend.finish_event("sql.query", "title", "body", Appsignal::EventFormatter::DEFAULT, 0) + backend.complete + + event_span = span_exporter.finished_spans.find { |s| s.name == "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"], []) + backend.set_error("ArgumentError", "second", ["line 2"], []) + backend.complete + + backend_span_id = backend.instance_variable_get(:@span).context.span_id + root = span_exporter.finished_spans.find { |s| s.span_id == backend_span_id } + events = root.events.select { |e| e.name == "exception" } + expect(events.map { |e| e.attributes["exception.type"] }) + .to eq(["RuntimeError", "ArgumentError"]) + expect(events.map { |e| e.attributes["exception.message"] }).to eq(["first", "second"]) + end + end + + describe "#supports_multiple_errors?" do + it "returns true (multiple exception events on one span)" do + expect(create_backend.supports_multiple_errors?).to eq(true) + end + end + describe "#finish" do it "returns false so Transaction#complete does not run the sample_data path" do expect(create_backend.finish(0)).to eq(false) @@ -267,6 +381,7 @@ def new_transaction_with_otel_backend(namespace = Appsignal::Transaction::HTTP_R 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 diff --git a/spec/lib/appsignal/transaction_spec.rb b/spec/lib/appsignal/transaction_spec.rb index 5d686b255..bc8f63fe4 100644 --- a/spec/lib/appsignal/transaction_spec.rb +++ b/spec/lib/appsignal/transaction_spec.rb @@ -1982,6 +1982,151 @@ def to_s end end + describe "recording the error on the span" do + def perform + transaction.add_error(error) + end + + it "in agent mode", :agent_mode do + perform + + expect(transaction).to have_error( + "ExampleStandardError", + "test message", + ["line 1"] + ) + end + + it "in collector mode", :collector_mode do + perform + transaction.complete + + event = root_span.events.find { |e| e.name == "exception" } + expect(event).not_to be_nil + expect(event.attributes["exception.type"]).to eq("ExampleStandardError") + expect(event.attributes["exception.message"]).to eq("test message") + expect(event.attributes["exception.stacktrace"]).to eq("line 1") + expect(event.attributes).not_to have_key("appsignal.error_causes") + expect(root_span.status.code).to eq(::OpenTelemetry::Trace::Status::ERROR) + end + end + + describe "recording an error that has causes" do + let(:error) do + cause = ExampleStandardError.new("cause message").tap do |e| + e.set_backtrace(["/path/cause.rb:1:in `cause_method'"]) + end + ExampleException.new("wrapper message").tap do |e| + e.set_backtrace(["/path/wrapper.rb:2:in `wrapper_method'"]) + allow(e).to receive(:cause).and_return(cause) + end + end + + def perform + # Hide Rails so the backtrace isn't run through its cleaner, keeping the + # asserted lines deterministic (mirrors the error-causes sample-data spec). + hide_const("Rails") + transaction.add_error(error) + end + + it "in agent mode", :agent_mode do + 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 + perform + transaction.complete + + event = root_span.events.find { |e| e.name == "exception" } + expect(event.attributes["exception.type"]).to eq("ExampleException") + # `appsignal.error_causes` matches the processor's ErrorSubCause shape: + # name / message / lines (full cleaned backtrace per cause). + expect(JSON.parse(event.attributes["appsignal.error_causes"])).to eq( + [ + { + "name" => "ExampleStandardError", + "message" => "cause message", + "lines" => ["/path/cause.rb:1:in `cause_method'"] + } + ] + ) + end + end + + describe "recording multiple errors" do + let(:other_error) do + ExampleStandardError.new("other message").tap { |e| e.set_backtrace(["line 2"]) } + end + + def perform + transaction.add_error(error) + transaction.add_error(other_error) + end + + it "in agent mode", :agent_mode do + 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 "in collector mode", :collector_mode do + 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.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 + 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.name == "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 + 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| @@ -2666,7 +2811,7 @@ 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 block = proc do |transaction, error| transaction.set_action(error.message) end @@ -2691,6 +2836,25 @@ def to_s have_action("hook_error_second") ) end + + it "calls the hook once with the first error in collector mode", :collector_mode do + 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 From cb004f48dfa7df8c0908f6446185f5c376a8958b Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Thu, 4 Jun 2026 17:32:48 +0200 Subject: [PATCH 031/151] Match collector-mode assertion depth to agent mode Close shallow- and missing-assertion gaps in the dual-mode event specs. Collector twins now assert the emitted span/log count, the parent-span linkage on the abnormal-exit paths, and the body/title attributes their agent twins already checked. Pin the MongoDB subscriber's white-box unit tests to agent mode: they assert C-extension mechanics that don't apply to the OTel backend, whose output is covered separately in both modes. --- .../finish_with_state_shared_examples.rb | 1 + .../instrument_shared_examples.rb | 8 ++ .../start_finish_shared_examples.rb | 2 + spec/lib/appsignal/hooks/sequel_spec.rb | 1 + .../active_support_event_reporter_spec.rb | 1 + .../integrations/mongo_ruby_driver_spec.rb | 77 +++++++++---------- .../appsignal/integrations/net_http_spec.rb | 6 +- 7 files changed, 55 insertions(+), 41 deletions(-) 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 bfc2c0636..343fb131c 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 @@ -31,6 +31,7 @@ def perform 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) 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 075b0ad11..e9de62a68 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 @@ -27,6 +27,7 @@ def perform 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) @@ -65,6 +66,7 @@ def perform 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) @@ -104,6 +106,7 @@ def perform perform Appsignal::Transaction.complete_current! + expect(event_spans.size).to eq(1) expect(event_spans.map(&:name)).to include("not_a_string") end end @@ -167,8 +170,10 @@ def perform 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) expect(span.attributes["db.query.text"]).to eq("SQL") expect(span.attributes["db.system.name"]).to eq("other_sql") end @@ -205,9 +210,12 @@ def perform 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) expect(span.attributes["db.query.text"]).to eq("SQL") + expect(span.attributes["db.system.name"]).to eq("other_sql") 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 a4faff8bc..f4234de65 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 @@ -31,6 +31,7 @@ def perform 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) @@ -71,6 +72,7 @@ def perform 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) diff --git a/spec/lib/appsignal/hooks/sequel_spec.rb b/spec/lib/appsignal/hooks/sequel_spec.rb index ef0dd78e7..ebbda8350 100644 --- a/spec/lib/appsignal/hooks/sequel_spec.rb +++ b/spec/lib/appsignal/hooks/sequel_spec.rb @@ -46,6 +46,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).not_to have_key("appsignal.title") 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 220ca4ddd..6f87178ac 100644 --- a/spec/lib/appsignal/integrations/active_support_event_reporter_spec.rb +++ b/spec/lib/appsignal/integrations/active_support_event_reporter_spec.rb @@ -25,6 +25,7 @@ it "in collector mode", :collector_mode do subscriber.emit(event) + expect(log_records.size).to eq(1) record = log_records.first expect(record).not_to be_nil expect(record.body).to eq("user.created") diff --git a/spec/lib/appsignal/integrations/mongo_ruby_driver_spec.rb b/spec/lib/appsignal/integrations/mongo_ruby_driver_spec.rb index c242567ad..5f4dee8b5 100644 --- a/spec/lib/appsignal/integrations/mongo_ruby_driver_spec.rb +++ b/spec/lib/appsignal/integrations/mongo_ruby_driver_spec.rb @@ -1,20 +1,15 @@ require "appsignal/integrations/mongo_ruby_driver" -describe Appsignal::Hooks::MongoMonitorSubscriber do - if DependencyHelper.mongo_present? - let(:subscriber) { Appsignal::Hooks::MongoMonitorSubscriber.new } - let(:address) { Mongo::Address.new("127.0.0.1:27017") } - - # Build real `Mongo::Monitoring::Event` objects so the subscriber is - # exercised against the driver's actual event API rather than doubles. The - # constructors don't open a connection, so no MongoDB server is needed. - def command_started_event( - request_id: 1, command_name: "find", - command: { "foo" => "bar" }, database_name: "test" - ) - Mongo::Monitoring::Event::CommandStarted.new( - command_name, database_name, address, request_id, 1, command - ) + # White-box unit tests of the subscriber's interaction with the Transaction + # API and the C-extension. Pinned to :agent_mode: they assert extension + # mechanics (`start_event`/`finish_event`) that only apply to the agent + # backend; the OTel-backed transaction output is covered by "instrumenting a + # finished query" below in both modes. `start_agent` comes from the mode + # context, so it is not started here. + context "with transaction", :agent_mode do + let(:transaction) { http_request_transaction } + before do + set_current_transaction(transaction) end def command_succeeded_event( @@ -179,28 +174,10 @@ def perform expect(snapshot.data_points.first.attributes).to eq("database" => "test") 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) } - - def perform - subscriber.started(started_event) - subscriber.failed(failed_event) - end - - it "records the query as an event" 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 + context "without transaction", :agent_mode do + before do + allow(Appsignal::Transaction).to receive(:current) + .and_return(Appsignal::Transaction::NilTransaction.new) end # The subscriber guards on a current, unpaused transaction before touching @@ -239,8 +216,30 @@ def perform expect(Appsignal::Extension).to_not receive(:finish_event) expect(Appsignal).to_not receive(:add_distribution_value) - perform - end + subscriber.finish("SUCCEEDED", double) + end + end + + context "when appsignal is paused", :agent_mode do + let(:transaction) { double(:paused? => true, :nil_transaction? => false) } + before { allow(Appsignal::Transaction).to receive(:current).and_return(transaction) } + + it "should not attempt to start an event" do + expect(Appsignal::Extension).to_not receive(:start_event) + + subscriber.started(double) + end + + it "should not attempt to finish an event" do + expect(Appsignal::Extension).to_not receive(:finish_event) + + subscriber.finish("SUCCEEDED", double) + end + + it "should not attempt to send duration metrics" do + expect(Appsignal).to_not receive(:add_distribution_value) + + subscriber.finish("SUCCEEDED", double) end end end diff --git a/spec/lib/appsignal/integrations/net_http_spec.rb b/spec/lib/appsignal/integrations/net_http_spec.rb index d636a33fc..4db6f806c 100644 --- a/spec/lib/appsignal/integrations/net_http_spec.rb +++ b/spec/lib/appsignal/integrations/net_http_spec.rb @@ -15,7 +15,8 @@ def perform expect(transaction).to include_event( "name" => "request.net_http", - "title" => "GET http://www.google.com" + "title" => "GET http://www.google.com", + "body" => "" ) end @@ -51,7 +52,8 @@ def perform expect(transaction).to include_event( "name" => "request.net_http", - "title" => "GET https://www.google.com" + "title" => "GET https://www.google.com", + "body" => "" ) end From 67938660302bd1cc27d0220e34f802a0c9b0a3bb Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Thu, 4 Jun 2026 17:36:21 +0200 Subject: [PATCH 032/151] Cover the full Sidekiq probe metrics in collector The collector example only sampled three gauges; expand it to assert every gauge the probe emits, with values and attributes, matching the agent-mode expectations. Add the same coverage to the Sidekiq 6 path, which had none, and dual-mode the nil-value suppression and configured hostname cases. Shared helpers keep the two paths in sync. --- spec/lib/appsignal/probes/sidekiq_spec.rb | 206 ++++++++++++++-------- 1 file changed, 130 insertions(+), 76 deletions(-) diff --git a/spec/lib/appsignal/probes/sidekiq_spec.rb b/spec/lib/appsignal/probes/sidekiq_spec.rb index a4cb7cea3..a2c2def9b 100644 --- a/spec/lib/appsignal/probes/sidekiq_spec.rb +++ b/spec/lib/appsignal/probes/sidekiq_spec.rb @@ -244,73 +244,48 @@ def perform end it "in agent mode", :agent_mode 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 + expect_all_custom_gauges perform end it "in collector mode", :collector_mode do perform - - # The agent has no in-memory metric readout, so collector mode asserts - # that representative gauges reach the OpenTelemetry backend: a plain - # gauge, a delta-based gauge carrying a status tag, and a per-queue - # gauge. The agent-mode example above covers the full set of values. - # Pull the snapshots once: each `metric_snapshots` call resets the - # in-memory reader, so a second pull would come back empty. - snapshots = metric_snapshots - - worker_count = snapshots.find { |snapshot| snapshot.name == "sidekiq_worker_count" } - expect(worker_count).not_to be_nil - expect(worker_count.instrument_kind).to eq(:gauge) - expect(worker_count.data_points.first.value).to eq(24) - expect(worker_count.data_points.first.attributes).to eq("hostname" => "localhost") - - job_count = snapshots.find { |snapshot| snapshot.name == "sidekiq_job_count" } - processed = job_count.data_points.find do |point| - point.attributes["status"] == "processed" - end - expect(processed.value).to eq(5) - - queue_length = snapshots.find { |snapshot| snapshot.name == "sidekiq_queue_length" } - default_queue = queue_length.data_points.find do |point| - point.attributes["queue"] == "default" - end - expect(default_queue.value).to eq(10) + 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", :agent_mode 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 + 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 + 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 - context "with Sidekiq 6", :agent_mode do + 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 log = capture_logs { probe.call } expect(log).to contains_log( :debug, @@ -321,25 +296,22 @@ def perform 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 + expect_all_custom_gauges + perform + end + + it "in collector mode", :collector_mode do + perform + expect_all_custom_gauge_snapshots + end end context "when Sidekiq `redis_info` is not defined" do @@ -347,20 +319,30 @@ def perform 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 + 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 + 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 - context "when hostname is configured for probe", :agent_mode do + context "when hostname is configured for probe" do 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 with_sidekiq! allow(Appsignal).to receive(:set_gauge).and_call_original @@ -377,6 +359,16 @@ def perform 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 + 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 = {}) @@ -384,5 +376,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 a15ac3848cad6ed3e98020e1884bae8c8cd23921 Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Thu, 4 Jun 2026 17:46:01 +0200 Subject: [PATCH 033/151] Port more probe/hook metrics to collector mode Only a single gauge per probe had collector coverage. Port the rest of the deterministic metrics the MRI and GVL probes emit, the ActiveJob failed/priority counters and queue-time distribution, and the 500 path of the response-status counter. Each asserts the value and attributes its agent-mode twin already checked. The ActiveJob queue-time case is the first collector-mode coverage of a distribution (histogram) metric anywhere in the suite. --- spec/lib/appsignal/hooks/activejob_spec.rb | 137 ++++++++ spec/lib/appsignal/probes/gvl_spec.rb | 280 +++++++++++----- spec/lib/appsignal/probes/mri_spec.rb | 305 +++++++++++++----- spec/lib/appsignal/rack/event_handler_spec.rb | 65 +++- 4 files changed, 615 insertions(+), 172 deletions(-) diff --git a/spec/lib/appsignal/hooks/activejob_spec.rb b/spec/lib/appsignal/hooks/activejob_spec.rb index 5b79259e8..27e6c943c 100644 --- a/spec/lib/appsignal/hooks/activejob_spec.rb +++ b/spec/lib/appsignal/hooks/activejob_spec.rb @@ -729,4 +729,141 @@ def perform ) 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 + 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 + 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 + 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 + 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 + 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 + 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/probes/gvl_spec.rb b/spec/lib/appsignal/probes/gvl_spec.rb index 74805bae5..968ba28b4 100644 --- a/spec/lib/appsignal/probes/gvl_spec.rb +++ b/spec/lib/appsignal/probes/gvl_spec.rb @@ -21,6 +21,39 @@ def gauges_for(metric) 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, :gvl_tools => FakeGVLTools) + end + + # 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 + + 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 + after { FakeGVLTools.reset } describe "the global timer delta gauge" do @@ -47,41 +80,61 @@ def perform(probe) end it "in collector mode", :collector_mode do - # Inject the real Appsignal so `set_gauge` routes through the OTel - # metrics backend instead of the in-memory AppsignalMock. - perform(described_class.new(:appsignal => Appsignal, :gvl_tools => FakeGVLTools)) + perform(collector_probe) - snapshot = metric_snapshot("gvl_global_timer") - expect(snapshot).not_to be_nil - expect(snapshot.instrument_kind).to eq(:gauge) - expect(snapshot.data_points.map(&:value)).to all(eq(200)) + # 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 context "when the delta is negative" do - it "does not gauge the global timer delta" do - FakeGVLTools::GlobalTimer.monotonic_time = 300_000_000 - probe.call - - expect(gauges_for("gvl_global_timer")).to be_empty - - FakeGVLTools::GlobalTimer.monotonic_time = 0 - probe.call - - expect(gauges_for("gvl_global_timer")).to be_empty + 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 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 - - expect(gauges_for("gvl_global_timer")).to be_empty - - probe.call - - expect(gauges_for("gvl_global_timer")).to be_empty + 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 @@ -90,18 +143,30 @@ def perform(probe) 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 + 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 + perform(collector_probe) + + expect_dual_gauge_points("gvl_waiting_threads", 3, :process_name => "rspec") + end end end @@ -110,71 +175,130 @@ def perform(probe) 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 + + it "in agent mode", :agent_mode do + start_agent + perform(probe) - expect(gauges_for("gvl_waiting_threads")).to be_empty + 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 0e90b8b02..5d20b40b1 100644 --- a/spec/lib/appsignal/probes/mri_spec.rb +++ b/spec/lib/appsignal/probes/mri_spec.rb @@ -25,14 +25,35 @@ 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 + + describe "the vm cache gauges" do + def perform(probe) + probe.call + end + + it "in agent mode", :agent_mode do + 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 + 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 @@ -47,9 +68,7 @@ def perform(probe) end it "in collector mode", :collector_mode do - # Inject the real Appsignal so `set_gauge` routes through the OTel - # metrics backend instead of the in-memory AppsignalMock. - perform(described_class.new(:appsignal => Appsignal, :gc_profiler => gc_profiler_mock)) + perform(collector_probe) snapshot = metric_snapshot("thread_count") expect(snapshot).not_to be_nil @@ -60,100 +79,232 @@ def perform(probe) 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 + perform(probe) + expect_gauge_value("gc_time", 5) + end + + it "in collector mode", :collector_mode do + 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 + 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 + 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 + 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 + 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 + perform(probe) + expect_gauge_value("allocated_objects", 5) + end + + it "in collector mode", :collector_mode do + 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 + 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 + 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/rack/event_handler_spec.rb b/spec/lib/appsignal/rack/event_handler_spec.rb index 93939e039..8fb7af609 100644 --- a/spec/lib/appsignal/rack/event_handler_spec.rb +++ b/spec/lib/appsignal/rack/event_handler_spec.rb @@ -498,27 +498,58 @@ def on_finish(given_request = request, given_response = response) end end - def perform - event_handler_instance.on_start(request, response) - event_handler_instance.on_finish(request, response) - 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 + expect(Appsignal).to receive(:increment_counter) + .with(:response_status, 1, :status => 200, :namespace => :web) - it "in agent mode", :agent_mode do - expect(Appsignal).to receive(:increment_counter) - .with(:response_status, 1, :status => 200, :namespace => :web) + perform + end + + it "in collector mode", :collector_mode do + perform - 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 - it "in collector mode", :collector_mode do - perform + 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 + expect(Appsignal).to receive(:increment_counter) + .with(:response_status, 1, :status => 500, :namespace => :web) + + perform + end - 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" - ) + it "in collector mode", :collector_mode do + 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 From b34af6743fcb7651a62319685405b7bc26ea319b Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Thu, 4 Jun 2026 17:51:31 +0200 Subject: [PATCH 034/151] Cover transaction errors in collector mode The error-message sanitization, backtrace cleaning, and error-cause sections were asserted in agent mode only. Add collector-mode twins that read the exception span-event: sanitized message, cleaned stacktrace, and the appsignal.error_causes attribute (which carries the full backtrace per cause, unlike the agent's first-line projection). --- spec/lib/appsignal/transaction_spec.rb | 185 ++++++++++++++++++++++--- 1 file changed, 163 insertions(+), 22 deletions(-) diff --git a/spec/lib/appsignal/transaction_spec.rb b/spec/lib/appsignal/transaction_spec.rb index bc8f63fe4..b3c712d1d 100644 --- a/spec/lib/appsignal/transaction_spec.rb +++ b/spec/lib/appsignal/transaction_spec.rb @@ -2246,17 +2246,29 @@ def perform "\"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 + perform + + expect(transaction).to have_error("PG::UniqueViolation", sanitized_message) + end + + it "records a sanitized error message in collector mode", :collector_mode do + 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 @@ -2267,26 +2279,42 @@ def perform "\"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 + perform + + expect(transaction).to have_error("ActiveRecord::RecordNotUnique", sanitized_message) + end + + it "records a sanitized error message in collector mode", :collector_mode do + 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 + perform expect(last_transaction).to have_error( "ExampleStandardError", @@ -2294,6 +2322,14 @@ def perform ["line 1", "line 2"] ) end + + it "records the backtrace uncleaned in collector mode", :collector_mode do + perform + transaction.complete + + event = exception_event + expect(event.attributes["exception.stacktrace"]).to eq("line 1\nline 2") + end end if rails_present? @@ -2312,18 +2348,38 @@ def perform ::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 + 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 + 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 @@ -2335,6 +2391,11 @@ def perform 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 @@ -2358,11 +2419,18 @@ def perform 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 transaction.send(:_set_error, error) expect(transaction).to include_error_causes([]) end + + it "sets no error causes attribute in collector mode", :collector_mode do + 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 @@ -2400,7 +2468,7 @@ def perform 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 # Hide Rails so we can test the normal Ruby behavior. The Rails # behavior is tested in another spec. hide_const("Rails") @@ -2451,6 +2519,44 @@ def perform ) 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 + 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) @@ -2622,7 +2728,7 @@ def perform e end - it "sends only the first causes as sample data" do + it "sends only the first causes as sample data", :agent_mode do expected_error_causes = Array.new(10) do |i| { @@ -2648,6 +2754,32 @@ def perform "will be reported." ) end + + it "records only the first causes on the exception event in collector mode", + :collector_mode do + 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 @@ -2662,7 +2794,7 @@ def perform 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 transaction.send(:_set_error, error) expect(transaction).to have_error( @@ -2671,6 +2803,15 @@ def perform ["line 1"] ) end + + it "records an empty error message on the exception event in collector mode", + :collector_mode do + 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 From 2de60d9e778e9fb41f37ee40e02af15e8721d9b0 Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Thu, 4 Jun 2026 17:55:54 +0200 Subject: [PATCH 035/151] Tidy dead collector context and a logger invariant Remove an orphaned "in collector mode" context in the Appsignal spec that held no examples, so its setup never ran and only obscured the shared collector context the real examples use. Run the logger format validation, a backend-independent invariant, in both modes. --- spec/lib/appsignal/logger_spec.rb | 36 ++++++++++++++++++------------- spec/lib/appsignal_spec.rb | 32 --------------------------- 2 files changed, 21 insertions(+), 47 deletions(-) diff --git a/spec/lib/appsignal/logger_spec.rb b/spec/lib/appsignal/logger_spec.rb index 423f564c1..20b8fe1b0 100644 --- a/spec/lib/appsignal/logger_spec.rb +++ b/spec/lib/appsignal/logger_spec.rb @@ -461,25 +461,31 @@ def perform end describe "format validation" do - it "accepts the documented format constants" 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) + # 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 - it "warns and falls back to AUTODETECT for an unknown format" do - expect(Appsignal.internal_logger).to receive(:warn) - .with(/Unknown Appsignal::Logger format 99; falling back to AUTODETECT/) + 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) + logger = Appsignal::Logger.new("group", :format => 99) + expect(logger.instance_variable_get(:@format)).to eq(Appsignal::Logger::AUTODETECT) + end end end diff --git a/spec/lib/appsignal_spec.rb b/spec/lib/appsignal_spec.rb index 016d9144b..c057c96a1 100644 --- a/spec/lib/appsignal_spec.rb +++ b/spec/lib/appsignal_spec.rb @@ -2003,38 +2003,6 @@ def on_start end end - context "in collector mode", :if => DependencyHelper.opentelemetry_present? do - require "opentelemetry/sdk" if DependencyHelper.opentelemetry_present? - - 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 - start_agent(:options => { :collector_endpoint => "http://127.0.0.1:9090" }) - ::OpenTelemetry.tracer_provider = tracer_provider - end - - # complete_current! clears both the Transaction thread-local AND the - # OTel context (the default clear_current_transaction! only clears - # the thread-local, which would leave the OTel context attached and - # leak into the next test). - after { Appsignal::Transaction.complete_current! } - - 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 - end - describe "custom metrics" do let(:tags) { { :foo => "bar" } } From 96fdf52c2943ee4e7a96699bed714e80dc791e51 Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Thu, 4 Jun 2026 18:06:05 +0200 Subject: [PATCH 036/151] Cover error-reporting helpers in collector mode send_error, set_error and report_error were asserted in agent mode only. Add a top-level dual-mode describe covering the core behaviour in both modes: the new trace and exception event, block-set action and namespace on the root span, recording onto the active transaction, and multiple reported errors collapsing onto one trace. Kept at the top level, like the instrument and custom-metric specs, so start_agent comes from the mode contexts rather than the clobbering "with config and started" hook. Tag assertions wait on set_sample_data; the agent-specific details stay in the existing agent-mode describes. --- spec/lib/appsignal_spec.rb | 195 +++++++++++++++++++++++++++++++++++++ 1 file changed, 195 insertions(+) diff --git a/spec/lib/appsignal_spec.rb b/spec/lib/appsignal_spec.rb index c057c96a1..e98653f8f 100644 --- a/spec/lib/appsignal_spec.rb +++ b/spec/lib/appsignal_spec.rb @@ -2003,6 +2003,201 @@ def on_start end end + # Dual-mode coverage of the error-reporting helpers. A separate top-level + # describe, like `custom metrics` and `.instrument` below: `start_agent` comes + # from the mode contexts, so it avoids the `before { start_agent }` in the + # `with config and started` context above (which clobbers collector mode). The + # agent-specific details (tag flushing, the duplicate-transaction model, + # validation/logging) stay in the agent-mode describes in that context. + describe "error reporting" do + around { |example| keep_transactions { example.run } } + + # The completed root span's sole `exception` span-event. + def exception_event + root_span.events.find { |event| event.name == "exception" } + end + + describe ".send_error" do + let(:error) { ExampleException.new("error message") } + + def perform + Appsignal.send_error(error) + end + + it "in agent mode", :agent_mode do + 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 be_completed + end + + it "in collector mode", :collector_mode do + # 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(Appsignal::Transaction::HTTP_REQUEST) + expect(root_span.attributes).not_to have_key("appsignal.action_name") + expect(exception_event.attributes["exception.type"]).to eq("ExampleException") + expect(exception_event.attributes["exception.message"]).to eq("error message") + end + + describe "with a block setting metadata" do + def perform + Appsignal.send_error(error) do |transaction| + transaction.set_action("my_action") + transaction.set_namespace("my_namespace") + end + end + + it "in agent mode", :agent_mode do + perform + + expect(last_transaction).to have_namespace("my_namespace") + expect(last_transaction).to have_action("my_action") + expect(last_transaction).to have_error("ExampleException", "error message") + end + + it "in collector mode", :collector_mode do + 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(exception_event.attributes["exception.type"]).to eq("ExampleException") + end + end + end + + describe ".set_error" do + let(:error) { ExampleException.new("I am an exception") } + let(:transaction) { http_request_transaction } + + # `transaction` (and so its root span) is built here inside the example + # action, not in a `before`, so it uses the in-memory tracer provider the + # collector-mode context swaps in. + def perform + set_current_transaction(transaction) + Appsignal.set_error(error) + end + + it "in agent mode", :agent_mode do + perform + + transaction._sample + expect(transaction).to have_namespace(Appsignal::Transaction::HTTP_REQUEST) + expect(transaction).to have_error("ExampleException", "I am an exception") + end + + it "in collector mode", :collector_mode do + perform + transaction.complete + + expect(exception_event.attributes["exception.type"]).to eq("ExampleException") + expect(exception_event.attributes["exception.message"]).to eq("I am an exception") + end + end + + describe ".report_error" do + let(:error) { ExampleException.new("error message") } + + context "without an active transaction" do + def perform + Appsignal.report_error(error) + end + + it "in agent mode", :agent_mode do + expect { perform }.to(change { created_transactions.count }.by(1)) + + expect(last_transaction).to have_error("ExampleException", "error message") + expect(last_transaction).to be_completed + end + + it "in collector mode", :collector_mode do + # 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 + expect(exception_event.attributes["exception.type"]).to eq("ExampleException") + expect(exception_event.attributes["exception.message"]).to eq("error message") + end + end + + context "with an active transaction" do + let(:transaction) { http_request_transaction } + + # Built inside the action, not a `before`, so the root span uses the + # in-memory tracer provider the collector-mode context swaps in. + def perform + set_current_transaction(transaction) + Appsignal.report_error(error) + end + + it "in agent mode", :agent_mode do + perform + + expect(last_transaction).to eq(transaction) + transaction._sample + expect(transaction).to have_error("ExampleException", "error message") + end + + it "in collector mode", :collector_mode do + perform + transaction.complete + + expect(exception_event.attributes["exception.type"]).to eq("ExampleException") + expect(exception_event.attributes["exception.message"]).to eq("error message") + end + + context "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 + 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 do |t| + t.to_h["error"]["message"] + end).to contain_exactly("error message", "other message") + end + + it "in collector mode", :collector_mode do + 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 + end + end + end + describe "custom metrics" do let(:tags) { { :foo => "bar" } } From f68e3661fe070e33a57b758065327512b4914e4e Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Thu, 4 Jun 2026 18:08:30 +0200 Subject: [PATCH 037/151] Add dual-mode test infra for the unblocked specs Three small additions to the mode-test helpers so the error/event specs can run in both agent and collector mode: - A start_agent_args hook on both mode contexts, so examples can pass a custom :env or :options (the collector context still injects the collector_endpoint). Guarded with defined? so an example group's own let wins over the included context. - An exception_events helper on the collector context that returns the OpenTelemetry exception events across all finished spans. - it_in_both_modes now takes an optional description. --- spec/support/helpers/mode_helpers.rb | 8 +++++--- spec/support/shared_contexts/agent_mode.rb | 7 ++++++- spec/support/shared_contexts/collector_mode.rb | 18 +++++++++++++++++- 3 files changed, 28 insertions(+), 5 deletions(-) diff --git a/spec/support/helpers/mode_helpers.rb b/spec/support/helpers/mode_helpers.rb index 17091e9cb..623aeea52 100644 --- a/spec/support/helpers/mode_helpers.rb +++ b/spec/support/helpers/mode_helpers.rb @@ -1,9 +1,11 @@ # frozen_string_literal: true module ModeHelpers - def it_in_both_modes(&block) - it("in agent mode", :agent_mode, &block) - it("in collector mode", :collector_mode, &block) + # Defines the same example in both agent and collector mode. Pass an optional + # description; it is suffixed with " in agent mode" / " in collector mode". + def it_in_both_modes(description = nil, &block) + it([description, "in agent mode"].compact.join(" "), :agent_mode, &block) + it([description, "in collector mode"].compact.join(" "), :collector_mode, &block) end end diff --git a/spec/support/shared_contexts/agent_mode.rb b/spec/support/shared_contexts/agent_mode.rb index dc55a2f99..bb92492d5 100644 --- a/spec/support/shared_contexts/agent_mode.rb +++ b/spec/support/shared_contexts/agent_mode.rb @@ -1,7 +1,12 @@ # frozen_string_literal: true RSpec.shared_context "agent mode", :agent_mode do - before { start_agent } + # Examples can define a `start_agent_args` `let` to pass `:env`/`:options` to + # `start_agent` (the collector-mode context accepts the same hook and also + # injects the `collector_endpoint`). Guarded with `defined?` rather than a + # default `let` here, because an included shared context's `let` would take + # precedence over the example group's own `let` override. + before { start_agent(**(defined?(start_agent_args) ? start_agent_args : {})) } # Make completed transactions readable via `to_h` so agent-mode tests can # assert on `include_event` / `include_tags` etc. after the transaction diff --git a/spec/support/shared_contexts/collector_mode.rb b/spec/support/shared_contexts/collector_mode.rb index 40f1e9fe9..52c3e9716 100644 --- a/spec/support/shared_contexts/collector_mode.rb +++ b/spec/support/shared_contexts/collector_mode.rb @@ -37,8 +37,15 @@ provider end + # 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. before do - start_agent(:options => { :collector_endpoint => "http://127.0.0.1:9090" }) + args = (defined?(start_agent_args) ? start_agent_args : {}).dup + args[:options] = { :collector_endpoint => "http://127.0.0.1:9090" }.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 @@ -79,6 +86,15 @@ 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 From e5800eb20761e4183f85397afa503015d912c432 Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Thu, 4 Jun 2026 18:16:58 +0200 Subject: [PATCH 038/151] Dual-mode the Sinatra instrumentation spec Run every Sinatra middleware example in both agent and collector mode: invariants (request counting, settings, raise_errors_on, inactive env) via it_in_both_modes, and the transaction-output examples (namespace, action, process_action.sinatra event, error reported/not reported) as agent/collector pairs asserting on to_h and the in-memory spans respectively. --- .../rack/sinatra_instrumentation_spec.rb | 222 ++++++++++++++---- 1 file changed, 174 insertions(+), 48 deletions(-) diff --git a/spec/lib/appsignal/rack/sinatra_instrumentation_spec.rb b/spec/lib/appsignal/rack/sinatra_instrumentation_spec.rb index 165317ee5..6a6e88aec 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,44 @@ 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 + 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 + expect { perform }.to(change { created_transactions.count }.by(1)) + + expect(root_span.attributes["appsignal.namespace"]) + .to eq(Appsignal::Transaction::HTTP_REQUEST) + 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 + 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 + 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 +149,72 @@ 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 + expect { perform }.to(change { created_transactions.count }.by(1)) - expect(last_transaction).to have_namespace(Appsignal::Transaction::HTTP_REQUEST) + expect(last_transaction).to have_namespace(Appsignal::Transaction::HTTP_REQUEST) + end + + it "in collector mode", :collector_mode do + expect { perform }.to(change { created_transactions.count }.by(1)) + + expect(root_span.attributes["appsignal.namespace"]) + .to eq(Appsignal::Transaction::HTTP_REQUEST) + 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 + + it "in agent mode", :agent_mode do + 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 + 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 + perform + + expect(last_transaction).to_not have_error + end + + it "in collector mode", :collector_mode do + perform - expect(last_transaction).to_not have_error + expect(exception_events).to be_empty + end end end @@ -162,19 +226,44 @@ 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 + perform - expect(last_transaction).to_not have_error + expect(last_transaction).to_not have_error + end + + it "in collector mode", :collector_mode do + 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 + perform + + expect(last_transaction).to have_action("GET /path") + end + + it "in collector mode", :collector_mode do + 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 +271,45 @@ 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 + perform + + expect(last_transaction).to_not have_action + end + + it "in collector mode", :collector_mode do + 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 + 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 + 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 +317,22 @@ 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 + perform + + expect(last_transaction).to_not have_action + end + + it "in collector mode", :collector_mode do + perform + + expect(root_span.attributes).to_not have_key("appsignal.action_name") + end end end end From 74e68f721754f02a2a7303deb7d9c0ef51b85226 Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Thu, 4 Jun 2026 18:19:04 +0200 Subject: [PATCH 039/151] Dual-mode the Rake hook spec Run the Rake instrumentation examples in both modes: invariants (transaction counting, original-task return, at_exit registration, the helper's register-once/stop behaviour) via it_in_both_modes, and the transaction-output examples (namespace, action, task.rake event, error reported/not reported) as agent/collector pairs. Params assertions stay agent-only -- set_sample_data is not yet implemented in the OpenTelemetry backend. --- spec/lib/appsignal/hooks/rake_spec.rb | 149 +++++++++++++++++--------- 1 file changed, 100 insertions(+), 49 deletions(-) diff --git a/spec/lib/appsignal/hooks/rake_spec.rb b/spec/lib/appsignal/hooks/rake_spec.rb index 2c1360396..6ad4bb259 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,39 @@ 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 + 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 + 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 +99,40 @@ 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 + 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 + 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 +140,9 @@ 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 perform expect(last_transaction).to_not include_params @@ -117,22 +153,36 @@ 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 + 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 + 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 + 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 + perform + + expect(exception_events).to be_empty + end end end end @@ -142,12 +192,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 +211,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 From 8e95bb7b869ccb8e23c405b8c01ff44cc83e1eca Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Thu, 4 Jun 2026 18:25:39 +0200 Subject: [PATCH 040/151] Dual-mode the at_exit hook spec Run the at_exit reporter examples in both modes: the hook-installation, dedup, SystemExit/SignalException skip, and Appsignal.stop behaviours via it_in_both_modes, and the unhandled-error report (namespace + error) as an agent/collector pair. --- spec/lib/appsignal/hooks/at_exit_spec.rb | 58 ++++++++++++++++-------- 1 file changed, 38 insertions(+), 20 deletions(-) diff --git a/spec/lib/appsignal/hooks/at_exit_spec.rb b/spec/lib/appsignal/hooks/at_exit_spec.rb index 9d4b11cd6..1f2e8d6fd 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,38 @@ 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 + 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 + 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 +103,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 +115,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 +136,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 +148,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 +166,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 +175,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 From 99b51890f5d46b3eddd8f6c805df51b9e8b6249f Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Thu, 4 Jun 2026 18:25:39 +0200 Subject: [PATCH 041/151] Dual-mode the Webmachine integration spec Run the Webmachine examples in both modes: transaction counting and completion via it_in_both_modes, and action/event/error as agent/collector pairs. The parent-transaction examples create the transaction inside the example so it gets the OpenTelemetry backend, and finish it to export the span. Params and headers stay agent-only -- set_sample_data is not yet implemented in the OpenTelemetry backend. --- .../appsignal/integrations/webmachine_spec.rb | 186 ++++++++++++++---- 1 file changed, 149 insertions(+), 37 deletions(-) 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 From a6025bba90ea649d714533fc1c1f7d1302a850a0 Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Thu, 4 Jun 2026 18:37:13 +0200 Subject: [PATCH 042/151] Dual-mode the Rack BodyWrapper spec Run every BodyWrapper example in both modes: the wrapper-shape and forwarding mechanics via it_in_both_modes, and the instrumentation-event and error-reported/not-reported examples (including the EPIPE/ECONNRESET and error-cause filtering variants) as agent/collector pairs. Collector examples finish the transaction to export its spans and assert on the recorded exception events / child event spans. --- spec/lib/appsignal/rack/body_wrapper_spec.rb | 1211 +++++++++++++----- 1 file changed, 858 insertions(+), 353 deletions(-) diff --git a/spec/lib/appsignal/rack/body_wrapper_spec.rb b/spec/lib/appsignal/rack/body_wrapper_spec.rb index 0a425cb3e..78efd29da 100644 --- a/spec/lib/appsignal/rack/body_wrapper_spec.rb +++ b/spec/lib/appsignal/rack/body_wrapper_spec.rb @@ -1,11 +1,45 @@ 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 + span = event_spans.find { |s| s.name == name } + expect(span).not_to be_nil + expect(span.parent_span_id).to eq(root_span.span_id) + expect(span.attributes["appsignal.title"]).to eq(title) if title + end + + def expect_collector_no_event(name) + transaction.complete + expect(event_spans.map(&:name)).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 +53,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 +65,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 +75,359 @@ 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 + perform - 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") + expect(transaction).to include_event( + "name" => "process_response_body.rack", + "title" => "Process Rack response body (#each)" + ) + end - 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") + it "in collector mode", :collector_mode do + 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 + perform + + expect(transaction).to_not include_event("name" => "process_response_body.rack") + end - expect(transaction).to have_error("ExampleException", "error message") + it "in collector mode", :collector_mode do + 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. + titleless_event = event_spans.find do |span| + span.name == "process_response_body.rack" && + span.attributes["appsignal.title"].to_s.empty? + 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 + perform - expect(transaction).to_not have_error + expect(transaction).to have_error("ExampleException", "error message") + end + + it "in collector mode", :collector_mode do + perform + + expect_collector_error("ExampleException", "error message") + end end - it "doesn't report ECONNRESET error" do - fake_body = double - expect(fake_body).to receive(:each).once.and_raise(Errno::ECONNRESET) + describe "doesn't report EPIPE error" do + def perform + fake_body = double + expect(fake_body).to receive(:each).once.and_raise(Errno::EPIPE) - wrapped = described_class.wrap(fake_body, transaction) - expect do - expect { |b| wrapped.each(&b) }.to yield_control - end.to raise_error(Errno::ECONNRESET) + wrapped = described_class.wrap(fake_body, transaction) + expect do + expect { |b| wrapped.each(&b) }.to yield_control + end.to raise_error(Errno::EPIPE) + end + + it "in agent mode", :agent_mode do + perform + expect(transaction).to_not have_error + end - expect(transaction).to_not have_error + it "in collector mode", :collector_mode do + perform + expect_collector_no_error + end end - it "does not report EPIPE error when it's the error cause" do - error = error_with_cause(StandardError, "error message", Errno::EPIPE) - fake_body = double - expect(fake_body).to receive(:each).once.and_raise(error) + describe "doesn't report ECONNRESET error" do + def perform + fake_body = double + expect(fake_body).to receive(:each).once.and_raise(Errno::ECONNRESET) - wrapped = described_class.wrap(fake_body, transaction) - expect do - expect { |b| wrapped.each(&b) }.to yield_control - end.to raise_error(StandardError, "error message") + wrapped = described_class.wrap(fake_body, transaction) + expect do + expect { |b| wrapped.each(&b) }.to yield_control + end.to raise_error(Errno::ECONNRESET) + end + + it "in agent mode", :agent_mode do + perform + expect(transaction).to_not have_error + end - expect(transaction).to_not have_error + it "in collector mode", :collector_mode do + 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 - expect(transaction).to_not have_error + it "in agent mode", :agent_mode do + perform + expect(transaction).to_not have_error + end + + it "in collector mode", :collector_mode do + 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 + perform + expect(transaction).to_not have_error + end - expect(transaction).to_not have_error + it "in collector mode", :collector_mode do + perform + expect_collector_no_error + end end - it "does not report ECONNRESET error when it's the nested error cause" do - error = error_with_nested_cause(StandardError, "error message", Errno::ECONNRESET) - fake_body = double - expect(fake_body).to receive(:each).once.and_raise(error) + describe "does not report ECONNRESET error when it's the error cause" do + def perform + error = error_with_cause(StandardError, "error message", Errno::ECONNRESET) + fake_body = double + expect(fake_body).to receive(:each).once.and_raise(error) - wrapped = described_class.wrap(fake_body, transaction) - expect do - expect { |b| wrapped.each(&b) }.to yield_control - end.to raise_error(StandardError, "error message") + wrapped = described_class.wrap(fake_body, transaction) + expect do + expect { |b| wrapped.each(&b) }.to yield_control + end.to raise_error(StandardError, "error message") + end + + it "in agent mode", :agent_mode do + perform + expect(transaction).to_not have_error + end - expect(transaction).to_not have_error + it "in collector mode", :collector_mode do + 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 + perform + expect(transaction).to_not have_error + end - expect(transaction).to include_event("name" => "close_response_body.rack") + it "in collector mode", :collector_mode do + 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 + perform + + expect(transaction).to include_event("name" => "close_response_body.rack") + end + + it "in collector mode", :collector_mode do + perform - expect(transaction).to have_error("ExampleException", "error message") + expect_collector_event("close_response_body.rack") + end end - it "doesn't report EPIPE error on close" do - fake_body = double - expect(fake_body).to receive(:close).and_raise(Errno::EPIPE) + describe "reports an error if an error occurs on close" do + def perform + fake_body = double + expect(fake_body).to receive(:close).and_raise(ExampleException, "error message") - wrapped = described_class.wrap(fake_body, transaction) - expect { wrapped.close }.to raise_error(Errno::EPIPE) - expect(transaction).to_not have_error + wrapped = described_class.wrap(fake_body, transaction) + expect do + wrapped.close + end.to raise_error(ExampleException, "error message") + end + + it "in agent mode", :agent_mode do + perform + + expect(transaction).to have_error("ExampleException", "error message") + end + + it "in collector mode", :collector_mode do + perform + + expect_collector_error("ExampleException", "error message") + end end - it "doesn't report ECONNRESET error on close" do - fake_body = double - expect(fake_body).to receive(:close).and_raise(Errno::ECONNRESET) + describe "doesn't report EPIPE error on close" do + def perform + fake_body = double + expect(fake_body).to receive(:close).and_raise(Errno::EPIPE) - wrapped = described_class.wrap(fake_body, transaction) - expect { wrapped.close }.to raise_error(Errno::ECONNRESET) - expect(transaction).to_not have_error + wrapped = described_class.wrap(fake_body, transaction) + expect { wrapped.close }.to raise_error(Errno::EPIPE) + end + + it "in agent mode", :agent_mode do + perform + expect(transaction).to_not have_error + end + + it "in collector mode", :collector_mode do + perform + expect_collector_no_error + end end - it "does not report EPIPE error when it's the error cause on close" do - error = error_with_cause(StandardError, "error message", Errno::EPIPE) - fake_body = double - expect(fake_body).to receive(:close).and_raise(error) + describe "doesn't report ECONNRESET error on close" do + def perform + fake_body = double + expect(fake_body).to receive(:close).and_raise(Errno::ECONNRESET) - wrapped = described_class.wrap(fake_body, transaction) - expect { wrapped.close }.to raise_error(StandardError, "error message") - expect(transaction).to_not have_error + wrapped = described_class.wrap(fake_body, transaction) + expect { wrapped.close }.to raise_error(Errno::ECONNRESET) + end + + it "in agent mode", :agent_mode do + perform + expect(transaction).to_not have_error + end + + it "in collector mode", :collector_mode do + perform + expect_collector_no_error + end end - it "does not report ECONNRESET error when it's the error cause on close" do - error = error_with_cause(StandardError, "error message", Errno::ECONNRESET) - fake_body = double - expect(fake_body).to receive(:close).and_raise(error) + describe "does not report EPIPE error when it's the error cause on close" do + def perform + error = error_with_cause(StandardError, "error message", Errno::EPIPE) + fake_body = double + expect(fake_body).to receive(:close).and_raise(error) - wrapped = described_class.wrap(fake_body, transaction) - expect { wrapped.close }.to raise_error(StandardError, "error message") - expect(transaction).to_not have_error + wrapped = described_class.wrap(fake_body, transaction) + expect { wrapped.close }.to raise_error(StandardError, "error message") + end + + it "in agent mode", :agent_mode do + perform + expect(transaction).to_not have_error + end + + it "in collector mode", :collector_mode do + perform + expect_collector_no_error + end + end + + describe "does not report ECONNRESET error when it's the error cause on close" do + def perform + error = error_with_cause(StandardError, "error message", Errno::ECONNRESET) + fake_body = double + expect(fake_body).to receive(:close).and_raise(error) + + wrapped = described_class.wrap(fake_body, transaction) + expect { wrapped.close }.to raise_error(StandardError, "error message") + end + + it "in agent mode", :agent_mode do + perform + expect(transaction).to_not have_error + end + + it "in collector mode", :collector_mode do + 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 +446,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 +455,252 @@ 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 + 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 + 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 + 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 + 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 - expect(transaction).to_not have_error + it "in agent mode", :agent_mode do + perform + expect(transaction).to_not have_error + end + + it "in collector mode", :collector_mode do + perform + expect_collector_no_error + end end - it "doesn't report ECONNRESET error" do - expect(fake_body).to receive(:each).once.and_raise(Errno::ECONNRESET) + describe "doesn't report ECONNRESET error" do + def perform + expect(fake_body).to receive(:each).once.and_raise(Errno::ECONNRESET) - wrapped = described_class.wrap(fake_body, transaction) - expect do - expect { |b| wrapped.each(&b) }.to yield_control - end.to raise_error(Errno::ECONNRESET) + wrapped = described_class.wrap(fake_body, transaction) + expect do + expect { |b| wrapped.each(&b) }.to yield_control + end.to raise_error(Errno::ECONNRESET) + end + + it "in agent mode", :agent_mode do + perform + expect(transaction).to_not have_error + end - expect(transaction).to_not have_error + it "in collector mode", :collector_mode do + perform + expect_collector_no_error + end end - it "does not report EPIPE error when it's the error cause" do - error = error_with_cause(StandardError, "error message", Errno::EPIPE) - fake_body = double - expect(fake_body).to receive(:each).once.and_raise(error) + describe "does not report EPIPE error when it's the error cause (each)" do + def perform + error = error_with_cause(StandardError, "error message", Errno::EPIPE) + fake_body = double + expect(fake_body).to receive(:each).once.and_raise(error) - wrapped = described_class.wrap(fake_body, transaction) - expect do - expect { |b| wrapped.each(&b) }.to yield_control - end.to raise_error(StandardError, "error message") + wrapped = described_class.wrap(fake_body, transaction) + expect do + expect { |b| wrapped.each(&b) }.to yield_control + end.to raise_error(StandardError, "error message") + end + + it "in agent mode", :agent_mode do + perform + expect(transaction).to_not have_error + end - expect(transaction).to_not have_error + it "in collector mode", :collector_mode do + 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 + perform + expect(transaction).to_not have_error + end - expect(transaction).to_not have_error + it "in collector mode", :collector_mode do + 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 + 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 + 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 + 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 + perform + + expect_collector_error("ExampleException", "error message") + end end - it "does not report EPIPE error when it's the error cause" do - error = error_with_cause(StandardError, "error message", Errno::EPIPE) - fake_body = double - allow(fake_body).to receive(:each) - expect(fake_body).to receive(:to_ary).once.and_raise(error) - expect(fake_body).to_not receive(:close) # Per spec we expect the body has closed itself + describe "does not report EPIPE error when it's the error cause (to_ary)" do + def perform + error = error_with_cause(StandardError, "error message", Errno::EPIPE) + fake_body = double + allow(fake_body).to receive(:each) + expect(fake_body).to receive(:to_ary).once.and_raise(error) + expect(fake_body).to_not receive(:close) # Per spec we expect the body has closed itself + + wrapped = described_class.wrap(fake_body, transaction) + expect do + wrapped.to_ary + end.to raise_error(StandardError, "error message") + end - wrapped = described_class.wrap(fake_body, transaction) - expect do - wrapped.to_ary - end.to raise_error(StandardError, "error message") + it "in agent mode", :agent_mode do + perform + expect(transaction).to_not have_error + end - expect(transaction).to_not have_error + it "in collector mode", :collector_mode do + 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 + perform + expect(transaction).to_not have_error + end - expect(transaction).to_not have_error + it "in collector mode", :collector_mode do + 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 +709,241 @@ 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 + 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 + 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 + perform + + expect(transaction).to have_error("ExampleException", "error message") + end + + it "in collector mode", :collector_mode do + perform - expect(transaction).to have_error("ExampleException", "error message") + 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 + 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 + 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 - expect(transaction).to_not have_error + it "in agent mode", :agent_mode do + perform + expect(transaction).to_not have_error + end + + it "in collector mode", :collector_mode do + 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 + + it "in agent mode", :agent_mode do + perform + expect(transaction).to_not have_error + end - expect(transaction).to_not have_error + it "in collector mode", :collector_mode do + 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 + perform + expect(transaction).to_not have_error + end - expect(transaction).to_not have_error + it "in collector mode", :collector_mode do + 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 + perform + expect(transaction).to_not have_error + end - expect(transaction).to_not have_error + it "in collector mode", :collector_mode do + 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 + perform + expect(transaction).to_not have_error + end + + it "in collector mode", :collector_mode do + 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 + perform + expect(transaction).to_not have_error + end - expect(transaction).to_not have_error + it "in collector mode", :collector_mode do + 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 + 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 + 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 +952,159 @@ 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 + 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 + 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 + perform + + expect(transaction).to have_error("ExampleException", "error message") + end + + it "in collector mode", :collector_mode do + 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 + perform + expect(transaction).to_not have_error + end - expect(transaction).to_not have_error + it "in collector mode", :collector_mode do + 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 + perform + expect(transaction).to_not have_error + end - expect(transaction).to_not have_error + it "in collector mode", :collector_mode do + 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 + perform + expect(transaction).to_not have_error + end - expect(transaction).to_not have_error + it "in collector mode", :collector_mode do + 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 + perform + expect(transaction).to_not have_error + end + + it "in collector mode", :collector_mode do + perform + expect_collector_no_error + end end end From ece81e36d2fcb0f6531a3f609b675cfe432822cf Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Fri, 5 Jun 2026 10:06:36 +0200 Subject: [PATCH 043/151] Let examples start the agent in the body Mode is global state set by start_agent, so an automatic before-hook start and ad-hoc start_agent calls fight over it with fragile ordering. Make both mode contexts' auto-start opt-out-able with a :manual_start tag, and extract the collector setup into a start_collector_agent helper, so a describe can start its own agent in the example body. The default stays automatic, so existing specs are unaffected. --- spec/support/shared_contexts/agent_mode.rb | 13 ++++- .../support/shared_contexts/collector_mode.rb | 48 ++++++++++++------- 2 files changed, 43 insertions(+), 18 deletions(-) diff --git a/spec/support/shared_contexts/agent_mode.rb b/spec/support/shared_contexts/agent_mode.rb index bb92492d5..39d1ae773 100644 --- a/spec/support/shared_contexts/agent_mode.rb +++ b/spec/support/shared_contexts/agent_mode.rb @@ -1,12 +1,23 @@ # frozen_string_literal: true RSpec.shared_context "agent mode", :agent_mode do + # Dual-mode start principle (see also collector_mode.rb): mode setup is a + # global, and having both an automatic `before` here AND ad-hoc `start_agent` + # calls elsewhere fight over it (last writer wins, order is fragile). Going + # forward, prefer starting the agent explicitly in the example body. A + # describe tagged `:manual_start` opts out of this automatic start; its + # `it "in agent mode"` calls `start_agent` itself before `perform`. + # # Examples can define a `start_agent_args` `let` to pass `:env`/`:options` to # `start_agent` (the collector-mode context accepts the same hook and also # injects the `collector_endpoint`). Guarded with `defined?` rather than a # default `let` here, because an included shared context's `let` would take # precedence over the example group's own `let` override. - before { start_agent(**(defined?(start_agent_args) ? start_agent_args : {})) } + before do |example| + next if example.metadata[:manual_start] + + start_agent(**(defined?(start_agent_args) ? start_agent_args : {})) + end # Make completed transactions readable via `to_h` so agent-mode tests can # assert on `include_event` / `include_tags` etc. after the transaction diff --git a/spec/support/shared_contexts/collector_mode.rb b/spec/support/shared_contexts/collector_mode.rb index 52c3e9716..e978cbf34 100644 --- a/spec/support/shared_contexts/collector_mode.rb +++ b/spec/support/shared_contexts/collector_mode.rb @@ -37,12 +37,42 @@ provider end + # Dual-mode start principle: mode setup is global state, so an automatic + # `before` start and ad-hoc `start_agent` calls fight over it with fragile + # ordering. Going forward, prefer starting the agent in the example body. A + # describe tagged `:manual_start` opts out of this automatic start; its + # `it "in collector mode"` calls `start_collector_agent` itself before + # `perform`. The in-memory providers, helpers and teardown below still apply. + before do |example| + start_collector_agent unless example.metadata[:manual_start] + end + + 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 + end + + # Boots the agent in collector mode and swaps in the in-memory OTel providers. + # Called automatically by the `before` above, or explicitly from an example + # body in a `:manual_start` describe. + # # 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. - before do + def start_collector_agent args = (defined?(start_agent_args) ? start_agent_args : {}).dup args[:options] = { :collector_endpoint => "http://127.0.0.1:9090" }.merge(args[:options] || {}) start_agent(**args) @@ -62,22 +92,6 @@ Appsignal::Logger::OpenTelemetryBackend.reset! end - 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 - end - def root_span span_exporter.finished_spans.find { |s| [:server, :consumer].include?(s.kind) } end From d0c021373c8814486c3bb1b28ff2ba79a8396fc5 Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Fri, 5 Jun 2026 10:06:36 +0200 Subject: [PATCH 044/151] Co-locate error-reporting modes per describe The error-reporting collector coverage lived in a separate top-level describe, duplicating the agent examples and splitting the two modes across the file. Fold it into the existing .send_error/.set_error/ .report_error describes: the core behaviours become :manual_start describes with paired agent/collector examples that start their own agent in the body, while the agent-specific details stay alongside. Removes the duplicate describe and the redundant start_agent hooks. --- spec/lib/appsignal_spec.rb | 401 ++++++++++++++++--------------------- 1 file changed, 171 insertions(+), 230 deletions(-) diff --git a/spec/lib/appsignal_spec.rb b/spec/lib/appsignal_spec.rb index e98653f8f..93593cdd6 100644 --- a/spec/lib/appsignal_spec.rb +++ b/spec/lib/appsignal_spec.rb @@ -1140,7 +1140,10 @@ def on_start end context "with config and started" do - before { start_agent } + # Opt-out-aware so a `:manual_start` describe can start its own agent in the + # example body (the dual-mode start principle) without this hook clobbering + # the collector-mode setup. + before { |example| start_agent unless example.metadata[:manual_start] } around { |example| keep_transactions { example.run } } describe ".monitor" do @@ -1610,15 +1613,38 @@ 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", :manual_start 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(Appsignal::Transaction::HTTP_REQUEST) + expect(root_span.attributes).not_to have_key("appsignal.action_name") + expect(exception_events.first.attributes["exception.type"]).to eq("ExampleException") + expect(exception_events.first.attributes["exception.message"]).to eq("error message") + end end context "when given error is not an Exception" do @@ -1639,15 +1665,32 @@ 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", :manual_start 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") + expect(exception_events.first.attributes["exception.type"]).to eq("StandardError") + end end it "yields and allows additional metadata to be set with global helpers" do @@ -1695,11 +1738,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", :manual_start 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 +1757,20 @@ def on_start expect(transaction).to_not include_tags end + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + expect(exception_events.first.attributes["exception.type"]).to eq("ExampleException") + expect(exception_events.first.attributes["exception.message"]) + .to eq("I am an exception") + 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 +1819,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 +1841,30 @@ def on_start end context "when there is no active transaction" do - it "creates a new transaction" do - expect do + describe "reporting the error", :manual_start 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 + expect(exception_events.first.attributes["exception.type"]).to eq("ExampleException") + expect(exception_events.first.attributes["exception.message"]) + .to eq("error message") + end end context "when given a block" do @@ -1825,15 +1902,74 @@ def on_start context "when there is an active transaction" do let(:transaction) { http_request_transaction } - before { set_current_transaction(transaction) } + # Opt-out-aware: `:manual_start` examples set the current transaction in + # their own body, after swapping in the collector providers. + before do |example| + set_current_transaction(transaction) unless example.metadata[:manual_start] + end - it "sets the error in the active transaction" do - Appsignal.report_error(error) + describe "reporting the error onto it", :manual_start 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 + + expect(exception_events.first.attributes["exception.type"]).to eq("ExampleException") + expect(exception_events.first.attributes["exception.message"]).to eq("error message") + end + end + + describe "with multiple reported errors", :manual_start 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 @@ -2003,201 +2139,6 @@ def on_start end end - # Dual-mode coverage of the error-reporting helpers. A separate top-level - # describe, like `custom metrics` and `.instrument` below: `start_agent` comes - # from the mode contexts, so it avoids the `before { start_agent }` in the - # `with config and started` context above (which clobbers collector mode). The - # agent-specific details (tag flushing, the duplicate-transaction model, - # validation/logging) stay in the agent-mode describes in that context. - describe "error reporting" do - around { |example| keep_transactions { example.run } } - - # The completed root span's sole `exception` span-event. - def exception_event - root_span.events.find { |event| event.name == "exception" } - end - - describe ".send_error" do - let(:error) { ExampleException.new("error message") } - - def perform - Appsignal.send_error(error) - end - - it "in agent mode", :agent_mode do - 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 be_completed - end - - it "in collector mode", :collector_mode do - # 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(Appsignal::Transaction::HTTP_REQUEST) - expect(root_span.attributes).not_to have_key("appsignal.action_name") - expect(exception_event.attributes["exception.type"]).to eq("ExampleException") - expect(exception_event.attributes["exception.message"]).to eq("error message") - end - - describe "with a block setting metadata" do - def perform - Appsignal.send_error(error) do |transaction| - transaction.set_action("my_action") - transaction.set_namespace("my_namespace") - end - end - - it "in agent mode", :agent_mode do - perform - - expect(last_transaction).to have_namespace("my_namespace") - expect(last_transaction).to have_action("my_action") - expect(last_transaction).to have_error("ExampleException", "error message") - end - - it "in collector mode", :collector_mode do - 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(exception_event.attributes["exception.type"]).to eq("ExampleException") - end - end - end - - describe ".set_error" do - let(:error) { ExampleException.new("I am an exception") } - let(:transaction) { http_request_transaction } - - # `transaction` (and so its root span) is built here inside the example - # action, not in a `before`, so it uses the in-memory tracer provider the - # collector-mode context swaps in. - def perform - set_current_transaction(transaction) - Appsignal.set_error(error) - end - - it "in agent mode", :agent_mode do - perform - - transaction._sample - expect(transaction).to have_namespace(Appsignal::Transaction::HTTP_REQUEST) - expect(transaction).to have_error("ExampleException", "I am an exception") - end - - it "in collector mode", :collector_mode do - perform - transaction.complete - - expect(exception_event.attributes["exception.type"]).to eq("ExampleException") - expect(exception_event.attributes["exception.message"]).to eq("I am an exception") - end - end - - describe ".report_error" do - let(:error) { ExampleException.new("error message") } - - context "without an active transaction" do - def perform - Appsignal.report_error(error) - end - - it "in agent mode", :agent_mode do - expect { perform }.to(change { created_transactions.count }.by(1)) - - expect(last_transaction).to have_error("ExampleException", "error message") - expect(last_transaction).to be_completed - end - - it "in collector mode", :collector_mode do - # 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 - expect(exception_event.attributes["exception.type"]).to eq("ExampleException") - expect(exception_event.attributes["exception.message"]).to eq("error message") - end - end - - context "with an active transaction" do - let(:transaction) { http_request_transaction } - - # Built inside the action, not a `before`, so the root span uses the - # in-memory tracer provider the collector-mode context swaps in. - def perform - set_current_transaction(transaction) - Appsignal.report_error(error) - end - - it "in agent mode", :agent_mode do - perform - - expect(last_transaction).to eq(transaction) - transaction._sample - expect(transaction).to have_error("ExampleException", "error message") - end - - it "in collector mode", :collector_mode do - perform - transaction.complete - - expect(exception_event.attributes["exception.type"]).to eq("ExampleException") - expect(exception_event.attributes["exception.message"]).to eq("error message") - end - - context "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 - 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 do |t| - t.to_h["error"]["message"] - end).to contain_exactly("error message", "other message") - end - - it "in collector mode", :collector_mode do - 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 - end - end - end - describe "custom metrics" do let(:tags) { { :foo => "bar" } } From fa22843295d7f57cc6f2b47245610dd908e20510 Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Fri, 5 Jun 2026 15:51:01 +0200 Subject: [PATCH 045/151] Make it_in_both_modes start the agent in the body Apply the dual-mode start principle to it_in_both_modes: each generated example now starts its own agent (:manual_start) before running the shared block, instead of relying on the mode contexts' automatic start. Mode-dependent arrangement must move into the block, which runs after the start. --- spec/support/helpers/mode_helpers.rb | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/spec/support/helpers/mode_helpers.rb b/spec/support/helpers/mode_helpers.rb index 623aeea52..4509735a1 100644 --- a/spec/support/helpers/mode_helpers.rb +++ b/spec/support/helpers/mode_helpers.rb @@ -3,9 +3,23 @@ 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 (`:manual_start`, opting out of the mode contexts' + # automatic start): 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, &block) - it([description, "in collector mode"].compact.join(" "), :collector_mode, &block) + it([description, "in agent mode"].compact.join(" "), :agent_mode, :manual_start) do + start_agent(**(defined?(start_agent_args) ? start_agent_args : {})) + instance_exec(&block) + end + it([description, "in collector mode"].compact.join(" "), :collector_mode, :manual_start) do + start_collector_agent + instance_exec(&block) + end end end From 6f4fc950a42224bf3154c3726e2000642fea9beb Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Fri, 5 Jun 2026 16:25:33 +0200 Subject: [PATCH 046/151] Start the agent in the body across dual-mode specs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Apply the dual-mode start principle to every already-dual-moded spec: each paired agent/collector example is now under a :manual_start describe and starts its own agent in the body (start_agent / start_collector_agent) instead of relying on the mode contexts' automatic before-hook start. Mode-dependent arrangement moved into the bodies where it had been in a before. Pure setup refactor — no assertions, descriptions, or coverage changed. One agent per spec, fanned out in parallel. --- .../lib/appsignal/hooks/action_mailer_spec.rb | 6 +- .../finish_with_state_shared_examples.rb | 8 +- .../instrument_shared_examples.rb | 28 +- .../start_finish_shared_examples.rb | 16 +- spec/lib/appsignal/hooks/activejob_spec.rb | 24 +- spec/lib/appsignal/hooks/at_exit_spec.rb | 4 +- spec/lib/appsignal/hooks/dry_monitor_spec.rb | 8 +- spec/lib/appsignal/hooks/excon_spec.rb | 26 +- spec/lib/appsignal/hooks/rake_spec.rb | 16 +- spec/lib/appsignal/hooks/redis_client_spec.rb | 16 +- spec/lib/appsignal/hooks/redis_spec.rb | 8 +- spec/lib/appsignal/hooks/sequel_spec.rb | 4 +- .../active_support_event_reporter_spec.rb | 6 +- .../integrations/data_mapper_spec.rb | 26 +- .../integrations/mongo_ruby_driver_spec.rb | 54 ++-- .../appsignal/integrations/net_http_spec.rb | 8 +- spec/lib/appsignal/logger_spec.rb | 157 ++++++++--- spec/lib/appsignal/probes/gvl_spec.rb | 8 +- spec/lib/appsignal/probes/mri_spec.rb | 28 +- spec/lib/appsignal/probes/sidekiq_spec.rb | 32 ++- spec/lib/appsignal/rack/body_wrapper_spec.rb | 254 +++++++++++++++--- spec/lib/appsignal/rack/event_handler_spec.rb | 12 +- .../rack/sinatra_instrumentation_spec.rb | 40 ++- spec/lib/appsignal/transaction_spec.rb | 140 +++++++--- spec/lib/appsignal_spec.rb | 34 ++- 25 files changed, 729 insertions(+), 234 deletions(-) diff --git a/spec/lib/appsignal/hooks/action_mailer_spec.rb b/spec/lib/appsignal/hooks/action_mailer_spec.rb index d1818e391..cd157a2c6 100644 --- a/spec/lib/appsignal/hooks/action_mailer_spec.rb +++ b/spec/lib/appsignal/hooks/action_mailer_spec.rb @@ -24,8 +24,10 @@ def welcome end end - describe ".install" do + describe ".install", :manual_start do it "in agent mode", :agent_mode do + start_agent + expect(Appsignal.active?).to be_truthy expect(Appsignal).to receive(:increment_counter).with( :action_mailer_process, @@ -37,6 +39,8 @@ def welcome end it "in collector mode", :collector_mode do + start_collector_agent + expect(Appsignal.active?).to be_truthy UserMailer.welcome.deliver_now 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 343fb131c..7cfbd4005 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,13 +1,14 @@ shared_examples "activesupport finish_with_state override" do let(:instrumenter) { as.instrumenter } - describe "a finish_with_state event" do + describe "a finish_with_state event", :manual_start 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 @@ -24,6 +25,7 @@ def perform end it "in collector mode", :collector_mode do + start_collector_agent transaction = http_request_transaction set_current_transaction(transaction) as.notifier = notifier @@ -40,13 +42,14 @@ def perform end end - describe "an event whose name starts with a bang" do + describe "an event whose name starts with a bang", :manual_start 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 @@ -57,6 +60,7 @@ def perform end it "in collector mode", :collector_mode do + start_collector_agent transaction = http_request_transaction set_current_transaction(transaction) as.notifier = notifier 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 e9de62a68..8df88934f 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,10 +1,11 @@ shared_examples "activesupport instrument override" do - describe "an event with a registered formatter" do + describe "an event with a registered formatter", :manual_start 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 @@ -20,6 +21,7 @@ def perform end it "in collector mode", :collector_mode do + start_collector_agent transaction = http_request_transaction set_current_transaction(transaction) as.notifier = notifier @@ -38,12 +40,13 @@ def perform end end - describe "an event with no registered formatter" do + describe "an event with no registered formatter", :manual_start do def perform as.instrument("no-registered.formatter", :key => "something") { "value" } end it "in agent mode", :agent_mode do + start_agent transaction = http_request_transaction set_current_transaction(transaction) as.notifier = notifier @@ -59,6 +62,7 @@ def perform end it "in collector mode", :collector_mode do + start_collector_agent transaction = http_request_transaction set_current_transaction(transaction) as.notifier = notifier @@ -77,12 +81,13 @@ def perform end end - describe "an event with a non-string name" do + describe "an event with a non-string name", :manual_start 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 @@ -99,6 +104,7 @@ def perform end it "in collector mode", :collector_mode do + start_collector_agent transaction = http_request_transaction set_current_transaction(transaction) as.notifier = notifier @@ -111,12 +117,13 @@ def perform end end - describe "an event whose name starts with a bang" do + describe "an event whose name starts with a bang", :manual_start 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 @@ -126,6 +133,7 @@ def perform end it "in collector mode", :collector_mode do + start_collector_agent transaction = http_request_transaction set_current_transaction(transaction) as.notifier = notifier @@ -137,7 +145,7 @@ def perform end end - describe "when an error is raised in an instrumented block" do + describe "when an error is raised in an instrumented block", :manual_start do def perform expect do as.instrument("sql.active_record", :sql => "SQL") do @@ -147,6 +155,7 @@ def perform end it "in agent mode", :agent_mode do + start_agent transaction = http_request_transaction set_current_transaction(transaction) as.notifier = notifier @@ -163,6 +172,7 @@ def perform end it "in collector mode", :collector_mode do + start_collector_agent transaction = http_request_transaction set_current_transaction(transaction) as.notifier = notifier @@ -179,7 +189,7 @@ def perform end end - describe "when a message is thrown in an instrumented block" do + describe "when a message is thrown in an instrumented block", :manual_start do def perform expect do as.instrument("sql.active_record", :sql => "SQL") { throw :foo } @@ -187,6 +197,7 @@ def perform end it "in agent mode", :agent_mode do + start_agent transaction = http_request_transaction set_current_transaction(transaction) as.notifier = notifier @@ -203,6 +214,7 @@ def perform end it "in collector mode", :collector_mode do + start_collector_agent transaction = http_request_transaction set_current_transaction(transaction) as.notifier = notifier @@ -219,7 +231,7 @@ def perform end end - describe "when the transaction is completed inside an instrumented block" do + describe "when the transaction is completed inside an instrumented block", :manual_start do def perform as.instrument("sql.active_record", :sql => "SQL") do Appsignal::Transaction.complete_current! @@ -227,6 +239,7 @@ def perform end it "in agent mode", :agent_mode do + start_agent transaction = http_request_transaction set_current_transaction(transaction) as.notifier = notifier @@ -238,6 +251,7 @@ def perform end it "in collector mode", :collector_mode do + start_collector_agent transaction = http_request_transaction set_current_transaction(transaction) as.notifier = notifier 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 f4234de65..1bd4d6dd5 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,13 +1,14 @@ shared_examples "activesupport start finish override" do let(:instrumenter) { as.instrumenter } - describe "a start/finish event whose payload is provided at start" do + describe "a start/finish event whose payload is provided at start", :manual_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 @@ -24,6 +25,7 @@ def perform end it "in collector mode", :collector_mode do + start_collector_agent transaction = http_request_transaction set_current_transaction(transaction) as.notifier = notifier @@ -42,13 +44,14 @@ def perform end end - describe "a start/finish event whose payload is provided at finish" do + describe "a start/finish event whose payload is provided at finish", :manual_start 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 @@ -65,6 +68,7 @@ def perform end it "in collector mode", :collector_mode do + start_collector_agent transaction = http_request_transaction set_current_transaction(transaction) as.notifier = notifier @@ -81,13 +85,14 @@ def perform end end - describe "an event whose name starts with a bang" do + describe "an event whose name starts with a bang", :manual_start 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 @@ -98,6 +103,7 @@ def perform end it "in collector mode", :collector_mode do + start_collector_agent transaction = http_request_transaction set_current_transaction(transaction) as.notifier = notifier @@ -109,7 +115,7 @@ def perform end end - describe "when the transaction is completed between start and finish" do + describe "when the transaction is completed between start and finish", :manual_start do def perform instrumenter.start("sql.active_record", {}) Appsignal::Transaction.complete_current! @@ -117,6 +123,7 @@ def perform end it "in agent mode", :agent_mode do + start_agent transaction = http_request_transaction set_current_transaction(transaction) as.notifier = notifier @@ -128,6 +135,7 @@ def perform end it "in collector mode", :collector_mode do + start_collector_agent transaction = http_request_transaction set_current_transaction(transaction) as.notifier = notifier diff --git a/spec/lib/appsignal/hooks/activejob_spec.rb b/spec/lib/appsignal/hooks/activejob_spec.rb index 27e6c943c..4ac2d3095 100644 --- a/spec/lib/appsignal/hooks/activejob_spec.rb +++ b/spec/lib/appsignal/hooks/activejob_spec.rb @@ -697,7 +697,7 @@ def active_job_internal_key # 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 + describe "emitting the queue job count metric", :manual_start do before do ActiveJob::Base.queue_adapter = :inline stub_const("ActiveJobTestJob", Class.new(ActiveJob::Base) do @@ -711,6 +711,8 @@ def perform 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 }) @@ -718,6 +720,8 @@ def perform end it "in collector mode", :collector_mode do + start_collector_agent + perform snapshot = metric_snapshot("active_job_queue_job_count") @@ -732,7 +736,7 @@ def perform # 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 + describe "emitting the failed job count metric", :manual_start do before do ActiveJob::Base.queue_adapter = :inline stub_const("ActiveJobFailingJob", Class.new(ActiveJob::Base) do @@ -750,6 +754,8 @@ def perform 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 }) @@ -758,6 +764,8 @@ def perform end it "in collector mode", :collector_mode do + start_collector_agent + perform snapshot = metric_snapshot("active_job_queue_job_count") @@ -771,7 +779,7 @@ def perform # 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 + describe "emitting the priority job count metric", :manual_start do before do ActiveJob::Base.queue_adapter = :inline stub_const("ActiveJobPriorityJob", Class.new(ActiveJob::Base) do @@ -787,6 +795,8 @@ def perform 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", @@ -798,6 +808,8 @@ def perform end it "in collector mode", :collector_mode do + start_collector_agent + perform snapshot = metric_snapshot("active_job_queue_priority_job_count") @@ -816,7 +828,7 @@ def perform # 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 + describe "emitting the queue time metric", :manual_start do before do stub_const( "ActiveJob::QueueAdapters::AppsignalTestAdapter", @@ -844,6 +856,8 @@ def perform end it "in agent mode", :agent_mode do + start_agent + allow(Appsignal).to receive(:add_distribution_value) perform @@ -854,6 +868,8 @@ def perform end it "in collector mode", :collector_mode do + start_collector_agent + perform snapshot = metric_snapshot("active_job_queue_time") diff --git a/spec/lib/appsignal/hooks/at_exit_spec.rb b/spec/lib/appsignal/hooks/at_exit_spec.rb index 1f2e8d6fd..4f16a6a38 100644 --- a/spec/lib/appsignal/hooks/at_exit_spec.rb +++ b/spec/lib/appsignal/hooks/at_exit_spec.rb @@ -60,7 +60,7 @@ def call_callback expect(logs).to_not contains_log(:error, "Appsignal.report_error: Cannot add error.") end - describe "reports an error if there's an unhandled error" do + describe "reports an error if there's an unhandled error", :manual_start do def perform with_error(ExampleException, "error message") do call_callback @@ -68,6 +68,7 @@ def perform 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) @@ -77,6 +78,7 @@ def perform 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) diff --git a/spec/lib/appsignal/hooks/dry_monitor_spec.rb b/spec/lib/appsignal/hooks/dry_monitor_spec.rb index 71aeeec02..79884703b 100644 --- a/spec/lib/appsignal/hooks/dry_monitor_spec.rb +++ b/spec/lib/appsignal/hooks/dry_monitor_spec.rb @@ -34,7 +34,7 @@ describe "Dry Monitor Integration" do let(:notifications) { Dry::Monitor::Notifications.new(:test) } - describe "a SQL event" do + describe "a SQL event", :manual_start do let(:event_id) { :sql } let(:payload) do { @@ -48,6 +48,7 @@ def perform end it "in agent mode", :agent_mode do + start_agent transaction = http_request_transaction set_current_transaction(transaction) perform @@ -61,6 +62,7 @@ def perform end it "in collector mode", :collector_mode do + start_collector_agent transaction = http_request_transaction set_current_transaction(transaction) perform @@ -78,7 +80,7 @@ def perform end end - describe "an unregistered formatter event" do + describe "an unregistered formatter event", :manual_start do let(:event_id) { :foo } let(:payload) { { :name => "foo" } } @@ -87,6 +89,7 @@ def perform end it "in agent mode", :agent_mode do + start_agent transaction = http_request_transaction set_current_transaction(transaction) perform @@ -100,6 +103,7 @@ def perform end it "in collector mode", :collector_mode do + start_collector_agent transaction = http_request_transaction set_current_transaction(transaction) perform diff --git a/spec/lib/appsignal/hooks/excon_spec.rb b/spec/lib/appsignal/hooks/excon_spec.rb index 5524af0b7..6ab1f94d7 100644 --- a/spec/lib/appsignal/hooks/excon_spec.rb +++ b/spec/lib/appsignal/hooks/excon_spec.rb @@ -23,20 +23,22 @@ def self.defaults end describe "instrumentation" do - describe "a http request" do + describe "a http request", :manual_start do def perform + transaction = http_request_transaction + set_current_transaction(transaction) data = { :host => "www.google.com", :method => :get, :scheme => "http" } Excon.defaults[:instrumentor].instrument("excon.request", data) {} # rubocop:disable Lint/EmptyBlock + transaction end it "in agent mode", :agent_mode do - transaction = http_request_transaction - set_current_transaction(transaction) - perform + start_agent + transaction = perform expect(transaction).to include_event( "name" => "request.excon", @@ -46,8 +48,7 @@ def perform end it "in collector mode", :collector_mode do - transaction = http_request_transaction - set_current_transaction(transaction) + start_collector_agent perform Appsignal::Transaction.complete_current! @@ -60,16 +61,18 @@ def perform end end - describe "a http response" do + describe "a http response", :manual_start do def perform + transaction = http_request_transaction + set_current_transaction(transaction) data = { :host => "www.google.com" } Excon.defaults[:instrumentor].instrument("excon.response", data) {} # rubocop:disable Lint/EmptyBlock + transaction end it "in agent mode", :agent_mode do - transaction = http_request_transaction - set_current_transaction(transaction) - perform + start_agent + transaction = perform expect(transaction).to include_event( "name" => "response.excon", @@ -79,8 +82,7 @@ def perform end it "in collector mode", :collector_mode do - transaction = http_request_transaction - set_current_transaction(transaction) + start_collector_agent perform Appsignal::Transaction.complete_current! diff --git a/spec/lib/appsignal/hooks/rake_spec.rb b/spec/lib/appsignal/hooks/rake_spec.rb index 6ad4bb259..1af58ff3b 100644 --- a/spec/lib/appsignal/hooks/rake_spec.rb +++ b/spec/lib/appsignal/hooks/rake_spec.rb @@ -48,8 +48,9 @@ def perform context "with :enable_rake_performance_instrumentation == true" do let(:options) { { :enable_rake_performance_instrumentation => true } } - describe "creates a transaction" do + describe "creates a transaction", :manual_start 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 @@ -63,6 +64,7 @@ def perform 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 -- @@ -99,8 +101,9 @@ def perform context "with normal error" do let(:error) { ExampleException.new("error message") } - describe "creates a background job transaction" do + describe "creates a background job transaction", :manual_start do it "in agent mode", :agent_mode do + start_agent(**start_agent_args) perform transaction = last_transaction @@ -113,6 +116,7 @@ def perform end it "in collector mode", :collector_mode do + start_collector_agent perform # NOTE: params (include_params) is a collector-mode gap -- @@ -153,14 +157,16 @@ def perform context "when error is a SystemExit" do let(:error) { SystemExit.new(1) } - describe "does not report the error" do + describe "does not report the error", :manual_start 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 expect(exception_events).to be_empty @@ -171,14 +177,16 @@ def perform context "when error is a SignalException" do let(:error) { SignalException.new(1) } - describe "does not report the error" do + describe "does not report the error", :manual_start 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 expect(exception_events).to be_empty diff --git a/spec/lib/appsignal/hooks/redis_client_spec.rb b/spec/lib/appsignal/hooks/redis_client_spec.rb index 03338c19c..b7cfa5c99 100644 --- a/spec/lib/appsignal/hooks/redis_client_spec.rb +++ b/spec/lib/appsignal/hooks/redis_client_spec.rb @@ -79,12 +79,13 @@ def write(_commands) Appsignal::Hooks::RedisClientHook.new.install end - describe "a redis call" do + describe "a redis call", :manual_start 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") @@ -97,6 +98,7 @@ def perform 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") @@ -112,7 +114,7 @@ def perform end end - describe "a redis script call" do + describe "a redis script call", :manual_start do let(:script) { "return redis.call('set',KEYS[1],ARGV[1])" } def perform @@ -123,6 +125,7 @@ def perform end it "in agent mode", :agent_mode do + start_agent transaction = http_request_transaction set_current_transaction(transaction) expect(perform).to eql("stub_write") @@ -135,6 +138,7 @@ def perform 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") @@ -206,12 +210,13 @@ def write(_commands) Appsignal::Hooks::RedisClientHook.new.install end - describe "a redis call" do + describe "a redis call", :manual_start do def perform RedisClient::HiredisConnection.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") @@ -224,6 +229,7 @@ def perform 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") @@ -239,7 +245,7 @@ def perform end end - describe "a redis script call" do + describe "a redis script call", :manual_start do let(:script) { "return redis.call('set',KEYS[1],ARGV[1])" } def perform @@ -250,6 +256,7 @@ def perform end it "in agent mode", :agent_mode do + start_agent transaction = http_request_transaction set_current_transaction(transaction) expect(perform).to eql("stub_write") @@ -262,6 +269,7 @@ def perform 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") diff --git a/spec/lib/appsignal/hooks/redis_spec.rb b/spec/lib/appsignal/hooks/redis_spec.rb index a8721a1eb..162e6b321 100644 --- a/spec/lib/appsignal/hooks/redis_spec.rb +++ b/spec/lib/appsignal/hooks/redis_spec.rb @@ -73,12 +73,13 @@ def write(_commands) Appsignal::Hooks::RedisHook.new.install end - describe "a redis call" do + describe "a redis call", :manual_start 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") @@ -91,6 +92,7 @@ def perform 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") @@ -106,7 +108,7 @@ def perform end end - describe "a redis script call" do + describe "a redis script call", :manual_start do let(:script) { "return redis.call('set',KEYS[1],ARGV[1])" } def perform @@ -116,6 +118,7 @@ def perform end it "in agent mode", :agent_mode do + start_agent transaction = http_request_transaction set_current_transaction(transaction) expect(perform).to eql("stub_write") @@ -128,6 +131,7 @@ def perform 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") diff --git a/spec/lib/appsignal/hooks/sequel_spec.rb b/spec/lib/appsignal/hooks/sequel_spec.rb index ebbda8350..1db787a31 100644 --- a/spec/lib/appsignal/hooks/sequel_spec.rb +++ b/spec/lib/appsignal/hooks/sequel_spec.rb @@ -15,12 +15,13 @@ it { is_expected.to be_truthy } end - context "with a transaction" do + context "with a transaction", :manual_start do def perform db["SELECT 1"].all.to_a end it "in agent mode", :agent_mode do + start_agent transaction = http_request_transaction set_current_transaction(transaction) perform @@ -34,6 +35,7 @@ def perform end it "in collector mode", :collector_mode do + start_collector_agent transaction = http_request_transaction set_current_transaction(transaction) perform 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 6f87178ac..a8603981f 100644 --- a/spec/lib/appsignal/integrations/active_support_event_reporter_spec.rb +++ b/spec/lib/appsignal/integrations/active_support_event_reporter_spec.rb @@ -9,8 +9,10 @@ } end - describe "#emit" do + describe "#emit", :manual_start do it "in agent mode", :agent_mode do + start_agent + logger = instance_double(Appsignal::Logger) allow(Appsignal::Logger).to receive(:new).with("rails_events").and_return(logger) @@ -23,6 +25,8 @@ end it "in collector mode", :collector_mode do + start_collector_agent + subscriber.emit(event) expect(log_records.size).to eq(1) diff --git a/spec/lib/appsignal/integrations/data_mapper_spec.rb b/spec/lib/appsignal/integrations/data_mapper_spec.rb index 1f8c1480b..39b257a2b 100644 --- a/spec/lib/appsignal/integrations/data_mapper_spec.rb +++ b/spec/lib/appsignal/integrations/data_mapper_spec.rb @@ -20,7 +20,7 @@ def log_message connection_class.new.log(message) end - describe "a SQL-like scheme" do + describe "a SQL-like scheme", :manual_start do let(:connection_class) { DataObjects::Sqlite3::Connection } before do stub_const("DataObjects::Sqlite3::Connection", Class.new do @@ -30,13 +30,15 @@ def log_message end def perform + transaction = http_request_transaction + set_current_transaction(transaction) log_message + transaction end it "in agent mode", :agent_mode do - transaction = http_request_transaction - set_current_transaction(transaction) - perform + start_agent + transaction = perform expect(transaction).to include_event( "name" => "query.data_mapper", "title" => "DataMapper Query", @@ -47,8 +49,7 @@ def perform end it "in collector mode", :collector_mode do - transaction = http_request_transaction - set_current_transaction(transaction) + start_collector_agent perform Appsignal::Transaction.complete_current! @@ -66,7 +67,7 @@ def perform end end - describe "a non-SQL scheme" do + describe "a non-SQL scheme", :manual_start do let(:connection_class) { DataObjects::MongoDB::Connection } before do stub_const("DataObjects::MongoDB::Connection", Class.new do @@ -76,13 +77,15 @@ def perform end def perform + transaction = http_request_transaction + set_current_transaction(transaction) log_message + transaction end it "in agent mode", :agent_mode do - transaction = http_request_transaction - set_current_transaction(transaction) - perform + start_agent + transaction = perform expect(transaction).to include_event( "name" => "query.data_mapper", "title" => "DataMapper Query", @@ -93,8 +96,7 @@ def perform end it "in collector mode", :collector_mode do - transaction = http_request_transaction - set_current_transaction(transaction) + start_collector_agent perform Appsignal::Transaction.complete_current! diff --git a/spec/lib/appsignal/integrations/mongo_ruby_driver_spec.rb b/spec/lib/appsignal/integrations/mongo_ruby_driver_spec.rb index 5f4dee8b5..3305b13bb 100644 --- a/spec/lib/appsignal/integrations/mongo_ruby_driver_spec.rb +++ b/spec/lib/appsignal/integrations/mongo_ruby_driver_spec.rb @@ -6,7 +6,7 @@ # backend; the OTel-backed transaction output is covered by "instrumenting a # finished query" below in both modes. `start_agent` comes from the mode # context, so it is not started here. - context "with transaction", :agent_mode do + context "with transaction", :agent_mode, :manual_start do let(:transaction) { http_request_transaction } before do set_current_transaction(transaction) @@ -53,18 +53,24 @@ def perform subscriber.succeeded(succeeded_event) end - it "records the query as an event and emits a duration metric" do + it "should sanitize command" do start_agent - transaction = http_request_transaction - set_current_transaction(transaction) + # TODO: additional curly brackets required for issue + # https://github.com/rspec/rspec-mocks/issues/1460 + expect(Appsignal::EventFormatter::MongoRubyDriver::QueryFormatter) + .to receive(:format).with("find", { "foo" => "bar" }) + subscriber.started(event) + end it "should store command on the transaction" do + start_agent subscriber.started(event) expect(transaction.store("mongo_driver")).to eq(1 => { "foo" => "?" }) end it "should start an event in the extension" do + start_agent expect(transaction).to receive(:start_event) subscriber.started(event) @@ -75,6 +81,7 @@ def perform let(:event) { double } it "should finish the event" do + start_agent expect(subscriber).to receive(:finish).with("SUCCEEDED", event) subscriber.succeeded(event) @@ -85,6 +92,7 @@ def perform let(:event) { double } it "should finish the event" do + start_agent expect(subscriber).to receive(:finish).with("FAILED", event) subscriber.failed(event) @@ -108,6 +116,7 @@ def perform end it "should get the query from the store" do + start_agent expect(transaction).to receive(:store).with("mongo_driver").and_return(command) subscriber.finish("SUCCEEDED", event) @@ -115,7 +124,7 @@ def perform end end - describe "instrumenting a finished query" do + describe "instrumenting a finished query", :manual_start do let(:started_event) do double( :request_id => 2, @@ -138,6 +147,7 @@ def perform end it "in agent mode", :agent_mode do + start_agent transaction = http_request_transaction set_current_transaction(transaction) @@ -157,6 +167,7 @@ def perform end it "in collector mode", :collector_mode do + start_collector_agent transaction = http_request_transaction set_current_transaction(transaction) perform @@ -174,20 +185,15 @@ def perform expect(snapshot.data_points.first.attributes).to eq("database" => "test") end - context "without transaction", :agent_mode do + context "without transaction", :agent_mode, :manual_start do before do allow(Appsignal::Transaction).to receive(:current) .and_return(Appsignal::Transaction::NilTransaction.new) end - # The subscriber guards on a current, unpaused transaction before touching - # the extension, so nothing is recorded otherwise. - describe "without an active transaction" do - def perform - started = command_started_event - subscriber.started(started) - subscriber.succeeded(command_succeeded_event(started)) - end + it "should not attempt to start an event" do + start_agent + expect(Appsignal::Extension).to_not receive(:start_event) it "does not record anything" do start_agent @@ -199,12 +205,9 @@ def perform end end - describe "when the transaction is paused" do - def perform - started = command_started_event - subscriber.started(started) - subscriber.succeeded(command_succeeded_event(started)) - end + it "should not attempt to finish an event" do + start_agent + expect(Appsignal::Extension).to_not receive(:finish_event) it "does not record anything" do start_agent @@ -212,31 +215,34 @@ def perform set_current_transaction(transaction) transaction.pause! - expect(Appsignal::Extension).to_not receive(:start_event) - expect(Appsignal::Extension).to_not receive(:finish_event) - expect(Appsignal).to_not receive(:add_distribution_value) + it "should not attempt to send duration metrics" do + start_agent + expect(Appsignal).to_not receive(:add_distribution_value) subscriber.finish("SUCCEEDED", double) end end - context "when appsignal is paused", :agent_mode do + context "when appsignal is paused", :agent_mode, :manual_start do let(:transaction) { double(:paused? => true, :nil_transaction? => false) } before { allow(Appsignal::Transaction).to receive(:current).and_return(transaction) } it "should not attempt to start an event" do + start_agent expect(Appsignal::Extension).to_not receive(:start_event) subscriber.started(double) end it "should not attempt to finish an event" do + start_agent expect(Appsignal::Extension).to_not receive(:finish_event) subscriber.finish("SUCCEEDED", double) end it "should not attempt to send duration metrics" do + start_agent expect(Appsignal).to_not receive(:add_distribution_value) subscriber.finish("SUCCEEDED", double) diff --git a/spec/lib/appsignal/integrations/net_http_spec.rb b/spec/lib/appsignal/integrations/net_http_spec.rb index 4db6f806c..0d823f4ce 100644 --- a/spec/lib/appsignal/integrations/net_http_spec.rb +++ b/spec/lib/appsignal/integrations/net_http_spec.rb @@ -1,7 +1,7 @@ require "appsignal/integrations/net_http" describe Appsignal::Integrations::NetHttpIntegration do - describe "a http request" do + describe "a http request", :manual_start do def perform stub_request(:any, "http://www.google.com/") @@ -9,6 +9,7 @@ def perform end it "in agent mode", :agent_mode do + start_agent transaction = http_request_transaction set_current_transaction(transaction) perform @@ -21,6 +22,7 @@ def perform end it "in collector mode", :collector_mode do + start_collector_agent transaction = http_request_transaction set_current_transaction(transaction) perform @@ -35,7 +37,7 @@ def perform end end - describe "a https request" do + describe "a https request", :manual_start do def perform stub_request(:any, "https://www.google.com/") @@ -46,6 +48,7 @@ def perform end it "in agent mode", :agent_mode do + start_agent transaction = http_request_transaction set_current_transaction(transaction) perform @@ -58,6 +61,7 @@ def perform end it "in collector mode", :collector_mode do + start_collector_agent transaction = http_request_transaction set_current_transaction(transaction) perform diff --git a/spec/lib/appsignal/logger_spec.rb b/spec/lib/appsignal/logger_spec.rb index 20b8fe1b0..c0858ccb9 100644 --- a/spec/lib/appsignal/logger_spec.rb +++ b/spec/lib/appsignal/logger_spec.rb @@ -1,5 +1,5 @@ shared_examples "tagged logging" do - describe "with tags from logger.tagged" do + describe "with tags from logger.tagged", :manual_start do def perform logger.tagged("My tag", "My other tag") do logger.info("Some message") @@ -7,6 +7,7 @@ def perform end it "in agent mode", :agent_mode do + start_agent expect(Appsignal::Extension).to receive(:log) .with( "group", @@ -19,6 +20,7 @@ def perform end it "in collector mode", :collector_mode do + start_collector_agent expect(Appsignal::Logger::OpenTelemetryBackend).to receive(:emit) .with( "group", @@ -31,7 +33,7 @@ def perform end end - describe "with nested tags from logger.tagged" do + describe "with nested tags from logger.tagged", :manual_start do def perform logger.tagged("My tag", "My other tag") do logger.tagged("Nested tag", "Nested other tag") do @@ -41,6 +43,7 @@ def perform end it "in agent mode", :agent_mode do + start_agent expect(Appsignal::Extension).to receive(:log) .with( "group", @@ -53,6 +56,7 @@ def perform end it "in collector mode", :collector_mode do + start_collector_agent expect(Appsignal::Logger::OpenTelemetryBackend).to receive(:emit) .with( "group", @@ -65,8 +69,9 @@ def perform end end - describe "with tags from Rails.application.config.log_tags" do + describe "with tags from Rails.application.config.log_tags", :manual_start do it "in agent mode", :agent_mode do + start_agent allow(Appsignal::Extension).to receive(:log) logger.push_tags(["Request tag", "Second tag"]) @@ -103,6 +108,7 @@ def perform 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"]) @@ -139,13 +145,14 @@ def perform end end - describe "with tags from Rails 8 application.config.log_tags" do + describe "with tags from Rails 8 application.config.log_tags", :manual_start 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) @@ -159,6 +166,7 @@ def perform 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) @@ -172,8 +180,9 @@ def perform end end - describe "clearing all tags with clear_tags!" do + describe "clearing all tags with clear_tags!", :manual_start do it "in agent mode", :agent_mode do + start_agent allow(Appsignal::Extension).to receive(:log) logger.push_tags(["Request tag", "Second tag"]) @@ -200,6 +209,7 @@ def perform 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"]) @@ -226,7 +236,7 @@ def perform end end - describe "with tags passed as an array" do + describe "with tags passed as an array", :manual_start do def perform logger.tagged(["My tag", "My other tag"]) do logger.info("Some message") @@ -234,6 +244,7 @@ def perform end it "in agent mode", :agent_mode do + start_agent expect(Appsignal::Extension).to receive(:log) .with( "group", @@ -246,6 +257,7 @@ def perform end it "in collector mode", :collector_mode do + start_collector_agent expect(Appsignal::Logger::OpenTelemetryBackend).to receive(:emit) .with( "group", @@ -264,12 +276,13 @@ def perform # is present. if !DependencyHelper.rails_present? || DependencyHelper.rails7_present? describe "when calling #tagged without a block" do - describe "returns a new logger with the tags added" do + describe "returns a new logger with the tags added", :manual_start do def perform logger.tagged("My tag", "My other tag").info("Some message") end it "in agent mode", :agent_mode do + start_agent expect(Appsignal::Extension).to receive(:log) .with( "group", @@ -282,6 +295,7 @@ def perform end it "in collector mode", :collector_mode do + start_collector_agent expect(Appsignal::Logger::OpenTelemetryBackend).to receive(:emit) .with( "group", @@ -294,8 +308,9 @@ def perform end end - describe "does not modify the original logger" do + describe "does not modify the original logger", :manual_start do it "in agent mode", :agent_mode do + start_agent expect(Appsignal::Extension).to receive(:log) .with( "group", @@ -321,6 +336,7 @@ def perform end it "in collector mode", :collector_mode do + start_collector_agent expect(Appsignal::Logger::OpenTelemetryBackend).to receive(:emit) .with( "group", @@ -346,12 +362,13 @@ def perform end end - describe "can be chained" do + describe "can be chained", :manual_start do def perform logger.tagged("My tag", "My other tag").tagged("My third tag").info("Some message") end it "in agent mode", :agent_mode do + start_agent expect(Appsignal::Extension).to receive(:log) .with( "group", @@ -364,6 +381,7 @@ def perform end it "in collector mode", :collector_mode do + start_collector_agent expect(Appsignal::Logger::OpenTelemetryBackend).to receive(:emit) .with( "group", @@ -376,7 +394,7 @@ def perform end end - describe "can be chained before a block invocation" do + describe "can be chained before a block invocation", :manual_start do def perform # Use the logger passed to the block: the logger returned from # the first #tagged invocation is a new instance. @@ -386,6 +404,7 @@ def perform end it "in agent mode", :agent_mode do + start_agent expect(Appsignal::Extension).to receive(:log) .with( "group", @@ -398,6 +417,7 @@ def perform end it "in collector mode", :collector_mode do + start_collector_agent expect(Appsignal::Logger::OpenTelemetryBackend).to receive(:emit) .with( "group", @@ -410,7 +430,7 @@ def perform end end - describe "can be chained after a block invocation" do + describe "can be chained after a block invocation", :manual_start do def perform logger.tagged("My tag", "My other tag") do logger.tagged("My third tag").info("Some message") @@ -418,6 +438,7 @@ def perform end it "in agent mode", :agent_mode do + start_agent expect(Appsignal::Extension).to receive(:log) .with( "group", @@ -430,6 +451,7 @@ def perform end it "in collector mode", :collector_mode do + start_collector_agent expect(Appsignal::Logger::OpenTelemetryBackend).to receive(:emit) .with( "group", @@ -490,25 +512,27 @@ def perform end describe "#add" do - describe "with a level and message" do + describe "with a level and message", :manual_start 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 + describe "with a non-string message", :manual_start do def perform logger.add(::Logger::INFO, 123) logger.add(::Logger::INFO, {}) @@ -516,6 +540,7 @@ def perform 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) @@ -526,6 +551,7 @@ def 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) @@ -536,36 +562,40 @@ def perform end end - describe "with a block" do + describe "with a block", :manual_start 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 level, message and group" do + describe "with a level, message and group", :manual_start do def perform logger.add(::Logger::INFO, "Log message", "other_group") end 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 @@ -575,24 +605,26 @@ def perform describe "with info log level" do let(:logger) { Appsignal::Logger.new("group", :level => ::Logger::INFO) } - describe "when the call's level is too low" do + describe "when the call's level is too low", :manual_start 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 - describe "with the PLAINTEXT format set" do + describe "with the PLAINTEXT format set", :manual_start do let(:logger) { Appsignal::Logger.new("group", :format => Appsignal::Logger::PLAINTEXT) } def perform @@ -600,19 +632,21 @@ def perform 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)) 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 - describe "with the logfmt format set" do + describe "with the logfmt format set", :manual_start do let(:logger) { Appsignal::Logger.new("group", :format => Appsignal::Logger::LOGFMT) } def perform @@ -620,19 +654,21 @@ def perform 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)) 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 - describe "with the JSON format set" do + describe "with the JSON format set", :manual_start do let(:logger) { Appsignal::Logger.new("group", :format => Appsignal::Logger::JSON) } def perform @@ -640,12 +676,14 @@ def perform 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)) 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 @@ -659,12 +697,13 @@ def perform end end - describe "logs with a level, message and group" do + describe "logs with a level, message and group", :manual_start do def perform logger.add(::Logger::INFO, "Log message", "other_group") end it "in agent mode", :agent_mode do + start_agent expect(Appsignal::Extension).to receive(:log).with( "other_group", 3, @@ -676,6 +715,7 @@ def perform end it "in collector mode", :collector_mode do + start_collector_agent expect(Appsignal::Logger::OpenTelemetryBackend).to receive(:emit).with( "other_group", ::Logger::INFO, @@ -687,12 +727,13 @@ def perform end end - describe "calls the formatter with the original message" do + describe "calls the formatter with the original message", :manual_start 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", @@ -708,6 +749,7 @@ def perform end it "in collector mode", :collector_mode do + start_collector_agent expect(Appsignal::Logger::OpenTelemetryBackend).to receive(:emit) .with( "group", @@ -723,12 +765,13 @@ def perform end end - describe "calls #to_s on the formatter output if it is not a string" do + describe "calls #to_s on the formatter output if it is not a string", :manual_start 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) @@ -738,6 +781,7 @@ def 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) @@ -758,7 +802,7 @@ def perform end end - describe "silences the logger up to, but not including, the given level" do + describe "silences the logger up to, but not including, the given level", :manual_start do def perform logger.silence(::Logger::WARN) do logger.info("Log message") @@ -767,6 +811,7 @@ def perform end 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) @@ -775,6 +820,7 @@ def perform end 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) @@ -783,7 +829,7 @@ def perform end end - describe "silences the logger to error level by default" do + describe "silences the logger to error level by default", :manual_start do def perform logger.silence do logger.debug("Log message") @@ -795,6 +841,7 @@ def perform end 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)) @@ -807,6 +854,7 @@ def perform end 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", {}) @@ -821,7 +869,7 @@ def perform end describe "#broadcast_to" do - describe "broadcasts the message to the given logger" do + describe "broadcasts the message to the given logger", :manual_start do let(:other_device) { StringIO.new } let(:other_logger) { ::Logger.new(other_device) } before { logger.broadcast_to(other_logger) } @@ -832,19 +880,22 @@ def perform 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 "broadcasts the message to the given logger when it's below the log level" do + describe "broadcasts the message to the given logger when it's below the log level", + :manual_start do let(:logger) { Appsignal::Logger.new("group", :level => ::Logger::INFO) } let(:other_device) { StringIO.new } let(:other_logger) { ::Logger.new(other_device) } @@ -856,17 +907,19 @@ def perform 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 - describe "does not broadcast the message to the given logger when silenced" do + describe "does not broadcast the message to the given logger when silenced", :manual_start do let(:other_device) { StringIO.new } let(:other_logger) { ::Logger.new(other_device) } before { logger.broadcast_to(other_logger) } @@ -877,11 +930,13 @@ def perform 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 @@ -929,7 +984,7 @@ def perform ActiveSupport::TaggedLogging.new(appsignal_logger) end - describe "broadcasts a tagged message to the given logger" do + describe "broadcasts a tagged message to the given logger", :manual_start do def perform logger.tagged("My tag", "My other tag") do logger.info("Some message") @@ -938,6 +993,7 @@ def perform end it "in agent mode", :agent_mode do + start_agent expect(Appsignal::Extension).to receive(:log) .with( "group", @@ -950,6 +1006,7 @@ def perform end it "in collector mode", :collector_mode do + start_collector_agent expect(Appsignal::Logger::OpenTelemetryBackend).to receive(:emit) .with( "group", @@ -975,7 +1032,7 @@ def perform method, extension_level, logger_level, higher_level = permutation describe "##{method}" do - describe "with a message and attributes" do + describe "with a message and attributes", :manual_start 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`. @@ -984,6 +1041,7 @@ def perform end it "in agent mode", :agent_mode do + start_agent expect(Appsignal::Utils::Data).to receive(:generate) .with({ :attribute => "value" }) .and_call_original @@ -996,6 +1054,7 @@ def perform end it "in collector mode", :collector_mode do + start_collector_agent expect(Appsignal::Logger::OpenTelemetryBackend).to receive(:emit) .with( "group", @@ -1008,12 +1067,13 @@ def perform end end - describe "with a block" do + describe "with a block", :manual_start 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 @@ -1026,21 +1086,24 @@ def perform end 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 - describe "with a nil message" do + describe "with a nil message", :manual_start 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 @@ -1050,15 +1113,17 @@ def perform context "with a lower log level" do let(:logger) { Appsignal::Logger.new("group", :level => higher_level) } - describe "skips logging when the level is too low" do + describe "skips logging when the level is too low", :manual_start 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 @@ -1079,10 +1144,11 @@ def perform after { Timecop.return } - describe "logs the formatted message" do + describe "logs the formatted message", :manual_start 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", @@ -1095,6 +1161,7 @@ def perform end it "in collector mode", :collector_mode do + start_collector_agent expect(Appsignal::Logger::OpenTelemetryBackend).to receive(:emit) .with( "group", @@ -1113,12 +1180,13 @@ def perform describe "a logger with default attributes" do let(:logger) { Appsignal::Logger.new("group", :attributes => { :some_key => "some_value" }) } - describe "adds the attributes when a message is logged" do + describe "adds the attributes when a message is logged", :manual_start 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( @@ -1129,6 +1197,7 @@ def perform end it "in collector mode", :collector_mode do + start_collector_agent expect(Appsignal::Logger::OpenTelemetryBackend).to receive(:emit).with( "group", ::Logger::ERROR, @@ -1153,12 +1222,13 @@ def perform end end - describe "prioritises line attributes over default attributes" do + describe "prioritises line attributes over default attributes", :manual_start do def perform logger.error("Some message", { :some_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({ :some_key => "other_value" }) @@ -1167,6 +1237,7 @@ def perform end it "in collector mode", :collector_mode do + start_collector_agent expect(Appsignal::Logger::OpenTelemetryBackend).to receive(:emit).with( "group", ::Logger::ERROR, @@ -1178,12 +1249,13 @@ def perform end end - describe "adds the default attributes when #add is called" do + describe "adds the default attributes when #add is called", :manual_start 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" }) @@ -1192,6 +1264,7 @@ def perform end it "in collector mode", :collector_mode do + start_collector_agent expect(Appsignal::Logger::OpenTelemetryBackend).to receive(:emit).with( "group", ::Logger::INFO, @@ -1205,7 +1278,7 @@ def perform end describe "#error with exception object" do - describe "logs the exception class and its message" do + describe "logs the exception class and its message", :manual_start do let(:error) do raise ExampleStandardError, "oh no!" rescue => e @@ -1219,6 +1292,7 @@ def perform end it "in agent mode", :agent_mode do + start_agent expect(Appsignal::Extension).to receive(:log) .with( "group", @@ -1231,6 +1305,7 @@ def perform end it "in collector mode", :collector_mode do + start_collector_agent expect(Appsignal::Logger::OpenTelemetryBackend).to receive(:emit) .with( "group", @@ -1245,7 +1320,7 @@ def perform end describe "#<<" do - describe "writes an info message and returns the number of characters written" do + describe "writes an info message and returns the number of characters written", :manual_start do def perform message = "hello there" result = logger << message @@ -1253,12 +1328,14 @@ def perform end 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 @@ -1275,12 +1352,13 @@ def perform # 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 + describe "logs a formatted message", :manual_start 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) ) @@ -1288,6 +1366,7 @@ def 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'", {} ) diff --git a/spec/lib/appsignal/probes/gvl_spec.rb b/spec/lib/appsignal/probes/gvl_spec.rb index 968ba28b4..727855947 100644 --- a/spec/lib/appsignal/probes/gvl_spec.rb +++ b/spec/lib/appsignal/probes/gvl_spec.rb @@ -56,7 +56,7 @@ def expect_process_tag_split(snapshot, process_name) after { FakeGVLTools.reset } - describe "the global timer delta gauge" do + describe "the global timer delta gauge", :manual_start do def perform(probe) FakeGVLTools::GlobalTimer.monotonic_time = 100_000_000 probe.call @@ -65,6 +65,7 @@ def perform(probe) end 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) @@ -80,6 +81,7 @@ def perform(probe) end it "in collector mode", :collector_mode do + start_collector_agent perform(collector_probe) # The probe emits the gauge twice: once tagged with the process, once @@ -143,13 +145,14 @@ def perform(probe) FakeGVLTools::WaitingThreads.enabled = true end - describe "the waiting threads count gauge" do + describe "the waiting threads count gauge", :manual_start 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 [ @@ -163,6 +166,7 @@ def perform(probe) 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") diff --git a/spec/lib/appsignal/probes/mri_spec.rb b/spec/lib/appsignal/probes/mri_spec.rb index 5d20b40b1..5ca96e4f7 100644 --- a/spec/lib/appsignal/probes/mri_spec.rb +++ b/spec/lib/appsignal/probes/mri_spec.rb @@ -34,12 +34,13 @@ def vm_cache_metrics end end - describe "the vm cache gauges" do + describe "the vm cache gauges", :manual_start 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 }) @@ -47,6 +48,7 @@ def perform(probe) end it "in collector mode", :collector_mode do + start_collector_agent perform(collector_probe) snapshots = metric_snapshots @@ -57,17 +59,19 @@ def perform(probe) end end - describe "the thread count gauge" do + describe "the thread count gauge", :manual_start 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") @@ -79,7 +83,7 @@ def perform(probe) end end - describe "the gc time gauge" do + describe "the gc time gauge", :manual_start 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) @@ -88,11 +92,13 @@ def perform(probe) 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 @@ -126,7 +132,7 @@ def perform(probe) end context "when GC profiling is disabled" do - describe "the gc time gauge" do + describe "the gc time gauge", :manual_start do def perform(probe) allow(GC::Profiler).to receive(:enabled?).and_return(false) expect(gc_profiler_mock).to_not receive(:total_time) @@ -135,12 +141,14 @@ def perform(probe) end 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 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 @@ -183,7 +191,7 @@ def perform(probe) end end - describe "the gc run count gauge" do + describe "the gc run count gauge", :manual_start do # The gauges report deltas between measurements, so call twice. def perform(probe) expect(GC).to receive(:count).and_return(10, 15) @@ -196,6 +204,7 @@ def perform(probe) 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 }) @@ -203,6 +212,7 @@ def perform(probe) 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) @@ -211,7 +221,7 @@ def perform(probe) end end - describe "the allocated objects gauge" do + describe "the allocated objects gauge", :manual_start do # Only tracks the delta value, so it needs to be called twice. def perform(probe) expect(GC).to receive(:stat).and_return( @@ -223,28 +233,32 @@ def perform(probe) 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 - describe "the heap slots gauges" do + describe "the heap slots gauges", :manual_start 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) diff --git a/spec/lib/appsignal/probes/sidekiq_spec.rb b/spec/lib/appsignal/probes/sidekiq_spec.rb index a2c2def9b..2b83d147c 100644 --- a/spec/lib/appsignal/probes/sidekiq_spec.rb +++ b/spec/lib/appsignal/probes/sidekiq_spec.rb @@ -205,7 +205,8 @@ def with_sidekiq6! end end - it "loads Sidekiq::API", :agent_mode do + it "loads Sidekiq::API", :agent_mode, :manual_start 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. @@ -216,7 +217,8 @@ def with_sidekiq6! expect(defined?(Sidekiq::Stats)).to be_truthy end - it "logs config on initialize", :agent_mode do + it "logs config on initialize", :agent_mode, :manual_start do + start_agent with_sidekiq! log = capture_logs { probe } expect(log).to contains_log(:debug, "Initializing Sidekiq probe\n") @@ -225,7 +227,8 @@ def with_sidekiq6! context "with Sidekiq 7" do before { with_sidekiq7! } - it "logs used hostname on call once", :agent_mode do + it "logs used hostname on call once", :agent_mode, :manual_start do + start_agent log = capture_logs { probe.call } expect(log).to contains_log( :debug, @@ -236,7 +239,7 @@ def with_sidekiq6! expect(log).to_not contains_log(:debug, %(Sidekiq probe: )) end - describe "collecting custom metrics" do + describe "collecting custom metrics", :manual_start do # Call the probe twice so the delta-based gauges report a value. def perform probe.call @@ -244,11 +247,13 @@ def perform 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 @@ -257,7 +262,7 @@ def perform context "when redis info doesn't contain requested keys" do before { Sidekiq7Mock.redis_info_data = {} } - describe "the redis info gauges" do + describe "the redis info gauges", :manual_start do # Call probe twice so we can calculate the delta for some gauge values. def perform probe.call @@ -265,6 +270,7 @@ def perform 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 @@ -272,6 +278,7 @@ def 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") @@ -285,7 +292,8 @@ def perform context "with Sidekiq 6" do before { with_sidekiq6! } - it "logs used hostname on call once", :agent_mode do + it "logs used hostname on call once", :agent_mode, :manual_start do + start_agent log = capture_logs { probe.call } expect(log).to contains_log( :debug, @@ -296,7 +304,7 @@ def perform expect(log).to_not contains_log(:debug, %(Sidekiq probe: )) end - describe "collecting custom metrics" do + describe "collecting custom metrics", :manual_start do # Call the probe twice so the delta-based gauges report a value. def perform probe.call @@ -304,11 +312,13 @@ def perform 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 @@ -319,8 +329,9 @@ def perform allow(Sidekiq).to receive(:respond_to?).with(:redis_info).and_return(false) end - describe "the redis info gauges" do + describe "the redis info gauges", :manual_start 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 @@ -328,6 +339,7 @@ def perform 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") @@ -338,11 +350,12 @@ def perform end end - context "when hostname is configured for probe" do + context "when hostname is configured for probe", :manual_start do let(:redis_hostname) { "my_redis_server" } let(:probe) { described_class.new(:hostname => redis_hostname) } 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 @@ -361,6 +374,7 @@ def perform end it "tags the emitted gauges with the configured hostname", :collector_mode do + start_collector_agent with_sidekiq! probe.call diff --git a/spec/lib/appsignal/rack/body_wrapper_spec.rb b/spec/lib/appsignal/rack/body_wrapper_spec.rb index 78efd29da..912e0659a 100644 --- a/spec/lib/appsignal/rack/body_wrapper_spec.rb +++ b/spec/lib/appsignal/rack/body_wrapper_spec.rb @@ -75,7 +75,7 @@ def expect_collector_no_event(name) expect(wrapped).to respond_to(:close) end - describe "reads out the body in full using each" do + describe "reads out the body in full using each", :manual_start do def perform fake_body = double expect(fake_body).to receive(:each).once.and_yield("a").and_yield("b").and_yield("c") @@ -85,6 +85,8 @@ def perform end it "in agent mode", :agent_mode do + start_agent + perform expect(transaction).to include_event( @@ -94,6 +96,8 @@ def perform end it "in collector mode", :collector_mode do + start_collector_agent + perform expect_collector_event( @@ -103,7 +107,7 @@ def perform end end - describe "returns an Enumerator if each() gets called without a block" do + describe "returns an Enumerator if each() gets called without a block", :manual_start do def perform fake_body = double expect(fake_body).to receive(:each).once.and_yield("a").and_yield("b").and_yield("c") @@ -115,12 +119,16 @@ def perform end it "in agent mode", :agent_mode do + start_agent + perform 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 @@ -136,7 +144,7 @@ def perform end end - describe "sets the exception raised inside each() on the transaction" do + describe "sets the exception raised inside each() on the transaction", :manual_start do def perform fake_body = double expect(fake_body).to receive(:each).once.and_raise(ExampleException, "error message") @@ -148,19 +156,23 @@ def perform end it "in agent mode", :agent_mode do + start_agent + perform expect(transaction).to have_error("ExampleException", "error message") end it "in collector mode", :collector_mode do + start_collector_agent + perform expect_collector_error("ExampleException", "error message") end end - describe "doesn't report EPIPE error" do + describe "doesn't report EPIPE error", :manual_start do def perform fake_body = double expect(fake_body).to receive(:each).once.and_raise(Errno::EPIPE) @@ -172,17 +184,21 @@ def perform end it "in agent mode", :agent_mode do + start_agent + perform expect(transaction).to_not have_error end it "in collector mode", :collector_mode do + start_collector_agent + perform expect_collector_no_error end end - describe "doesn't report ECONNRESET error" do + describe "doesn't report ECONNRESET error", :manual_start do def perform fake_body = double expect(fake_body).to receive(:each).once.and_raise(Errno::ECONNRESET) @@ -194,17 +210,21 @@ def perform end it "in agent mode", :agent_mode do + start_agent + perform expect(transaction).to_not have_error end it "in collector mode", :collector_mode do + start_collector_agent + perform expect_collector_no_error end end - describe "does not report EPIPE error when it's the error cause" do + describe "does not report EPIPE error when it's the error cause", :manual_start do def perform error = error_with_cause(StandardError, "error message", Errno::EPIPE) fake_body = double @@ -217,17 +237,21 @@ def perform end it "in agent mode", :agent_mode do + start_agent + perform expect(transaction).to_not have_error end it "in collector mode", :collector_mode do + start_collector_agent + perform expect_collector_no_error end end - describe "does not report EPIPE error when it's the nested error cause" do + describe "does not report EPIPE error when it's the nested error cause", :manual_start do def perform error = error_with_nested_cause(StandardError, "error message", Errno::EPIPE) fake_body = double @@ -240,17 +264,21 @@ def perform end it "in agent mode", :agent_mode do + start_agent + perform expect(transaction).to_not have_error end it "in collector mode", :collector_mode do + start_collector_agent + perform expect_collector_no_error end end - describe "does not report ECONNRESET error when it's the error cause" do + describe "does not report ECONNRESET error when it's the error cause", :manual_start do def perform error = error_with_cause(StandardError, "error message", Errno::ECONNRESET) fake_body = double @@ -263,17 +291,21 @@ def perform end it "in agent mode", :agent_mode do + start_agent + perform expect(transaction).to_not have_error end it "in collector mode", :collector_mode do + start_collector_agent + perform expect_collector_no_error end end - describe "does not report ECONNRESET error when it's the nested error cause" do + describe "does not report ECONNRESET error when it's the nested error cause", :manual_start do def perform error = error_with_nested_cause(StandardError, "error message", Errno::ECONNRESET) fake_body = double @@ -286,17 +318,22 @@ def perform end it "in agent mode", :agent_mode do + start_agent + perform expect(transaction).to_not have_error end it "in collector mode", :collector_mode do + start_collector_agent + perform expect_collector_no_error end end - describe "closes the body and tracks an instrumentation event when it gets closed" do + describe "closes the body and tracks an instrumentation event when it gets closed", + :manual_start do def perform fake_body = double(:close => nil) expect(fake_body).to receive(:each).once.and_yield("a").and_yield("b").and_yield("c") @@ -307,19 +344,23 @@ def perform 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 + describe "reports an error if an error occurs on close", :manual_start do def perform fake_body = double expect(fake_body).to receive(:close).and_raise(ExampleException, "error message") @@ -331,19 +372,23 @@ def perform end it "in agent mode", :agent_mode do + start_agent + perform expect(transaction).to have_error("ExampleException", "error message") end it "in collector mode", :collector_mode do + start_collector_agent + perform expect_collector_error("ExampleException", "error message") end end - describe "doesn't report EPIPE error on close" do + describe "doesn't report EPIPE error on close", :manual_start do def perform fake_body = double expect(fake_body).to receive(:close).and_raise(Errno::EPIPE) @@ -353,17 +398,21 @@ def perform end it "in agent mode", :agent_mode do + start_agent + perform expect(transaction).to_not have_error end it "in collector mode", :collector_mode do + start_collector_agent + perform expect_collector_no_error end end - describe "doesn't report ECONNRESET error on close" do + describe "doesn't report ECONNRESET error on close", :manual_start do def perform fake_body = double expect(fake_body).to receive(:close).and_raise(Errno::ECONNRESET) @@ -373,17 +422,21 @@ def perform end it "in agent mode", :agent_mode do + start_agent + perform expect(transaction).to_not have_error end it "in collector mode", :collector_mode do + start_collector_agent + perform expect_collector_no_error end end - describe "does not report EPIPE error when it's the error cause on close" do + describe "does not report EPIPE error when it's the error cause on close", :manual_start do def perform error = error_with_cause(StandardError, "error message", Errno::EPIPE) fake_body = double @@ -394,17 +447,21 @@ def perform end it "in agent mode", :agent_mode do + start_agent + perform expect(transaction).to_not have_error end it "in collector mode", :collector_mode do + start_collector_agent + perform expect_collector_no_error end end - describe "does not report ECONNRESET error when it's the error cause on close" do + describe "does not report ECONNRESET error when it's the error cause on close", :manual_start do def perform error = error_with_cause(StandardError, "error message", Errno::ECONNRESET) fake_body = double @@ -415,11 +472,15 @@ def perform 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 @@ -455,7 +516,7 @@ def perform expect(wrapped).to respond_to(:close) end - describe "reads out the body in full using each" do + describe "reads out the body in full using each", :manual_start do def perform expect(fake_body).to receive(:each).once.and_yield("a").and_yield("b").and_yield("c") @@ -464,6 +525,8 @@ def perform end it "in agent mode", :agent_mode do + start_agent + perform expect(transaction).to include_event( @@ -473,6 +536,8 @@ def perform end it "in collector mode", :collector_mode do + start_collector_agent + perform expect_collector_event( @@ -482,7 +547,8 @@ def perform end end - describe "sets the exception raised inside each() into the Appsignal transaction" do + describe "sets the exception raised inside each() into the Appsignal transaction", + :manual_start do def perform expect(fake_body).to receive(:each).once.and_raise(ExampleException, "error message") @@ -493,19 +559,23 @@ def perform end it "in agent mode", :agent_mode do + start_agent + perform expect(transaction).to have_error("ExampleException", "error message") end it "in collector mode", :collector_mode do + start_collector_agent + perform expect_collector_error("ExampleException", "error message") end end - describe "doesn't report EPIPE error" do + describe "doesn't report EPIPE error", :manual_start do def perform expect(fake_body).to receive(:each).once.and_raise(Errno::EPIPE) @@ -516,17 +586,21 @@ def perform end it "in agent mode", :agent_mode do + start_agent + perform expect(transaction).to_not have_error end it "in collector mode", :collector_mode do + start_collector_agent + perform expect_collector_no_error end end - describe "doesn't report ECONNRESET error" do + describe "doesn't report ECONNRESET error", :manual_start do def perform expect(fake_body).to receive(:each).once.and_raise(Errno::ECONNRESET) @@ -537,17 +611,21 @@ def perform end it "in agent mode", :agent_mode do + start_agent + perform expect(transaction).to_not have_error end it "in collector mode", :collector_mode do + start_collector_agent + perform expect_collector_no_error end end - describe "does not report EPIPE error when it's the error cause (each)" do + describe "does not report EPIPE error when it's the error cause (each)", :manual_start do def perform error = error_with_cause(StandardError, "error message", Errno::EPIPE) fake_body = double @@ -560,17 +638,21 @@ def perform end it "in agent mode", :agent_mode do + start_agent + perform expect(transaction).to_not have_error end it "in collector mode", :collector_mode do + start_collector_agent + perform expect_collector_no_error end end - describe "does not report ECONNRESET error when it's the error cause (each)" do + describe "does not report ECONNRESET error when it's the error cause (each)", :manual_start do def perform error = error_with_cause(StandardError, "error message", Errno::ECONNRESET) fake_body = double @@ -583,17 +665,21 @@ def perform end it "in agent mode", :agent_mode do + start_agent + perform expect(transaction).to_not have_error end it "in collector mode", :collector_mode do + start_collector_agent + perform expect_collector_no_error end end - describe "reads out the body in full using to_ary" do + describe "reads out the body in full using to_ary", :manual_start do def perform expect(fake_body).to receive(:to_ary).and_return(["one", "two", "three"]) @@ -602,6 +688,8 @@ def perform end it "in agent mode", :agent_mode do + start_agent + perform expect(transaction).to include_event( @@ -611,6 +699,8 @@ def perform end it "in collector mode", :collector_mode do + start_collector_agent + perform expect_collector_event( @@ -620,7 +710,7 @@ def perform end end - describe "sends the exception raised inside to_ary() to AppSignal and closes" do + describe "sends the exception raised inside to_ary() to AppSignal and closes", :manual_start do def perform fake_body = double allow(fake_body).to receive(:each) @@ -634,19 +724,23 @@ def perform end it "in agent mode", :agent_mode do + start_agent + perform expect(transaction).to have_error("ExampleException", "error message") end it "in collector mode", :collector_mode do + start_collector_agent + perform expect_collector_error("ExampleException", "error message") end end - describe "does not report EPIPE error when it's the error cause (to_ary)" do + describe "does not report EPIPE error when it's the error cause (to_ary)", :manual_start do def perform error = error_with_cause(StandardError, "error message", Errno::EPIPE) fake_body = double @@ -661,17 +755,21 @@ def perform end it "in agent mode", :agent_mode do + start_agent + perform expect(transaction).to_not have_error end it "in collector mode", :collector_mode do + start_collector_agent + perform expect_collector_no_error end end - describe "does not report ECONNRESET error when it's the error cause (to_ary)" do + describe "does not report ECONNRESET error when it's the error cause (to_ary)", :manual_start do def perform error = error_with_cause(StandardError, "error message", Errno::ECONNRESET) fake_body = double @@ -686,11 +784,15 @@ def perform 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 @@ -709,7 +811,7 @@ def perform expect(wrapped).to respond_to(:close) end - describe "reads out the body in full using each()" do + describe "reads out the body in full using each()", :manual_start do def perform expect(fake_body).to receive(:each).once.and_yield("a").and_yield("b").and_yield("c") @@ -718,6 +820,8 @@ def perform end it "in agent mode", :agent_mode do + start_agent + perform expect(transaction).to include_event( @@ -727,6 +831,8 @@ def perform end it "in collector mode", :collector_mode do + start_collector_agent + perform expect_collector_event( @@ -736,7 +842,8 @@ def perform end end - describe "sets the exception raised inside each() into the Appsignal transaction" do + describe "sets the exception raised inside each() into the Appsignal transaction", + :manual_start do def perform expect(fake_body).to receive(:each).once.and_raise(ExampleException, "error message") @@ -747,19 +854,24 @@ def perform end it "in agent mode", :agent_mode do + start_agent + perform expect(transaction).to have_error("ExampleException", "error message") end it "in collector mode", :collector_mode do + start_collector_agent + perform expect_collector_error("ExampleException", "error message") end end - describe "sets the exception raised inside to_path() into the Appsignal transaction" do + describe "sets the exception raised inside to_path() into the Appsignal transaction", + :manual_start do def perform allow(fake_body).to receive(:to_path).once.and_raise(ExampleException, "error message") @@ -770,19 +882,23 @@ def perform end it "in agent mode", :agent_mode do + start_agent + perform expect(transaction).to have_error("ExampleException", "error message") end it "in collector mode", :collector_mode do + start_collector_agent + perform expect_collector_error("ExampleException", "error message") end end - describe "doesn't report EPIPE error" do + describe "doesn't report EPIPE error", :manual_start do def perform expect(fake_body).to receive(:to_path).once.and_raise(Errno::EPIPE) @@ -793,17 +909,21 @@ def perform end it "in agent mode", :agent_mode do + start_agent + perform expect(transaction).to_not have_error end it "in collector mode", :collector_mode do + start_collector_agent + perform expect_collector_no_error end end - describe "doesn't report ECONNRESET error" do + describe "doesn't report ECONNRESET error", :manual_start do def perform expect(fake_body).to receive(:to_path).once.and_raise(Errno::ECONNRESET) @@ -814,17 +934,21 @@ def perform end it "in agent mode", :agent_mode do + start_agent + perform expect(transaction).to_not have_error end it "in collector mode", :collector_mode do + start_collector_agent + perform expect_collector_no_error end end - describe "does not report EPIPE error from #each when it's the error cause" do + describe "does not report EPIPE error from #each when it's the error cause", :manual_start do def perform error = error_with_cause(StandardError, "error message", Errno::EPIPE) expect(fake_body).to receive(:each).once.and_raise(error) @@ -836,17 +960,22 @@ def perform end it "in agent mode", :agent_mode do + start_agent + perform expect(transaction).to_not have_error end it "in collector mode", :collector_mode do + start_collector_agent + perform expect_collector_no_error end end - describe "does not report ECONNRESET error from #each when it's the error cause" do + describe "does not report ECONNRESET error from #each when it's the error cause", + :manual_start do def perform error = error_with_cause(StandardError, "error message", Errno::ECONNRESET) expect(fake_body).to receive(:each).once.and_raise(error) @@ -858,17 +987,21 @@ def perform end it "in agent mode", :agent_mode do + start_agent + perform expect(transaction).to_not have_error end it "in collector mode", :collector_mode do + start_collector_agent + perform expect_collector_no_error end end - describe "does not report EPIPE error from #to_path when it's the error cause" do + describe "does not report EPIPE error from #to_path when it's the error cause", :manual_start do def perform error = error_with_cause(StandardError, "error message", Errno::EPIPE) allow(fake_body).to receive(:to_path).once.and_raise(error) @@ -880,17 +1013,22 @@ def perform end it "in agent mode", :agent_mode do + start_agent + perform expect(transaction).to_not have_error end it "in collector mode", :collector_mode do + start_collector_agent + perform expect_collector_no_error end end - describe "does not report ECONNRESET error from #to_path when it's the error cause" do + describe "does not report ECONNRESET error from #to_path when it's the error cause", + :manual_start do def perform error = error_with_cause(StandardError, "error message", Errno::ECONNRESET) allow(fake_body).to receive(:to_path).once.and_raise(error) @@ -902,17 +1040,21 @@ def perform end it "in agent mode", :agent_mode do + start_agent + perform expect(transaction).to_not have_error end it "in collector mode", :collector_mode do + start_collector_agent + perform expect_collector_no_error end end - describe "exposes to_path to the sender" do + describe "exposes to_path to the sender", :manual_start do def perform allow(fake_body).to receive(:to_path).and_return("/tmp/file.bin") @@ -921,6 +1063,8 @@ def perform end it "in agent mode", :agent_mode do + start_agent + perform expect(transaction).to include_event( @@ -930,6 +1074,8 @@ def perform end it "in collector mode", :collector_mode do + start_collector_agent + perform expect_collector_event( @@ -952,7 +1098,7 @@ def perform expect(wrapped).to respond_to(:close) end - describe "passes the stream into the call() of the body" do + describe "passes the stream into the call() of the body", :manual_start do def perform fake_rack_stream = double("stream") expect(fake_body).to receive(:call).with(fake_rack_stream) @@ -962,6 +1108,8 @@ def perform end it "in agent mode", :agent_mode do + start_agent + perform expect(transaction).to include_event( @@ -971,6 +1119,8 @@ def perform end it "in collector mode", :collector_mode do + start_collector_agent + perform expect_collector_event( @@ -980,7 +1130,8 @@ def perform end end - describe "sets the exception raised inside call() into the Appsignal transaction" do + describe "sets the exception raised inside call() into the Appsignal transaction", + :manual_start do def perform fake_rack_stream = double allow(fake_body).to receive(:call) @@ -995,19 +1146,23 @@ def perform end it "in agent mode", :agent_mode do + start_agent + perform expect(transaction).to have_error("ExampleException", "error message") end it "in collector mode", :collector_mode do + start_collector_agent + perform expect_collector_error("ExampleException", "error message") end end - describe "doesn't report EPIPE error" do + describe "doesn't report EPIPE error", :manual_start do def perform fake_rack_stream = double expect(fake_body).to receive(:call) @@ -1021,17 +1176,21 @@ def perform end it "in agent mode", :agent_mode do + start_agent + perform expect(transaction).to_not have_error end it "in collector mode", :collector_mode do + start_collector_agent + perform expect_collector_no_error end end - describe "doesn't report ECONNRESET error" do + describe "doesn't report ECONNRESET error", :manual_start do def perform fake_rack_stream = double expect(fake_body).to receive(:call) @@ -1045,17 +1204,21 @@ def perform end it "in agent mode", :agent_mode do + start_agent + perform expect(transaction).to_not have_error end it "in collector mode", :collector_mode do + start_collector_agent + perform expect_collector_no_error end end - describe "does not report EPIPE error from #call when it's the error cause" do + describe "does not report EPIPE error from #call when it's the error cause", :manual_start do def perform error = error_with_cause(StandardError, "error message", Errno::EPIPE) fake_rack_stream = double @@ -1071,17 +1234,22 @@ def perform end it "in agent mode", :agent_mode do + start_agent + perform expect(transaction).to_not have_error end it "in collector mode", :collector_mode do + start_collector_agent + perform expect_collector_no_error end end - describe "does not report ECONNRESET error from #call when it's the error cause" do + describe "does not report ECONNRESET error from #call when it's the error cause", + :manual_start do def perform error = error_with_cause(StandardError, "error message", Errno::ECONNRESET) fake_rack_stream = double @@ -1097,11 +1265,15 @@ def perform 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 diff --git a/spec/lib/appsignal/rack/event_handler_spec.rb b/spec/lib/appsignal/rack/event_handler_spec.rb index 8fb7af609..9e2b79b6b 100644 --- a/spec/lib/appsignal/rack/event_handler_spec.rb +++ b/spec/lib/appsignal/rack/event_handler_spec.rb @@ -498,13 +498,15 @@ def on_finish(given_request = request, given_response = response) end end - describe "for a successful request" do + describe "for a successful request", :manual_start 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) @@ -512,6 +514,8 @@ def perform end it "in collector mode", :collector_mode do + start_collector_agent + perform snapshot = metric_snapshot("response_status") @@ -524,7 +528,7 @@ def perform end end - describe "for a request that errors" do + describe "for a request that errors", :manual_start do # No response, and an error recorded by `on_error`, so the status comes # from the error (500) rather than the response. def perform @@ -534,6 +538,8 @@ def perform end it "in agent mode", :agent_mode do + start_agent + expect(Appsignal).to receive(:increment_counter) .with(:response_status, 1, :status => 500, :namespace => :web) @@ -541,6 +547,8 @@ def perform end it "in collector mode", :collector_mode do + start_collector_agent + perform snapshot = metric_snapshot("response_status") diff --git a/spec/lib/appsignal/rack/sinatra_instrumentation_spec.rb b/spec/lib/appsignal/rack/sinatra_instrumentation_spec.rb index 6a6e88aec..dfc510e33 100644 --- a/spec/lib/appsignal/rack/sinatra_instrumentation_spec.rb +++ b/spec/lib/appsignal/rack/sinatra_instrumentation_spec.rb @@ -104,18 +104,20 @@ def make_request_with_error(error) context "when appsignal is active" do context "without an error" do - describe "creates a transaction for the request" do + describe "creates a transaction for the request", :manual_start do def perform make_request end it "in agent mode", :agent_mode do + start_agent(**start_agent_args) expect { perform }.to(change { created_transactions.count }.by(1)) expect(last_transaction).to have_namespace(Appsignal::Transaction::HTTP_REQUEST) end it "in collector mode", :collector_mode do + start_collector_agent expect { perform }.to(change { created_transactions.count }.by(1)) expect(root_span.attributes["appsignal.namespace"]) @@ -124,18 +126,20 @@ def perform end end - describe "reports a process_action.sinatra event" do + describe "reports a process_action.sinatra event", :manual_start 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 it "in collector mode", :collector_mode do + start_collector_agent perform span = event_spans.find { |s| s.name == "process_action.sinatra" } @@ -149,18 +153,20 @@ def perform let(:error) { ExampleException.new("error message") } before { env["sinatra.error"] = error } - describe "creates a transaction for the request" do + describe "creates a transaction for the request", :manual_start do def perform make_request end it "in agent mode", :agent_mode do + start_agent(**start_agent_args) expect { perform }.to(change { created_transactions.count }.by(1)) expect(last_transaction).to have_namespace(Appsignal::Transaction::HTTP_REQUEST) end it "in collector mode", :collector_mode do + start_collector_agent expect { perform }.to(change { created_transactions.count }.by(1)) expect(root_span.attributes["appsignal.namespace"]) @@ -171,18 +177,20 @@ def perform context "when raise_errors is off" do let(:settings) { double(:raise_errors => false) } - describe "records the error" do + describe "records the error", :manual_start do def perform make_request 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" } @@ -199,18 +207,20 @@ def perform context "when raise_errors is on" do let(:settings) { double(:raise_errors => true) } - describe "does not record the error" do + describe "does not record the error", :manual_start 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 @@ -226,18 +236,20 @@ def perform ) end - describe "does not record the error" do + describe "does not record the error", :manual_start 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 @@ -247,18 +259,20 @@ def perform end describe "action name" do - describe "sets the action to the request method and path" do + describe "sets the action to the request method and path", :manual_start 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(root_span.name).to eq("GET /path") @@ -271,18 +285,20 @@ def perform Rack::MockRequest.env_for("/path", "REQUEST_METHOD" => "GET") end - describe "doesn't set an action name" do + describe "doesn't set an action name", :manual_start 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(root_span.attributes).to_not have_key("appsignal.action_name") @@ -293,18 +309,20 @@ def perform context "with mounted modular application" do before { env["SCRIPT_NAME"] = "/api" } - describe "sets the action name with an application prefix path" do + describe "sets the action name with an application prefix path", :manual_start 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") end it "in collector mode", :collector_mode do + start_collector_agent perform expect(root_span.name).to eq("GET /api/path") @@ -317,18 +335,20 @@ def perform Rack::MockRequest.env_for("/path", "REQUEST_METHOD" => "GET") end - describe "doesn't set an action name" do + describe "doesn't set an action name", :manual_start 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(root_span.attributes).to_not have_key("appsignal.action_name") diff --git a/spec/lib/appsignal/transaction_spec.rb b/spec/lib/appsignal/transaction_spec.rb index b3c712d1d..00642e2ea 100644 --- a/spec/lib/appsignal/transaction_spec.rb +++ b/spec/lib/appsignal/transaction_spec.rb @@ -7,12 +7,19 @@ # Skip start_agent for :collector_mode examples -- the shared context boots # Appsignal with the collector endpoint instead, and `Appsignal.start` is a # no-op once started so the order would otherwise leave the test in agent - # mode. - unless example.metadata[:collector_mode] + # mode. Also skip for `:manual_start` examples, which start the agent in + # their own body (agent mode via `start_agent(**start_agent_args)`, + # collector mode via `start_collector_agent`). + unless example.metadata[:collector_mode] || example.metadata[:manual_start] start_agent(:options => options, :root_path => root_path) end Timecop.freeze(time) end + + # Per the dual-mode start principle, `:manual_start` agent-mode examples call + # `start_agent(**start_agent_args)` 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 @@ -115,8 +122,9 @@ end end - describe "OpenTelemetry root span" do + describe "OpenTelemetry root span", :manual_start 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! @@ -127,6 +135,7 @@ end it "uses SpanKind::CONSUMER for BACKGROUND_JOB", :collector_mode do + start_collector_agent create_transaction(Appsignal::Transaction::BACKGROUND_JOB) Appsignal::Transaction.complete_current! @@ -134,6 +143,7 @@ end it "uses SpanKind::SERVER for ACTION_CABLE", :collector_mode do + start_collector_agent create_transaction(Appsignal::Transaction::ACTION_CABLE) Appsignal::Transaction.complete_current! @@ -141,6 +151,7 @@ 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! @@ -150,8 +161,9 @@ end end - describe "OpenTelemetry current context" do + describe "OpenTelemetry current context", :manual_start 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) @@ -622,8 +634,9 @@ end end - describe "OpenTelemetry span emission" do + describe "OpenTelemetry span emission", :manual_start 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 @@ -1495,12 +1508,13 @@ let(:transaction) { new_transaction } let(:action_name) { "PagesController#show" } - context "when the action is set" do + context "when the action is set", :manual_start do 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) @@ -1508,6 +1522,7 @@ def perform end it "in collector mode", :collector_mode do + start_collector_agent perform expect(transaction.action).to eq(action_name) @@ -1517,13 +1532,14 @@ def perform end end - context "when the action is nil" do + context "when the action is nil", :manual_start do 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) @@ -1531,6 +1547,7 @@ def perform end it "in collector mode", :collector_mode do + start_collector_agent perform expect(transaction.action).to eq(action_name) @@ -1544,7 +1561,7 @@ def perform describe "#set_action_if_nil" do let(:transaction) { new_transaction } - context "when the action is not set" do + context "when the action is not set", :manual_start do let(:action_name) { "PagesController#show" } def perform @@ -1552,6 +1569,7 @@ def perform 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 @@ -1562,6 +1580,7 @@ def perform end it "in collector mode", :collector_mode do + start_collector_agent expect(transaction.action).to eq(nil) perform @@ -1581,6 +1600,7 @@ def perform end it "in agent mode", :agent_mode do + start_agent(**start_agent_args) perform expect(transaction.action).to eq(action_name) @@ -1588,6 +1608,7 @@ def perform end it "in collector mode", :collector_mode do + start_collector_agent perform expect(transaction.action).to eq(action_name) @@ -1597,7 +1618,7 @@ def perform end end - context "when the action is set" do + context "when the action is set", :manual_start do let(:action_name) { "something" } def perform @@ -1606,6 +1627,7 @@ def perform end it "in agent mode", :agent_mode do + start_agent(**start_agent_args) perform expect(transaction.action).to eq(action_name) @@ -1613,6 +1635,7 @@ def perform end it "in collector mode", :collector_mode do + start_collector_agent perform expect(transaction.action).to eq(action_name) @@ -1627,12 +1650,13 @@ def perform let(:transaction) { new_transaction } let(:namespace) { "custom" } - context "when the namespace is not nil" do + context "when the namespace is not nil", :manual_start do 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 @@ -1640,6 +1664,7 @@ def perform end it "in collector mode", :collector_mode do + start_collector_agent perform expect(transaction.namespace).to eq namespace @@ -1648,13 +1673,14 @@ def perform end end - context "when the namespace is nil" do + context "when the namespace is nil", :manual_start do 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) @@ -1662,6 +1688,7 @@ def perform end it "in collector mode", :collector_mode do + start_collector_agent perform expect(transaction.namespace).to eq(namespace) @@ -1670,8 +1697,9 @@ def perform end end - context "when set_namespace is never called", :collector_mode do + context "when set_namespace is never called", :collector_mode, :manual_start do it "carries the namespace from creation" do + start_collector_agent transaction = http_request_transaction transaction.complete @@ -1982,12 +2010,13 @@ def to_s end end - describe "recording the error on the span" do + describe "recording the error on the span", :manual_start 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( @@ -1998,6 +2027,7 @@ def perform end it "in collector mode", :collector_mode do + start_collector_agent perform transaction.complete @@ -2011,7 +2041,7 @@ def perform end end - describe "recording an error that has causes" do + describe "recording an error that has causes", :manual_start do let(:error) do cause = ExampleStandardError.new("cause message").tap do |e| e.set_backtrace(["/path/cause.rb:1:in `cause_method'"]) @@ -2030,6 +2060,7 @@ def perform end it "in agent mode", :agent_mode do + start_agent(**start_agent_args) perform expect(transaction).to have_error("ExampleException", "wrapper message") @@ -2039,6 +2070,7 @@ def perform end it "in collector mode", :collector_mode do + start_collector_agent perform transaction.complete @@ -2058,7 +2090,7 @@ def perform end end - describe "recording multiple errors" do + describe "recording multiple errors", :manual_start do let(:other_error) do ExampleStandardError.new("other message").tap { |e| e.set_backtrace(["line 2"]) } end @@ -2069,6 +2101,7 @@ def perform end it "in agent mode", :agent_mode do + start_agent(**start_agent_args) perform # The extension holds one error per transaction, so the extra error is # reported as a duplicate transaction. @@ -2084,6 +2117,7 @@ def perform end it "in collector mode", :collector_mode do + start_collector_agent perform transaction.complete @@ -2103,7 +2137,8 @@ def perform # 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 + it "records the error on the current event span", :collector_mode, :manual_start do + start_collector_agent transaction.start_event transaction.add_error(error) transaction.finish_event("query", "title", "body", Appsignal::EventFormatter::DEFAULT) @@ -2117,7 +2152,8 @@ def perform # 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 + it "applies error blocks in order, last-added error wins", :collector_mode, :manual_start 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") } @@ -2239,7 +2275,7 @@ def perform end end - context "with a PG::UniqueViolation" do + context "with a PG::UniqueViolation", :manual_start do let(:error) do PG::UniqueViolation.new( "ERROR: duplicate key value violates unique constraint " \ @@ -2257,12 +2293,14 @@ def perform end 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 @@ -2272,7 +2310,7 @@ def perform end end - context "with a ActiveRecord::RecordNotUnique" do + context "with a ActiveRecord::RecordNotUnique", :manual_start do let(:error) do ActiveRecord::RecordNotUnique.new( "PG::UniqueViolation: ERROR: duplicate key value violates unique constraint " \ @@ -2290,12 +2328,14 @@ def perform end 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 @@ -2305,7 +2345,7 @@ def perform end end - context "with Rails module but without backtrace_cleaner method" do + context "with Rails module but without backtrace_cleaner method", :manual_start do def perform stub_const("Rails", Module.new) error = ExampleStandardError.new("error message") @@ -2314,6 +2354,7 @@ def perform end it "returns the backtrace uncleaned in agent mode", :agent_mode do + start_agent(**start_agent_args) perform expect(last_transaction).to have_error( @@ -2324,6 +2365,7 @@ def perform end it "records the backtrace uncleaned in collector mode", :collector_mode do + start_collector_agent perform transaction.complete @@ -2333,7 +2375,7 @@ def perform end if rails_present? - context "with Rails" do + context "with Rails", :manual_start do let(:test_filter) do lambda do |line| if Appsignal::Testing.store[:enable_rails_backtrace_line_filter] @@ -2355,6 +2397,7 @@ def perform 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( @@ -2366,6 +2409,7 @@ def perform it "cleans the backtrace with the Rails backtrace cleaner in collector mode", :collector_mode do + start_collector_agent perform transaction.complete @@ -2419,13 +2463,15 @@ def exception_event end context "when the error has no causes" do - it "should set an empty causes array as sample data", :agent_mode do + it "should set an empty causes array as sample data", :agent_mode, :manual_start 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 + it "sets no error causes attribute in collector mode", :collector_mode, :manual_start do + start_collector_agent transaction.send(:_set_error, error) transaction.complete @@ -2468,7 +2514,8 @@ def exception_event end let(:options) { { :revision => "my_revision" } } - it "sends the error causes information as sample data", :agent_mode do + it "sends the error causes information as sample data", :agent_mode, :manual_start 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") @@ -2522,7 +2569,9 @@ def exception_event # 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 + it "records the error causes on the exception event in collector mode", :collector_mode, + :manual_start do + start_collector_agent hide_const("Rails") transaction.send(:_set_error, error) @@ -2728,7 +2777,8 @@ def exception_event e end - it "sends only the first causes as sample data", :agent_mode do + it "sends only the first causes as sample data", :agent_mode, :manual_start do + start_agent(**start_agent_args) expected_error_causes = Array.new(10) do |i| { @@ -2756,7 +2806,8 @@ def exception_event end it "records only the first causes on the exception event in collector mode", - :collector_mode do + :collector_mode, :manual_start do + start_collector_agent expected_error_causes = Array.new(10) do |i| { @@ -2794,7 +2845,8 @@ def exception_event transaction.send(:_set_error, error) end - it "sets an error on the transaction without an error message", :agent_mode do + it "sets an error on the transaction without an error message", :agent_mode, :manual_start do + start_agent(**start_agent_args) transaction.send(:_set_error, error) expect(transaction).to have_error( @@ -2805,7 +2857,8 @@ def exception_event end it "records an empty error message on the exception event in collector mode", - :collector_mode do + :collector_mode, :manual_start do + start_collector_agent transaction.send(:_set_error, error) transaction.complete @@ -2951,8 +3004,9 @@ def exception_event end end - context "when the transaction has several errors" do + context "when the transaction has several errors", :manual_start 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 @@ -2979,6 +3033,7 @@ def exception_event 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 @@ -3165,7 +3220,7 @@ def exception_event end end - describe "recording an event with the given duration" do + describe "recording an event with the given duration", :manual_start do let(:duration_ns) { 1_000_000_000 } def perform(transaction) @@ -3174,6 +3229,7 @@ def perform(transaction) 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! @@ -3186,6 +3242,7 @@ def perform(transaction) end it "in collector mode", :collector_mode do + start_collector_agent transaction = create_transaction(Appsignal::Transaction::HTTP_REQUEST) perform(transaction) Appsignal::Transaction.complete_current! @@ -3227,13 +3284,14 @@ def perform(transaction) end end - describe "instrumenting a SQL event" do + describe "instrumenting a SQL event", :manual_start 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! @@ -3247,6 +3305,7 @@ def perform(transaction) end it "in collector mode", :collector_mode do + start_collector_agent transaction = create_transaction(Appsignal::Transaction::HTTP_REQUEST) perform(transaction) Appsignal::Transaction.complete_current! @@ -3263,13 +3322,14 @@ def perform(transaction) end end - describe "instrumenting a default-format event" do + describe "instrumenting a default-format event", :manual_start 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! @@ -3283,6 +3343,7 @@ def perform(transaction) end it "in collector mode", :collector_mode do + start_collector_agent transaction = create_transaction(Appsignal::Transaction::HTTP_REQUEST) perform(transaction) Appsignal::Transaction.complete_current! @@ -3297,7 +3358,7 @@ def perform(transaction) end end - describe "nesting instrumented events" do + describe "nesting instrumented events", :manual_start do def perform(transaction) transaction.instrument("outer.event", "Outer", "outer body", Appsignal::EventFormatter::DEFAULT) do @@ -3307,6 +3368,7 @@ def perform(transaction) 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! @@ -3320,6 +3382,7 @@ def perform(transaction) end it "in collector mode", :collector_mode do + start_collector_agent transaction = create_transaction(Appsignal::Transaction::HTTP_REQUEST) perform(transaction) Appsignal::Transaction.complete_current! @@ -3332,8 +3395,9 @@ def perform(transaction) end end - describe "with an empty title" do + describe "with an empty title", :manual_start do it "omits the appsignal.title attribute on the span", :collector_mode do + start_collector_agent transaction = create_transaction(Appsignal::Transaction::HTTP_REQUEST) transaction.instrument("custom.event", nil, "Body", Appsignal::EventFormatter::DEFAULT) { nil } @@ -3343,8 +3407,9 @@ def perform(transaction) end end - describe "with an empty body" do + describe "with an empty body", :manual_start 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 } @@ -3356,8 +3421,9 @@ def perform(transaction) end end - describe "OpenTelemetry current context during the block" do + describe "OpenTelemetry current context during the block", :manual_start 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 diff --git a/spec/lib/appsignal_spec.rb b/spec/lib/appsignal_spec.rb index 93593cdd6..ade9fef3b 100644 --- a/spec/lib/appsignal_spec.rb +++ b/spec/lib/appsignal_spec.rb @@ -2139,12 +2139,13 @@ def perform end end - describe "custom metrics" do + describe "custom metrics", :manual_start 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) @@ -2157,12 +2158,14 @@ def perform 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 @@ -2173,6 +2176,7 @@ def perform 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) @@ -2181,6 +2185,7 @@ def perform 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, @@ -2197,6 +2202,7 @@ def perform 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") @@ -2209,12 +2215,14 @@ def perform 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 @@ -2225,6 +2233,7 @@ def perform 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) @@ -2233,6 +2242,7 @@ def perform 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) @@ -2241,6 +2251,7 @@ def perform 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) @@ -2254,6 +2265,7 @@ def perform 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) @@ -2266,12 +2278,14 @@ def perform 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 @@ -2282,6 +2296,7 @@ def perform 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) @@ -2290,6 +2305,7 @@ def perform 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) @@ -2301,7 +2317,7 @@ def perform end end - describe ".instrument" do + describe ".instrument", :manual_start do describe "block return value" do it_in_both_modes do set_current_transaction(transaction) @@ -2318,6 +2334,7 @@ def perform end it "in agent mode", :agent_mode do + start_agent set_current_transaction(transaction) perform expect(transaction).to include_event( @@ -2329,6 +2346,7 @@ def perform end it "in collector mode", :collector_mode do + start_collector_agent set_current_transaction(transaction) perform Appsignal::Transaction.complete_current! @@ -2352,6 +2370,7 @@ def perform end it "in agent mode", :agent_mode do + start_agent set_current_transaction(transaction) perform expect(transaction).to include_event( @@ -2360,6 +2379,7 @@ def perform end it "in collector mode", :collector_mode do + start_collector_agent set_current_transaction(transaction) perform Appsignal::Transaction.complete_current! @@ -2380,6 +2400,7 @@ def perform end it "in agent mode", :agent_mode do + start_agent set_current_transaction(transaction) perform expect(transaction).to include_event( @@ -2388,6 +2409,7 @@ def perform end it "in collector mode", :collector_mode do + start_collector_agent set_current_transaction(transaction) perform Appsignal::Transaction.complete_current! @@ -2401,13 +2423,14 @@ def perform end end - describe ".instrument_sql" do + describe ".instrument_sql", :manual_start 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") @@ -2420,6 +2443,7 @@ def perform end it "in collector mode", :collector_mode do + start_collector_agent set_current_transaction(transaction) expect(perform).to eq("return value") @@ -2437,9 +2461,10 @@ def perform end end - describe ".ignore_instrumentation_events" do + describe ".ignore_instrumentation_events", :manual_start 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 @@ -2454,6 +2479,7 @@ def perform end it "in collector mode", :collector_mode do + start_collector_agent set_current_transaction(transaction) Appsignal.instrument("register.this.event") { :do_nothing } From 729a9041645dff36e96fed814971ec106ad3bf2b Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Fri, 5 Jun 2026 17:02:52 +0200 Subject: [PATCH 047/151] Restore the notifier after AS notifications specs The ActiveSupport::Notifications specs swap in a fresh notifier to control subscriptions but never restored it, leaking a stale, subscription-less notifier into later specs. Run alphabetically the ActionMailer spec ran first and masked it, but in any order where these run first, ActionMailer's instrumentation fired into the stale notifier and recorded nothing. Restore the original notifier in an around hook. --- spec/lib/appsignal/hooks/active_support_notifications_spec.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/lib/appsignal/hooks/active_support_notifications_spec.rb b/spec/lib/appsignal/hooks/active_support_notifications_spec.rb index 0876be643..957eec3f1 100644 --- a/spec/lib/appsignal/hooks/active_support_notifications_spec.rb +++ b/spec/lib/appsignal/hooks/active_support_notifications_spec.rb @@ -5,7 +5,7 @@ let(:notifier) { ActiveSupport::Notifications::Fanout.new } let(:as) { ActiveSupport::Notifications } - # The before hook swaps in a fresh notifier (`as.notifier = notifier`) to + # 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 From 02618e5b6ee4cce1e317dc8c32b4bad30e06f6ff Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Mon, 8 Jun 2026 10:10:46 +0200 Subject: [PATCH 048/151] Drop the auto-start default and :manual_start The mode contexts no longer start the agent in a before hook; every mode-tagged example starts it in its own body (start_agent / start_collector_agent), and it_in_both_modes does the same. The few spec-local hooks that auto-start the non-mode examples in mixed contexts now key off the mode tags themselves instead of :manual_start, so the flag is gone entirely. One straggler (a rake agent-mode example that had relied on the context auto-start) now starts the agent in its body. --- .../lib/appsignal/hooks/action_mailer_spec.rb | 2 +- .../finish_with_state_shared_examples.rb | 4 +- .../instrument_shared_examples.rb | 14 +-- .../start_finish_shared_examples.rb | 8 +- spec/lib/appsignal/hooks/activejob_spec.rb | 8 +- spec/lib/appsignal/hooks/at_exit_spec.rb | 2 +- spec/lib/appsignal/hooks/dry_monitor_spec.rb | 4 +- spec/lib/appsignal/hooks/excon_spec.rb | 20 ++--- spec/lib/appsignal/hooks/rake_spec.rb | 9 +- spec/lib/appsignal/hooks/redis_client_spec.rb | 8 +- spec/lib/appsignal/hooks/redis_spec.rb | 4 +- spec/lib/appsignal/hooks/sequel_spec.rb | 2 +- .../active_support_event_reporter_spec.rb | 10 ++- .../integrations/data_mapper_spec.rb | 4 +- .../integrations/mongo_ruby_driver_spec.rb | 8 +- .../appsignal/integrations/net_http_spec.rb | 4 +- spec/lib/appsignal/logger_spec.rb | 79 ++++++++-------- spec/lib/appsignal/probes/gvl_spec.rb | 4 +- spec/lib/appsignal/probes/mri_spec.rb | 14 +-- spec/lib/appsignal/probes/sidekiq_spec.rb | 18 ++-- spec/lib/appsignal/rack/body_wrapper_spec.rb | 90 +++++++++---------- spec/lib/appsignal/rack/event_handler_spec.rb | 4 +- .../rack/sinatra_instrumentation_spec.rb | 20 ++--- spec/lib/appsignal/transaction_spec.rb | 87 +++++++++--------- spec/lib/appsignal_spec.rb | 40 +++++---- spec/support/helpers/mode_helpers.rb | 7 +- spec/support/shared_contexts/agent_mode.rb | 31 ++----- .../support/shared_contexts/collector_mode.rb | 20 ++--- 28 files changed, 253 insertions(+), 272 deletions(-) diff --git a/spec/lib/appsignal/hooks/action_mailer_spec.rb b/spec/lib/appsignal/hooks/action_mailer_spec.rb index cd157a2c6..4e08f7aab 100644 --- a/spec/lib/appsignal/hooks/action_mailer_spec.rb +++ b/spec/lib/appsignal/hooks/action_mailer_spec.rb @@ -24,7 +24,7 @@ def welcome end end - describe ".install", :manual_start do + describe ".install" do it "in agent mode", :agent_mode do start_agent 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 7cfbd4005..72d222072 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,7 +1,7 @@ shared_examples "activesupport finish_with_state override" do let(:instrumenter) { as.instrumenter } - describe "a finish_with_state event", :manual_start do + 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") @@ -42,7 +42,7 @@ def perform end end - describe "an event whose name starts with a bang", :manual_start do + 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") 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 8df88934f..84e648ac1 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,5 +1,5 @@ shared_examples "activesupport instrument override" do - describe "an event with a registered formatter", :manual_start do + describe "an event with a registered formatter" do def perform as.instrument("sql.active_record", :sql => "SQL") { "value" } end @@ -40,7 +40,7 @@ def perform end end - describe "an event with no registered formatter", :manual_start do + describe "an event with no registered formatter" do def perform as.instrument("no-registered.formatter", :key => "something") { "value" } end @@ -81,7 +81,7 @@ def perform end end - describe "an event with a non-string name", :manual_start do + describe "an event with a non-string name" do def perform as.instrument(:not_a_string) {} # rubocop:disable Lint/EmptyBlock end @@ -117,7 +117,7 @@ def perform end end - describe "an event whose name starts with a bang", :manual_start do + describe "an event whose name starts with a bang" do def perform as.instrument("!sql.active_record", :sql => "SQL") { "value" } end @@ -145,7 +145,7 @@ def perform end end - describe "when an error is raised in an instrumented block", :manual_start do + describe "when an error is raised in an instrumented block" do def perform expect do as.instrument("sql.active_record", :sql => "SQL") do @@ -189,7 +189,7 @@ def perform end end - describe "when a message is thrown in an instrumented block", :manual_start do + describe "when a message is thrown in an instrumented block" do def perform expect do as.instrument("sql.active_record", :sql => "SQL") { throw :foo } @@ -231,7 +231,7 @@ def perform end end - describe "when the transaction is completed inside an instrumented block", :manual_start 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! 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 1bd4d6dd5..d3a8714f7 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,7 +1,7 @@ shared_examples "activesupport start finish override" do let(:instrumenter) { as.instrumenter } - describe "a start/finish event whose payload is provided at start", :manual_start do + 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", {}) @@ -44,7 +44,7 @@ def perform end end - describe "a start/finish event whose payload is provided at finish", :manual_start do + 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") @@ -85,7 +85,7 @@ def perform end end - describe "an event whose name starts with a bang", :manual_start do + describe "an event whose name starts with a bang" do def perform instrumenter.start("!sql.active_record", {}) instrumenter.finish("!sql.active_record", {}) @@ -115,7 +115,7 @@ def perform end end - describe "when the transaction is completed between start and finish", :manual_start do + describe "when the transaction is completed between start and finish" do def perform instrumenter.start("sql.active_record", {}) Appsignal::Transaction.complete_current! diff --git a/spec/lib/appsignal/hooks/activejob_spec.rb b/spec/lib/appsignal/hooks/activejob_spec.rb index 4ac2d3095..e943f788b 100644 --- a/spec/lib/appsignal/hooks/activejob_spec.rb +++ b/spec/lib/appsignal/hooks/activejob_spec.rb @@ -697,7 +697,7 @@ def active_job_internal_key # 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", :manual_start do + describe "emitting the queue job count metric" do before do ActiveJob::Base.queue_adapter = :inline stub_const("ActiveJobTestJob", Class.new(ActiveJob::Base) do @@ -736,7 +736,7 @@ def perform # 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", :manual_start do + describe "emitting the failed job count metric" do before do ActiveJob::Base.queue_adapter = :inline stub_const("ActiveJobFailingJob", Class.new(ActiveJob::Base) do @@ -779,7 +779,7 @@ def perform # 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", :manual_start do + describe "emitting the priority job count metric" do before do ActiveJob::Base.queue_adapter = :inline stub_const("ActiveJobPriorityJob", Class.new(ActiveJob::Base) do @@ -828,7 +828,7 @@ def perform # 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", :manual_start do + describe "emitting the queue time metric" do before do stub_const( "ActiveJob::QueueAdapters::AppsignalTestAdapter", diff --git a/spec/lib/appsignal/hooks/at_exit_spec.rb b/spec/lib/appsignal/hooks/at_exit_spec.rb index 4f16a6a38..8d8da81c1 100644 --- a/spec/lib/appsignal/hooks/at_exit_spec.rb +++ b/spec/lib/appsignal/hooks/at_exit_spec.rb @@ -60,7 +60,7 @@ def call_callback expect(logs).to_not contains_log(:error, "Appsignal.report_error: Cannot add error.") end - describe "reports an error if there's an unhandled error", :manual_start do + describe "reports an error if there's an unhandled error" do def perform with_error(ExampleException, "error message") do call_callback diff --git a/spec/lib/appsignal/hooks/dry_monitor_spec.rb b/spec/lib/appsignal/hooks/dry_monitor_spec.rb index 79884703b..160981bb9 100644 --- a/spec/lib/appsignal/hooks/dry_monitor_spec.rb +++ b/spec/lib/appsignal/hooks/dry_monitor_spec.rb @@ -34,7 +34,7 @@ describe "Dry Monitor Integration" do let(:notifications) { Dry::Monitor::Notifications.new(:test) } - describe "a SQL event", :manual_start do + describe "a SQL event" do let(:event_id) { :sql } let(:payload) do { @@ -80,7 +80,7 @@ def perform end end - describe "an unregistered formatter event", :manual_start do + describe "an unregistered formatter event" do let(:event_id) { :foo } let(:payload) { { :name => "foo" } } diff --git a/spec/lib/appsignal/hooks/excon_spec.rb b/spec/lib/appsignal/hooks/excon_spec.rb index 6ab1f94d7..e4ee1c93f 100644 --- a/spec/lib/appsignal/hooks/excon_spec.rb +++ b/spec/lib/appsignal/hooks/excon_spec.rb @@ -23,22 +23,21 @@ def self.defaults end describe "instrumentation" do - describe "a http request", :manual_start do + describe "a http request" do def perform - transaction = http_request_transaction - set_current_transaction(transaction) data = { :host => "www.google.com", :method => :get, :scheme => "http" } Excon.defaults[:instrumentor].instrument("excon.request", data) {} # rubocop:disable Lint/EmptyBlock - transaction end it "in agent mode", :agent_mode do start_agent - transaction = perform + transaction = http_request_transaction + set_current_transaction(transaction) + perform expect(transaction).to include_event( "name" => "request.excon", @@ -49,6 +48,7 @@ def perform it "in collector mode", :collector_mode do start_collector_agent + set_current_transaction(http_request_transaction) perform Appsignal::Transaction.complete_current! @@ -61,18 +61,17 @@ def perform end end - describe "a http response", :manual_start do + describe "a http response" do def perform - transaction = http_request_transaction - set_current_transaction(transaction) data = { :host => "www.google.com" } Excon.defaults[:instrumentor].instrument("excon.response", data) {} # rubocop:disable Lint/EmptyBlock - transaction end it "in agent mode", :agent_mode do start_agent - transaction = perform + transaction = http_request_transaction + set_current_transaction(transaction) + perform expect(transaction).to include_event( "name" => "response.excon", @@ -83,6 +82,7 @@ def perform it "in collector mode", :collector_mode do start_collector_agent + set_current_transaction(http_request_transaction) perform Appsignal::Transaction.complete_current! diff --git a/spec/lib/appsignal/hooks/rake_spec.rb b/spec/lib/appsignal/hooks/rake_spec.rb index 1af58ff3b..7a8b64614 100644 --- a/spec/lib/appsignal/hooks/rake_spec.rb +++ b/spec/lib/appsignal/hooks/rake_spec.rb @@ -48,7 +48,7 @@ def perform context "with :enable_rake_performance_instrumentation == true" do let(:options) { { :enable_rake_performance_instrumentation => true } } - describe "creates a transaction", :manual_start do + 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)) @@ -101,7 +101,7 @@ def perform context "with normal error" do let(:error) { ExampleException.new("error message") } - describe "creates a background job transaction", :manual_start do + describe "creates a background job transaction" do it "in agent mode", :agent_mode do start_agent(**start_agent_args) perform @@ -147,6 +147,7 @@ def perform # 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 @@ -157,7 +158,7 @@ def perform context "when error is a SystemExit" do let(:error) { SystemExit.new(1) } - describe "does not report the error", :manual_start do + describe "does not report the error" do it "in agent mode", :agent_mode do start_agent(**start_agent_args) perform @@ -177,7 +178,7 @@ def perform context "when error is a SignalException" do let(:error) { SignalException.new(1) } - describe "does not report the error", :manual_start do + describe "does not report the error" do it "in agent mode", :agent_mode do start_agent(**start_agent_args) perform diff --git a/spec/lib/appsignal/hooks/redis_client_spec.rb b/spec/lib/appsignal/hooks/redis_client_spec.rb index b7cfa5c99..c9e6cbbc2 100644 --- a/spec/lib/appsignal/hooks/redis_client_spec.rb +++ b/spec/lib/appsignal/hooks/redis_client_spec.rb @@ -79,7 +79,7 @@ def write(_commands) Appsignal::Hooks::RedisClientHook.new.install end - describe "a redis call", :manual_start do + describe "a redis call" do def perform RedisClient::RubyConnection.new(client_config).write([:get, "key"]) end @@ -114,7 +114,7 @@ def perform end end - describe "a redis script call", :manual_start do + describe "a redis script call" do let(:script) { "return redis.call('set',KEYS[1],ARGV[1])" } def perform @@ -210,7 +210,7 @@ def write(_commands) Appsignal::Hooks::RedisClientHook.new.install end - describe "a redis call", :manual_start do + describe "a redis call" do def perform RedisClient::HiredisConnection.new(client_config).write([:get, "key"]) end @@ -245,7 +245,7 @@ def perform end end - describe "a redis script call", :manual_start do + describe "a redis script call" do let(:script) { "return redis.call('set',KEYS[1],ARGV[1])" } def perform diff --git a/spec/lib/appsignal/hooks/redis_spec.rb b/spec/lib/appsignal/hooks/redis_spec.rb index 162e6b321..557ad76e3 100644 --- a/spec/lib/appsignal/hooks/redis_spec.rb +++ b/spec/lib/appsignal/hooks/redis_spec.rb @@ -73,7 +73,7 @@ def write(_commands) Appsignal::Hooks::RedisHook.new.install end - describe "a redis call", :manual_start do + describe "a redis call" do def perform Redis::Client.new.write([:get, "key"]) end @@ -108,7 +108,7 @@ def perform end end - describe "a redis script call", :manual_start do + describe "a redis script call" do let(:script) { "return redis.call('set',KEYS[1],ARGV[1])" } def perform diff --git a/spec/lib/appsignal/hooks/sequel_spec.rb b/spec/lib/appsignal/hooks/sequel_spec.rb index 1db787a31..48d0368ac 100644 --- a/spec/lib/appsignal/hooks/sequel_spec.rb +++ b/spec/lib/appsignal/hooks/sequel_spec.rb @@ -15,7 +15,7 @@ it { is_expected.to be_truthy } end - context "with a transaction", :manual_start do + context "with a transaction" do def perform db["SELECT 1"].all.to_a end 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 a8603981f..03f51c590 100644 --- a/spec/lib/appsignal/integrations/active_support_event_reporter_spec.rb +++ b/spec/lib/appsignal/integrations/active_support_event_reporter_spec.rb @@ -9,7 +9,11 @@ } end - describe "#emit", :manual_start do + describe "#emit" do + def perform + subscriber.emit(event) + end + it "in agent mode", :agent_mode do start_agent @@ -21,13 +25,13 @@ { :id => 123, :email => "user@example.com" } ) - subscriber.emit(event) + perform end it "in collector mode", :collector_mode do start_collector_agent - subscriber.emit(event) + perform expect(log_records.size).to eq(1) record = log_records.first diff --git a/spec/lib/appsignal/integrations/data_mapper_spec.rb b/spec/lib/appsignal/integrations/data_mapper_spec.rb index 39b257a2b..7a5e49285 100644 --- a/spec/lib/appsignal/integrations/data_mapper_spec.rb +++ b/spec/lib/appsignal/integrations/data_mapper_spec.rb @@ -20,7 +20,7 @@ def log_message connection_class.new.log(message) end - describe "a SQL-like scheme", :manual_start do + describe "a SQL-like scheme" do let(:connection_class) { DataObjects::Sqlite3::Connection } before do stub_const("DataObjects::Sqlite3::Connection", Class.new do @@ -67,7 +67,7 @@ def perform end end - describe "a non-SQL scheme", :manual_start do + describe "a non-SQL scheme" do let(:connection_class) { DataObjects::MongoDB::Connection } before do stub_const("DataObjects::MongoDB::Connection", Class.new do diff --git a/spec/lib/appsignal/integrations/mongo_ruby_driver_spec.rb b/spec/lib/appsignal/integrations/mongo_ruby_driver_spec.rb index 3305b13bb..a2d71abdd 100644 --- a/spec/lib/appsignal/integrations/mongo_ruby_driver_spec.rb +++ b/spec/lib/appsignal/integrations/mongo_ruby_driver_spec.rb @@ -6,7 +6,7 @@ # backend; the OTel-backed transaction output is covered by "instrumenting a # finished query" below in both modes. `start_agent` comes from the mode # context, so it is not started here. - context "with transaction", :agent_mode, :manual_start do + context "with transaction", :agent_mode do let(:transaction) { http_request_transaction } before do set_current_transaction(transaction) @@ -124,7 +124,7 @@ def perform end end - describe "instrumenting a finished query", :manual_start do + describe "instrumenting a finished query" do let(:started_event) do double( :request_id => 2, @@ -185,7 +185,7 @@ def perform expect(snapshot.data_points.first.attributes).to eq("database" => "test") end - context "without transaction", :agent_mode, :manual_start do + context "without transaction", :agent_mode do before do allow(Appsignal::Transaction).to receive(:current) .and_return(Appsignal::Transaction::NilTransaction.new) @@ -223,7 +223,7 @@ def perform end end - context "when appsignal is paused", :agent_mode, :manual_start do + context "when appsignal is paused", :agent_mode do let(:transaction) { double(:paused? => true, :nil_transaction? => false) } before { allow(Appsignal::Transaction).to receive(:current).and_return(transaction) } diff --git a/spec/lib/appsignal/integrations/net_http_spec.rb b/spec/lib/appsignal/integrations/net_http_spec.rb index 0d823f4ce..18530efa3 100644 --- a/spec/lib/appsignal/integrations/net_http_spec.rb +++ b/spec/lib/appsignal/integrations/net_http_spec.rb @@ -1,7 +1,7 @@ require "appsignal/integrations/net_http" describe Appsignal::Integrations::NetHttpIntegration do - describe "a http request", :manual_start do + describe "a http request" do def perform stub_request(:any, "http://www.google.com/") @@ -37,7 +37,7 @@ def perform end end - describe "a https request", :manual_start do + describe "a https request" do def perform stub_request(:any, "https://www.google.com/") diff --git a/spec/lib/appsignal/logger_spec.rb b/spec/lib/appsignal/logger_spec.rb index c0858ccb9..f6e050e4d 100644 --- a/spec/lib/appsignal/logger_spec.rb +++ b/spec/lib/appsignal/logger_spec.rb @@ -1,5 +1,5 @@ shared_examples "tagged logging" do - describe "with tags from logger.tagged", :manual_start do + describe "with tags from logger.tagged" do def perform logger.tagged("My tag", "My other tag") do logger.info("Some message") @@ -33,7 +33,7 @@ def perform end end - describe "with nested tags from logger.tagged", :manual_start do + 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 @@ -69,7 +69,7 @@ def perform end end - describe "with tags from Rails.application.config.log_tags", :manual_start do + 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) @@ -145,7 +145,7 @@ def perform end end - describe "with tags from Rails 8 application.config.log_tags", :manual_start do + 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") } @@ -180,7 +180,7 @@ def perform end end - describe "clearing all tags with clear_tags!", :manual_start do + describe "clearing all tags with clear_tags!" do it "in agent mode", :agent_mode do start_agent allow(Appsignal::Extension).to receive(:log) @@ -236,7 +236,7 @@ def perform end end - describe "with tags passed as an array", :manual_start do + describe "with tags passed as an array" do def perform logger.tagged(["My tag", "My other tag"]) do logger.info("Some message") @@ -276,7 +276,7 @@ def perform # is present. if !DependencyHelper.rails_present? || DependencyHelper.rails7_present? describe "when calling #tagged without a block" do - describe "returns a new logger with the tags added", :manual_start do + describe "returns a new logger with the tags added" do def perform logger.tagged("My tag", "My other tag").info("Some message") end @@ -308,7 +308,7 @@ def perform end end - describe "does not modify the original logger", :manual_start do + describe "does not modify the original logger" do it "in agent mode", :agent_mode do start_agent expect(Appsignal::Extension).to receive(:log) @@ -362,7 +362,7 @@ def perform end end - describe "can be chained", :manual_start do + describe "can be chained" do def perform logger.tagged("My tag", "My other tag").tagged("My third tag").info("Some message") end @@ -394,7 +394,7 @@ def perform end end - describe "can be chained before a block invocation", :manual_start do + 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. @@ -430,7 +430,7 @@ def perform end end - describe "can be chained after a block invocation", :manual_start do + 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") @@ -512,7 +512,7 @@ def perform end describe "#add" do - describe "with a level and message", :manual_start do + describe "with a level and message" do def perform logger.add(::Logger::INFO, "Log message") end @@ -532,7 +532,7 @@ def perform end end - describe "with a non-string message", :manual_start do + describe "with a non-string message" do def perform logger.add(::Logger::INFO, 123) logger.add(::Logger::INFO, {}) @@ -562,7 +562,7 @@ def perform end end - describe "with a block", :manual_start do + describe "with a block" do def perform logger.add(::Logger::INFO) { "Log message" } end @@ -582,7 +582,7 @@ def perform end end - describe "with a level, message and group", :manual_start do + describe "with a level, message and group" do def perform logger.add(::Logger::INFO, "Log message", "other_group") end @@ -605,7 +605,7 @@ def perform describe "with info log level" do let(:logger) { Appsignal::Logger.new("group", :level => ::Logger::INFO) } - describe "when the call's level is too low", :manual_start do + describe "when the call's level is too low" do def perform logger.add(::Logger::DEBUG, "Log message") end @@ -624,7 +624,7 @@ def perform end end - describe "with the PLAINTEXT format set", :manual_start do + describe "with the PLAINTEXT format set" do let(:logger) { Appsignal::Logger.new("group", :format => Appsignal::Logger::PLAINTEXT) } def perform @@ -646,7 +646,7 @@ def perform end end - describe "with the logfmt format set", :manual_start do + describe "with the logfmt format set" do let(:logger) { Appsignal::Logger.new("group", :format => Appsignal::Logger::LOGFMT) } def perform @@ -668,7 +668,7 @@ def perform end end - describe "with the JSON format set", :manual_start do + describe "with the JSON format set" do let(:logger) { Appsignal::Logger.new("group", :format => Appsignal::Logger::JSON) } def perform @@ -697,7 +697,7 @@ def perform end end - describe "logs with a level, message and group", :manual_start do + describe "logs with a level, message and group" do def perform logger.add(::Logger::INFO, "Log message", "other_group") end @@ -727,7 +727,7 @@ def perform end end - describe "calls the formatter with the original message", :manual_start do + describe "calls the formatter with the original message" do def perform logger.add(::Logger::INFO, { :a => "b" }) end @@ -765,7 +765,7 @@ def perform end end - describe "calls #to_s on the formatter output if it is not a string", :manual_start do + describe "calls #to_s on the formatter output if it is not a string" do def perform logger.add(::Logger::INFO, 123) end @@ -802,7 +802,7 @@ def perform end end - describe "silences the logger up to, but not including, the given level", :manual_start do + describe "silences the logger up to, but not including, the given level" do def perform logger.silence(::Logger::WARN) do logger.info("Log message") @@ -829,7 +829,7 @@ def perform end end - describe "silences the logger to error level by default", :manual_start do + describe "silences the logger to error level by default" do def perform logger.silence do logger.debug("Log message") @@ -869,7 +869,7 @@ def perform end describe "#broadcast_to" do - describe "broadcasts the message to the given logger", :manual_start do + 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) } @@ -894,8 +894,7 @@ def perform end end - describe "broadcasts the message to the given logger when it's below the log level", - :manual_start do + 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) } @@ -919,7 +918,7 @@ def perform end end - describe "does not broadcast the message to the given logger when silenced", :manual_start do + 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) } @@ -984,7 +983,7 @@ def perform ActiveSupport::TaggedLogging.new(appsignal_logger) end - describe "broadcasts a tagged message to the given logger", :manual_start do + describe "broadcasts a tagged message to the given logger" do def perform logger.tagged("My tag", "My other tag") do logger.info("Some message") @@ -1032,7 +1031,7 @@ def perform method, extension_level, logger_level, higher_level = permutation describe "##{method}" do - describe "with a message and attributes", :manual_start do + 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`. @@ -1067,7 +1066,7 @@ def perform end end - describe "with a block", :manual_start do + describe "with a block" do define_method(:perform) do logger.send(method) { "Log message" } end @@ -1093,7 +1092,7 @@ def perform end end - describe "with a nil message", :manual_start do + describe "with a nil message" do define_method(:perform) { logger.send(method) } it "in agent mode", :agent_mode do @@ -1113,7 +1112,7 @@ def perform context "with a lower log level" do let(:logger) { Appsignal::Logger.new("group", :level => higher_level) } - describe "skips logging when the level is too low", :manual_start do + 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 @@ -1144,7 +1143,7 @@ def perform after { Timecop.return } - describe "logs the formatted message", :manual_start do + describe "logs the formatted message" do define_method(:perform) { logger.send(method, "Log message") } it "in agent mode", :agent_mode do @@ -1180,7 +1179,7 @@ def perform describe "a logger with default attributes" do let(:logger) { Appsignal::Logger.new("group", :attributes => { :some_key => "some_value" }) } - describe "adds the attributes when a message is logged", :manual_start do + describe "adds the attributes when a message is logged" do def perform logger.error("Some message", { :other_key => "other_value" }) end @@ -1222,7 +1221,7 @@ def perform end end - describe "prioritises line attributes over default attributes", :manual_start do + describe "prioritises line attributes over default attributes" do def perform logger.error("Some message", { :some_key => "other_value" }) end @@ -1249,7 +1248,7 @@ def perform end end - describe "adds the default attributes when #add is called", :manual_start do + describe "adds the default attributes when #add is called" do def perform logger.add(::Logger::INFO, "Log message") end @@ -1278,7 +1277,7 @@ def perform end describe "#error with exception object" do - describe "logs the exception class and its message", :manual_start do + describe "logs the exception class and its message" do let(:error) do raise ExampleStandardError, "oh no!" rescue => e @@ -1320,7 +1319,7 @@ def perform end describe "#<<" do - describe "writes an info message and returns the number of characters written", :manual_start do + describe "writes an info message and returns the number of characters written" do def perform message = "hello there" result = logger << message @@ -1352,7 +1351,7 @@ def perform # 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", :manual_start do + describe "logs a formatted message" do def perform logger << "Log message" end diff --git a/spec/lib/appsignal/probes/gvl_spec.rb b/spec/lib/appsignal/probes/gvl_spec.rb index 727855947..690fba4c3 100644 --- a/spec/lib/appsignal/probes/gvl_spec.rb +++ b/spec/lib/appsignal/probes/gvl_spec.rb @@ -56,7 +56,7 @@ def expect_process_tag_split(snapshot, process_name) after { FakeGVLTools.reset } - describe "the global timer delta gauge", :manual_start do + describe "the global timer delta gauge" do def perform(probe) FakeGVLTools::GlobalTimer.monotonic_time = 100_000_000 probe.call @@ -145,7 +145,7 @@ def perform(probe) FakeGVLTools::WaitingThreads.enabled = true end - describe "the waiting threads count gauge", :manual_start do + describe "the waiting threads count gauge" do def perform(probe) FakeGVLTools::WaitingThreads.count = 3 probe.call diff --git a/spec/lib/appsignal/probes/mri_spec.rb b/spec/lib/appsignal/probes/mri_spec.rb index 5ca96e4f7..121b4341a 100644 --- a/spec/lib/appsignal/probes/mri_spec.rb +++ b/spec/lib/appsignal/probes/mri_spec.rb @@ -34,7 +34,7 @@ def vm_cache_metrics end end - describe "the vm cache gauges", :manual_start do + describe "the vm cache gauges" do def perform(probe) probe.call end @@ -59,7 +59,7 @@ def perform(probe) end end - describe "the thread count gauge", :manual_start do + describe "the thread count gauge" do def perform(probe) probe.call end @@ -83,7 +83,7 @@ def perform(probe) end end - describe "the gc time gauge", :manual_start do + 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) @@ -132,7 +132,7 @@ def perform(probe) end context "when GC profiling is disabled" do - describe "the gc time gauge", :manual_start do + 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) @@ -191,7 +191,7 @@ def perform(probe) end end - describe "the gc run count gauge", :manual_start do + 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) @@ -221,7 +221,7 @@ def perform(probe) end end - describe "the allocated objects gauge", :manual_start do + 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( @@ -245,7 +245,7 @@ def perform(probe) end end - describe "the heap slots gauges", :manual_start do + describe "the heap slots gauges" do def perform(probe) probe.call end diff --git a/spec/lib/appsignal/probes/sidekiq_spec.rb b/spec/lib/appsignal/probes/sidekiq_spec.rb index 2b83d147c..a278cc701 100644 --- a/spec/lib/appsignal/probes/sidekiq_spec.rb +++ b/spec/lib/appsignal/probes/sidekiq_spec.rb @@ -205,7 +205,7 @@ def with_sidekiq6! end end - it "loads Sidekiq::API", :agent_mode, :manual_start do + it "loads Sidekiq::API", :agent_mode do start_agent with_sidekiq! # Hide the Sidekiq constant if it was already loaded. It will be @@ -217,7 +217,7 @@ def with_sidekiq6! expect(defined?(Sidekiq::Stats)).to be_truthy end - it "logs config on initialize", :agent_mode, :manual_start do + it "logs config on initialize", :agent_mode do start_agent with_sidekiq! log = capture_logs { probe } @@ -227,7 +227,7 @@ def with_sidekiq6! context "with Sidekiq 7" do before { with_sidekiq7! } - it "logs used hostname on call once", :agent_mode, :manual_start do + it "logs used hostname on call once", :agent_mode do start_agent log = capture_logs { probe.call } expect(log).to contains_log( @@ -239,7 +239,7 @@ def with_sidekiq6! expect(log).to_not contains_log(:debug, %(Sidekiq probe: )) end - describe "collecting custom metrics", :manual_start do + describe "collecting custom metrics" do # Call the probe twice so the delta-based gauges report a value. def perform probe.call @@ -262,7 +262,7 @@ def perform context "when redis info doesn't contain requested keys" do before { Sidekiq7Mock.redis_info_data = {} } - describe "the redis info gauges", :manual_start do + describe "the redis info gauges" do # Call probe twice so we can calculate the delta for some gauge values. def perform probe.call @@ -292,7 +292,7 @@ def perform context "with Sidekiq 6" do before { with_sidekiq6! } - it "logs used hostname on call once", :agent_mode, :manual_start do + it "logs used hostname on call once", :agent_mode do start_agent log = capture_logs { probe.call } expect(log).to contains_log( @@ -304,7 +304,7 @@ def perform expect(log).to_not contains_log(:debug, %(Sidekiq probe: )) end - describe "collecting custom metrics", :manual_start do + describe "collecting custom metrics" do # Call the probe twice so the delta-based gauges report a value. def perform probe.call @@ -329,7 +329,7 @@ def perform allow(Sidekiq).to receive(:respond_to?).with(:redis_info).and_return(false) end - describe "the redis info gauges", :manual_start do + 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 @@ -350,7 +350,7 @@ def perform end end - context "when hostname is configured for probe", :manual_start do + context "when hostname is configured for probe" do let(:redis_hostname) { "my_redis_server" } let(:probe) { described_class.new(:hostname => redis_hostname) } diff --git a/spec/lib/appsignal/rack/body_wrapper_spec.rb b/spec/lib/appsignal/rack/body_wrapper_spec.rb index 912e0659a..5dd19ef24 100644 --- a/spec/lib/appsignal/rack/body_wrapper_spec.rb +++ b/spec/lib/appsignal/rack/body_wrapper_spec.rb @@ -75,7 +75,7 @@ def expect_collector_no_event(name) expect(wrapped).to respond_to(:close) end - describe "reads out the body in full using each", :manual_start do + 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") @@ -107,7 +107,7 @@ def perform end end - describe "returns an Enumerator if each() gets called without a block", :manual_start do + 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") @@ -144,7 +144,7 @@ def perform end end - describe "sets the exception raised inside each() on the transaction", :manual_start do + 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") @@ -172,7 +172,7 @@ def perform end end - describe "doesn't report EPIPE error", :manual_start do + describe "doesn't report EPIPE error" do def perform fake_body = double expect(fake_body).to receive(:each).once.and_raise(Errno::EPIPE) @@ -198,7 +198,7 @@ def perform end end - describe "doesn't report ECONNRESET error", :manual_start do + describe "doesn't report ECONNRESET error" do def perform fake_body = double expect(fake_body).to receive(:each).once.and_raise(Errno::ECONNRESET) @@ -224,7 +224,7 @@ def perform end end - describe "does not report EPIPE error when it's the error cause", :manual_start do + 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 @@ -251,7 +251,7 @@ def perform end end - describe "does not report EPIPE error when it's the nested error cause", :manual_start do + 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 @@ -278,7 +278,7 @@ def perform end end - describe "does not report ECONNRESET error when it's the error cause", :manual_start do + 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 @@ -305,7 +305,7 @@ def perform end end - describe "does not report ECONNRESET error when it's the nested error cause", :manual_start do + 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 @@ -332,8 +332,7 @@ def perform end end - describe "closes the body and tracks an instrumentation event when it gets closed", - :manual_start do + 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") @@ -360,7 +359,7 @@ def perform end end - describe "reports an error if an error occurs on close", :manual_start do + 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") @@ -388,7 +387,7 @@ def perform end end - describe "doesn't report EPIPE error on close", :manual_start do + describe "doesn't report EPIPE error on close" do def perform fake_body = double expect(fake_body).to receive(:close).and_raise(Errno::EPIPE) @@ -412,7 +411,7 @@ def perform end end - describe "doesn't report ECONNRESET error on close", :manual_start do + describe "doesn't report ECONNRESET error on close" do def perform fake_body = double expect(fake_body).to receive(:close).and_raise(Errno::ECONNRESET) @@ -436,7 +435,7 @@ def perform end end - describe "does not report EPIPE error when it's the error cause on close", :manual_start do + 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 @@ -461,7 +460,7 @@ def perform end end - describe "does not report ECONNRESET error when it's the error cause on close", :manual_start do + 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 @@ -516,7 +515,7 @@ def perform expect(wrapped).to respond_to(:close) end - describe "reads out the body in full using each", :manual_start do + 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") @@ -547,8 +546,7 @@ def perform end end - describe "sets the exception raised inside each() into the Appsignal transaction", - :manual_start do + 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") @@ -575,7 +573,7 @@ def perform end end - describe "doesn't report EPIPE error", :manual_start do + describe "doesn't report EPIPE error" do def perform expect(fake_body).to receive(:each).once.and_raise(Errno::EPIPE) @@ -600,7 +598,7 @@ def perform end end - describe "doesn't report ECONNRESET error", :manual_start do + describe "doesn't report ECONNRESET error" do def perform expect(fake_body).to receive(:each).once.and_raise(Errno::ECONNRESET) @@ -625,7 +623,7 @@ def perform end end - describe "does not report EPIPE error when it's the error cause (each)", :manual_start do + 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 @@ -652,7 +650,7 @@ def perform end end - describe "does not report ECONNRESET error when it's the error cause (each)", :manual_start do + 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 @@ -679,7 +677,7 @@ def perform end end - describe "reads out the body in full using to_ary", :manual_start do + 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"]) @@ -710,7 +708,7 @@ def perform end end - describe "sends the exception raised inside to_ary() to AppSignal and closes", :manual_start do + describe "sends the exception raised inside to_ary() to AppSignal and closes" do def perform fake_body = double allow(fake_body).to receive(:each) @@ -740,7 +738,7 @@ def perform end end - describe "does not report EPIPE error when it's the error cause (to_ary)", :manual_start do + 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 @@ -769,7 +767,7 @@ def perform end end - describe "does not report ECONNRESET error when it's the error cause (to_ary)", :manual_start do + 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 @@ -811,7 +809,7 @@ def perform expect(wrapped).to respond_to(:close) end - describe "reads out the body in full using each()", :manual_start do + 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") @@ -842,8 +840,7 @@ def perform end end - describe "sets the exception raised inside each() into the Appsignal transaction", - :manual_start do + 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") @@ -870,8 +867,7 @@ def perform end end - describe "sets the exception raised inside to_path() into the Appsignal transaction", - :manual_start do + 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") @@ -898,7 +894,7 @@ def perform end end - describe "doesn't report EPIPE error", :manual_start do + describe "doesn't report EPIPE error" do def perform expect(fake_body).to receive(:to_path).once.and_raise(Errno::EPIPE) @@ -923,7 +919,7 @@ def perform end end - describe "doesn't report ECONNRESET error", :manual_start do + describe "doesn't report ECONNRESET error" do def perform expect(fake_body).to receive(:to_path).once.and_raise(Errno::ECONNRESET) @@ -948,7 +944,7 @@ def perform end end - describe "does not report EPIPE error from #each when it's the error cause", :manual_start do + 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) @@ -974,8 +970,7 @@ def perform end end - describe "does not report ECONNRESET error from #each when it's the error cause", - :manual_start do + 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) @@ -1001,7 +996,7 @@ def perform end end - describe "does not report EPIPE error from #to_path when it's the error cause", :manual_start do + 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) @@ -1027,8 +1022,7 @@ def perform end end - describe "does not report ECONNRESET error from #to_path when it's the error cause", - :manual_start do + 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) @@ -1054,7 +1048,7 @@ def perform end end - describe "exposes to_path to the sender", :manual_start do + describe "exposes to_path to the sender" do def perform allow(fake_body).to receive(:to_path).and_return("/tmp/file.bin") @@ -1098,7 +1092,7 @@ def perform expect(wrapped).to respond_to(:close) end - describe "passes the stream into the call() of the body", :manual_start do + 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) @@ -1130,8 +1124,7 @@ def perform end end - describe "sets the exception raised inside call() into the Appsignal transaction", - :manual_start do + describe "sets the exception raised inside call() into the Appsignal transaction" do def perform fake_rack_stream = double allow(fake_body).to receive(:call) @@ -1162,7 +1155,7 @@ def perform end end - describe "doesn't report EPIPE error", :manual_start do + describe "doesn't report EPIPE error" do def perform fake_rack_stream = double expect(fake_body).to receive(:call) @@ -1190,7 +1183,7 @@ def perform end end - describe "doesn't report ECONNRESET error", :manual_start do + describe "doesn't report ECONNRESET error" do def perform fake_rack_stream = double expect(fake_body).to receive(:call) @@ -1218,7 +1211,7 @@ def perform end end - describe "does not report EPIPE error from #call when it's the error cause", :manual_start do + 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 @@ -1248,8 +1241,7 @@ def perform end end - describe "does not report ECONNRESET error from #call when it's the error cause", - :manual_start do + 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 diff --git a/spec/lib/appsignal/rack/event_handler_spec.rb b/spec/lib/appsignal/rack/event_handler_spec.rb index 9e2b79b6b..e63d1d86b 100644 --- a/spec/lib/appsignal/rack/event_handler_spec.rb +++ b/spec/lib/appsignal/rack/event_handler_spec.rb @@ -498,7 +498,7 @@ def on_finish(given_request = request, given_response = response) end end - describe "for a successful request", :manual_start do + describe "for a successful request" do def perform event_handler_instance.on_start(request, response) event_handler_instance.on_finish(request, response) @@ -528,7 +528,7 @@ def perform end end - describe "for a request that errors", :manual_start do + 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 diff --git a/spec/lib/appsignal/rack/sinatra_instrumentation_spec.rb b/spec/lib/appsignal/rack/sinatra_instrumentation_spec.rb index dfc510e33..dda37120e 100644 --- a/spec/lib/appsignal/rack/sinatra_instrumentation_spec.rb +++ b/spec/lib/appsignal/rack/sinatra_instrumentation_spec.rb @@ -104,7 +104,7 @@ def make_request_with_error(error) context "when appsignal is active" do context "without an error" do - describe "creates a transaction for the request", :manual_start do + describe "creates a transaction for the request" do def perform make_request end @@ -126,7 +126,7 @@ def perform end end - describe "reports a process_action.sinatra event", :manual_start do + describe "reports a process_action.sinatra event" do def perform make_request end @@ -153,7 +153,7 @@ def perform let(:error) { ExampleException.new("error message") } before { env["sinatra.error"] = error } - describe "creates a transaction for the request", :manual_start do + describe "creates a transaction for the request" do def perform make_request end @@ -177,7 +177,7 @@ def perform context "when raise_errors is off" do let(:settings) { double(:raise_errors => false) } - describe "records the error", :manual_start do + describe "records the error" do def perform make_request end @@ -207,7 +207,7 @@ def perform context "when raise_errors is on" do let(:settings) { double(:raise_errors => true) } - describe "does not record the error", :manual_start do + describe "does not record the error" do def perform make_request end @@ -236,7 +236,7 @@ def perform ) end - describe "does not record the error", :manual_start do + describe "does not record the error" do def perform make_request end @@ -259,7 +259,7 @@ def perform end describe "action name" do - describe "sets the action to the request method and path", :manual_start do + describe "sets the action to the request method and path" do def perform make_request end @@ -285,7 +285,7 @@ def perform Rack::MockRequest.env_for("/path", "REQUEST_METHOD" => "GET") end - describe "doesn't set an action name", :manual_start do + describe "doesn't set an action name" do def perform make_request end @@ -309,7 +309,7 @@ def perform context "with mounted modular application" do before { env["SCRIPT_NAME"] = "/api" } - describe "sets the action name with an application prefix path", :manual_start do + describe "sets the action name with an application prefix path" do def perform make_request end @@ -335,7 +335,7 @@ def perform Rack::MockRequest.env_for("/path", "REQUEST_METHOD" => "GET") end - describe "doesn't set an action name", :manual_start do + describe "doesn't set an action name" do def perform make_request end diff --git a/spec/lib/appsignal/transaction_spec.rb b/spec/lib/appsignal/transaction_spec.rb index 00642e2ea..08c8630e0 100644 --- a/spec/lib/appsignal/transaction_spec.rb +++ b/spec/lib/appsignal/transaction_spec.rb @@ -4,20 +4,18 @@ let(:root_path) { nil } before do |example| - # Skip start_agent for :collector_mode examples -- the shared context boots - # Appsignal with the collector endpoint instead, and `Appsignal.start` is a - # no-op once started so the order would otherwise leave the test in agent - # mode. Also skip for `:manual_start` examples, which start the agent in - # their own body (agent mode via `start_agent(**start_agent_args)`, - # collector mode via `start_collector_agent`). - unless example.metadata[:collector_mode] || example.metadata[:manual_start] + # 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 - # Per the dual-mode start principle, `:manual_start` agent-mode examples call - # `start_agent(**start_agent_args)` in their body; expose the same + # 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 } @@ -122,7 +120,7 @@ end end - describe "OpenTelemetry root span", :manual_start do + 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) @@ -161,7 +159,7 @@ end end - describe "OpenTelemetry current context", :manual_start do + 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) @@ -634,7 +632,7 @@ end end - describe "OpenTelemetry span emission", :manual_start do + 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) @@ -1508,7 +1506,7 @@ let(:transaction) { new_transaction } let(:action_name) { "PagesController#show" } - context "when the action is set", :manual_start do + context "when the action is set" do def perform transaction.set_action(action_name) end @@ -1532,7 +1530,7 @@ def perform end end - context "when the action is nil", :manual_start do + context "when the action is nil" do def perform transaction.set_action(action_name) transaction.set_action(nil) @@ -1561,7 +1559,7 @@ def perform describe "#set_action_if_nil" do let(:transaction) { new_transaction } - context "when the action is not set", :manual_start do + context "when the action is not set" do let(:action_name) { "PagesController#show" } def perform @@ -1618,7 +1616,7 @@ def perform end end - context "when the action is set", :manual_start do + context "when the action is set" do let(:action_name) { "something" } def perform @@ -1650,7 +1648,7 @@ def perform let(:transaction) { new_transaction } let(:namespace) { "custom" } - context "when the namespace is not nil", :manual_start do + context "when the namespace is not nil" do def perform transaction.set_namespace(namespace) end @@ -1673,7 +1671,7 @@ def perform end end - context "when the namespace is nil", :manual_start do + context "when the namespace is nil" do def perform transaction.set_namespace(namespace) transaction.set_namespace(nil) @@ -1697,7 +1695,7 @@ def perform end end - context "when set_namespace is never called", :collector_mode, :manual_start do + context "when set_namespace is never called", :collector_mode do it "carries the namespace from creation" do start_collector_agent transaction = http_request_transaction @@ -2010,7 +2008,7 @@ def to_s end end - describe "recording the error on the span", :manual_start do + describe "recording the error on the span" do def perform transaction.add_error(error) end @@ -2041,7 +2039,7 @@ def perform end end - describe "recording an error that has causes", :manual_start do + 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'"]) @@ -2090,7 +2088,7 @@ def perform end end - describe "recording multiple errors", :manual_start do + describe "recording multiple errors" do let(:other_error) do ExampleStandardError.new("other message").tap { |e| e.set_backtrace(["line 2"]) } end @@ -2137,7 +2135,7 @@ def perform # 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, :manual_start do + it "records the error on the current event span", :collector_mode do start_collector_agent transaction.start_event transaction.add_error(error) @@ -2152,7 +2150,7 @@ def perform # 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, :manual_start do + 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") } @@ -2275,7 +2273,7 @@ def perform end end - context "with a PG::UniqueViolation", :manual_start do + context "with a PG::UniqueViolation" do let(:error) do PG::UniqueViolation.new( "ERROR: duplicate key value violates unique constraint " \ @@ -2310,7 +2308,7 @@ def perform end end - context "with a ActiveRecord::RecordNotUnique", :manual_start do + context "with a ActiveRecord::RecordNotUnique" do let(:error) do ActiveRecord::RecordNotUnique.new( "PG::UniqueViolation: ERROR: duplicate key value violates unique constraint " \ @@ -2345,7 +2343,7 @@ def perform end end - context "with Rails module but without backtrace_cleaner method", :manual_start do + context "with Rails module but without backtrace_cleaner method" do def perform stub_const("Rails", Module.new) error = ExampleStandardError.new("error message") @@ -2375,7 +2373,7 @@ def perform end if rails_present? - context "with Rails", :manual_start do + context "with Rails" do let(:test_filter) do lambda do |line| if Appsignal::Testing.store[:enable_rails_backtrace_line_filter] @@ -2463,14 +2461,14 @@ def exception_event end context "when the error has no causes" do - it "should set an empty causes array as sample data", :agent_mode, :manual_start 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, :manual_start do + it "sets no error causes attribute in collector mode", :collector_mode do start_collector_agent transaction.send(:_set_error, error) transaction.complete @@ -2514,7 +2512,7 @@ def exception_event end let(:options) { { :revision => "my_revision" } } - it "sends the error causes information as sample data", :agent_mode, :manual_start 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. @@ -2569,8 +2567,7 @@ def exception_event # 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, - :manual_start do + it "records the error causes on the exception event in collector mode", :collector_mode do start_collector_agent hide_const("Rails") @@ -2777,7 +2774,7 @@ def exception_event e end - it "sends only the first causes as sample data", :agent_mode, :manual_start 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| @@ -2806,7 +2803,7 @@ def exception_event end it "records only the first causes on the exception event in collector mode", - :collector_mode, :manual_start do + :collector_mode do start_collector_agent expected_error_causes = Array.new(10) do |i| @@ -2845,7 +2842,7 @@ def exception_event transaction.send(:_set_error, error) end - it "sets an error on the transaction without an error message", :agent_mode, :manual_start 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) @@ -2857,7 +2854,7 @@ def exception_event end it "records an empty error message on the exception event in collector mode", - :collector_mode, :manual_start do + :collector_mode do start_collector_agent transaction.send(:_set_error, error) transaction.complete @@ -3004,7 +3001,7 @@ def exception_event end end - context "when the transaction has several errors", :manual_start do + context "when the transaction has several errors" 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| @@ -3220,7 +3217,7 @@ def exception_event end end - describe "recording an event with the given duration", :manual_start do + describe "recording an event with the given duration" do let(:duration_ns) { 1_000_000_000 } def perform(transaction) @@ -3284,7 +3281,7 @@ def perform(transaction) end end - describe "instrumenting a SQL event", :manual_start do + describe "instrumenting a SQL event" do def perform(transaction) transaction.instrument("sql.active_record", "Query", "SELECT 1", Appsignal::EventFormatter::SQL_BODY_FORMAT) { nil } @@ -3322,7 +3319,7 @@ def perform(transaction) end end - describe "instrumenting a default-format event", :manual_start do + describe "instrumenting a default-format event" do def perform(transaction) transaction.instrument("custom.event", "Title", "Body", Appsignal::EventFormatter::DEFAULT) { nil } @@ -3358,7 +3355,7 @@ def perform(transaction) end end - describe "nesting instrumented events", :manual_start do + describe "nesting instrumented events" do def perform(transaction) transaction.instrument("outer.event", "Outer", "outer body", Appsignal::EventFormatter::DEFAULT) do @@ -3395,7 +3392,7 @@ def perform(transaction) end end - describe "with an empty title", :manual_start do + describe "with an empty title" do it "omits the appsignal.title attribute on the span", :collector_mode do start_collector_agent transaction = create_transaction(Appsignal::Transaction::HTTP_REQUEST) @@ -3407,7 +3404,7 @@ def perform(transaction) end end - describe "with an empty body", :manual_start do + 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) @@ -3421,7 +3418,7 @@ def perform(transaction) end end - describe "OpenTelemetry current context during the block", :manual_start do + 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) diff --git a/spec/lib/appsignal_spec.rb b/spec/lib/appsignal_spec.rb index ade9fef3b..10cbb58b6 100644 --- a/spec/lib/appsignal_spec.rb +++ b/spec/lib/appsignal_spec.rb @@ -1140,10 +1140,13 @@ def on_start end context "with config and started" do - # Opt-out-aware so a `:manual_start` describe can start its own agent in the - # example body (the dual-mode start principle) without this hook clobbering - # the collector-mode setup. - before { |example| start_agent unless example.metadata[:manual_start] } + # 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 @@ -1613,7 +1616,7 @@ def on_start keep_transactions { example.run } end - describe "sending the error", :manual_start do + describe "sending the error" do def perform Appsignal.send_error(error) end @@ -1665,7 +1668,7 @@ def perform end context "when given a block" do - describe "yielding the transaction to set metadata", :manual_start do + describe "yielding the transaction to set metadata" do def perform Appsignal.send_error(StandardError.new("my_error")) do |transaction| transaction.set_action("my_action") @@ -1738,7 +1741,7 @@ def perform let(:transaction) { http_request_transaction } around { |example| keep_transactions { example.run } } - describe "adding the error to the active transaction", :manual_start 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. @@ -1841,7 +1844,7 @@ def perform end context "when there is no active transaction" do - describe "reporting the error", :manual_start do + describe "reporting the error" do def perform Appsignal.report_error(error) end @@ -1902,13 +1905,16 @@ def perform context "when there is an active transaction" do let(:transaction) { http_request_transaction } - # Opt-out-aware: `:manual_start` examples set the current transaction in - # their own body, after swapping in the collector providers. + # 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| - set_current_transaction(transaction) unless example.metadata[:manual_start] + unless example.metadata[:agent_mode] || example.metadata[:collector_mode] + set_current_transaction(transaction) + end end - describe "reporting the error onto it", :manual_start do + describe "reporting the error onto it" do def perform set_current_transaction(transaction) Appsignal.report_error(error) @@ -1934,7 +1940,7 @@ def perform end end - describe "with multiple reported errors", :manual_start do + describe "with multiple reported errors" do let(:other_error) do ExampleStandardError.new("other message").tap { |e| e.set_backtrace(["line 2"]) } end @@ -2139,7 +2145,7 @@ def perform end end - describe "custom metrics", :manual_start do + describe "custom metrics" do let(:tags) { { :foo => "bar" } } describe ".set_gauge" do @@ -2317,7 +2323,7 @@ def perform end end - describe ".instrument", :manual_start do + describe ".instrument" do describe "block return value" do it_in_both_modes do set_current_transaction(transaction) @@ -2423,7 +2429,7 @@ def perform end end - describe ".instrument_sql", :manual_start do + describe ".instrument_sql" do describe "recording a SQL event around the block" do def perform Appsignal.instrument_sql("name", "title", "body") { "return value" } @@ -2461,7 +2467,7 @@ def perform end end - describe ".ignore_instrumentation_events", :manual_start do + describe ".ignore_instrumentation_events" do describe "with a current transaction" do it "in agent mode", :agent_mode do start_agent diff --git a/spec/support/helpers/mode_helpers.rb b/spec/support/helpers/mode_helpers.rb index 4509735a1..837b27856 100644 --- a/spec/support/helpers/mode_helpers.rb +++ b/spec/support/helpers/mode_helpers.rb @@ -5,18 +5,17 @@ module ModeHelpers # 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 (`:manual_start`, opting out of the mode contexts' - # automatic start): agent mode via `start_agent`, collector mode via + # 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, :manual_start) do + 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, :manual_start) do + it([description, "in collector mode"].compact.join(" "), :collector_mode) do start_collector_agent instance_exec(&block) end diff --git a/spec/support/shared_contexts/agent_mode.rb b/spec/support/shared_contexts/agent_mode.rb index 39d1ae773..10d412d88 100644 --- a/spec/support/shared_contexts/agent_mode.rb +++ b/spec/support/shared_contexts/agent_mode.rb @@ -1,28 +1,15 @@ # frozen_string_literal: true RSpec.shared_context "agent mode", :agent_mode do - # Dual-mode start principle (see also collector_mode.rb): mode setup is a - # global, and having both an automatic `before` here AND ad-hoc `start_agent` - # calls elsewhere fight over it (last writer wins, order is fragile). Going - # forward, prefer starting the agent explicitly in the example body. A - # describe tagged `:manual_start` opts out of this automatic start; its - # `it "in agent mode"` calls `start_agent` itself before `perform`. - # - # Examples can define a `start_agent_args` `let` to pass `:env`/`:options` to - # `start_agent` (the collector-mode context accepts the same hook and also - # injects the `collector_endpoint`). Guarded with `defined?` rather than a - # default `let` here, because an included shared context's `let` would take - # precedence over the example group's own `let` override. - before do |example| - next if example.metadata[:manual_start] - - start_agent(**(defined?(start_agent_args) ? start_agent_args : {})) - end - - # Make completed transactions readable via `to_h` so agent-mode tests 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. + # 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 diff --git a/spec/support/shared_contexts/collector_mode.rb b/spec/support/shared_contexts/collector_mode.rb index e978cbf34..8fdfc48c5 100644 --- a/spec/support/shared_contexts/collector_mode.rb +++ b/spec/support/shared_contexts/collector_mode.rb @@ -37,16 +37,13 @@ provider end - # Dual-mode start principle: mode setup is global state, so an automatic - # `before` start and ad-hoc `start_agent` calls fight over it with fragile - # ordering. Going forward, prefer starting the agent in the example body. A - # describe tagged `:manual_start` opts out of this automatic start; its - # `it "in collector mode"` calls `start_collector_agent` itself before - # `perform`. The in-memory providers, helpers and teardown below still apply. - before do |example| - start_collector_agent unless example.metadata[:manual_start] - 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. @@ -64,8 +61,7 @@ end # Boots the agent in collector mode and swaps in the in-memory OTel providers. - # Called automatically by the `before` above, or explicitly from an example - # body in a `:manual_start` describe. + # 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 From c03e6130aa075e4f57b98b716ec6030219060b0b Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Mon, 8 Jun 2026 10:38:49 +0200 Subject: [PATCH 049/151] Remove the unused webmachine 1 gemfiles The webmachine 1 build was dropped from CI in 294bda78 ("Remove webmachine 1 build from CI" -- webmachine 1.x is EOL since 2017 and largely redundant with 2.x), but its gemfiles were left behind. They are not in the build matrix and no longer boot on Ruby 3.4 as-is: the pinned pre-1.x i18n uses base64/mutex_m, which Ruby 3.4 moved from default to bundled gems. Remove them. --- gemfiles/webmachine1-collector.gemfile | 6 ------ 1 file changed, 6 deletions(-) delete mode 100644 gemfiles/webmachine1-collector.gemfile diff --git a/gemfiles/webmachine1-collector.gemfile b/gemfiles/webmachine1-collector.gemfile deleted file mode 100644 index aae5d862d..000000000 --- a/gemfiles/webmachine1-collector.gemfile +++ /dev/null @@ -1,6 +0,0 @@ -# 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 webmachine1.gemfile. - -eval_gemfile File.expand_path("webmachine1.gemfile", __dir__) -eval_gemfile File.expand_path("collector.rb", __dir__) From aa950b13edda14d3badfc433647c6b0ab4586764 Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Mon, 8 Jun 2026 12:19:11 +0200 Subject: [PATCH 050/151] Dual-mode the Object and HTTP integration specs Split each event-asserting example into paired "in agent mode" / "in collector mode" examples that share a `perform` action. Agent mode keeps the `include_event`/`have_namespace` matchers; collector mode asserts on the in-memory span exporter (event_spans/root_span). Return-value-only examples become `it_in_both_modes`; the inactive-agent examples are left as-is. --- spec/lib/appsignal/integrations/http_spec.rb | 340 +++++++++++++----- .../lib/appsignal/integrations/object_spec.rb | 208 +++++++++-- 2 files changed, 430 insertions(+), 118 deletions(-) diff --git a/spec/lib/appsignal/integrations/http_spec.rb b/spec/lib/appsignal/integrations/http_spec.rb index 1b01e4797..3e9161e70 100644 --- a/spec/lib/appsignal/integrations/http_spec.rb +++ b/spec/lib/appsignal/integrations/http_spec.rb @@ -6,64 +6,144 @@ 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(Appsignal::Transaction::HTTP_REQUEST) + expect(event_spans.size).to eq(1) + span = event_spans.first + expect(span.name).to eq("request.http_rb") + expect(span.parent_span_id).to eq(root_span.span_id) + expect(span.attributes["appsignal.title"]).to eq("GET http://www.google.com") + expect(span.attributes).not_to have_key("appsignal.body") + end end - context "with request parameters" do - it "does not include the query parameters in the title" do - stub_request(:get, "https://www.google.com?q=Appsignal") + describe "instrumenting a HTTPS request" do + def perform + stub_request(:get, "https://www.google.com") + + HTTP.get("https://www.google.com") + end - HTTP.get("https://www.google.com", :params => { :q => "Appsignal" }) + it "in agent mode", :agent_mode do + start_agent + set_current_transaction(transaction) + perform + expect(transaction).to have_namespace(Appsignal::Transaction::HTTP_REQUEST) expect(transaction).to include_event( "body" => "", + "body_format" => Appsignal::EventFormatter::DEFAULT, + "name" => "request.http_rb", "title" => "GET https://www.google.com" ) end - it "does not include the request body in the title" do - stub_request(:post, "https://www.google.com") - .with(:body => { :q => "Appsignal" }.to_json) + it "in collector mode", :collector_mode do + start_collector_agent + set_current_transaction(transaction) + perform + Appsignal::Transaction.complete_current! + + expect(root_span.attributes["appsignal.namespace"]) + .to eq(Appsignal::Transaction::HTTP_REQUEST) + expect(event_spans.size).to eq(1) + span = event_spans.first + expect(span.name).to eq("request.http_rb") + expect(span.parent_span_id).to eq(root_span.span_id) + expect(span.attributes["appsignal.title"]).to eq("GET https://www.google.com") + expect(span.attributes).not_to have_key("appsignal.body") + 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.attributes["appsignal.title"]).to eq("GET https://www.google.com") + 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.attributes["appsignal.title"]).to eq("POST https://www.google.com") + expect(span.attributes).not_to have_key("appsignal.body") + end end end @@ -88,65 +168,165 @@ end context "with various URI objects" do - it "parses an object responding to #to_s" do - request_uri = Struct.new(:uri) do - def to_s - uri.to_s + describe "parsing an object responding to #to_s" do + def perform + request_uri = Struct.new(:uri) do + def to_s + uri.to_s + end end + + stub_request(:get, "http://www.google.com") + + HTTP.get(request_uri.new("http://www.google.com")) end - stub_request(:get, "http://www.google.com") + it "in agent mode", :agent_mode do + start_agent + set_current_transaction(transaction) + perform - HTTP.get(request_uri.new("http://www.google.com")) + expect(transaction).to include_event( + "name" => "request.http_rb", + "title" => "GET http://www.google.com" + ) + end - expect(transaction).to include_event( - "name" => "request.http_rb", - "title" => "GET http://www.google.com" - ) + it "in collector mode", :collector_mode do + start_collector_agent + set_current_transaction(transaction) + perform + Appsignal::Transaction.complete_current! + + expect(event_spans.size).to eq(1) + span = event_spans.first + expect(span.name).to eq("request.http_rb") + expect(span.attributes["appsignal.title"]).to eq("GET http://www.google.com") + 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")) + end - HTTP.get(URI("http://www.google.com")) + it "in agent mode", :agent_mode do + start_agent + set_current_transaction(transaction) + perform - expect(transaction).to include_event( - "name" => "request.http_rb", - "title" => "GET http://www.google.com" - ) + expect(transaction).to include_event( + "name" => "request.http_rb", + "title" => "GET http://www.google.com" + ) + end + + it "in collector mode", :collector_mode do + start_collector_agent + set_current_transaction(transaction) + perform + Appsignal::Transaction.complete_current! + + expect(event_spans.size).to eq(1) + span = event_spans.first + expect(span.name).to eq("request.http_rb") + expect(span.attributes["appsignal.title"]).to eq("GET http://www.google.com") + 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("request.http_rb") + expect(span.attributes["appsignal.title"]).to eq("GET http://www.google.com") + end end - it "parses a string" do - stub_request(:get, "http://www.google.com") + describe "parsing a string" do + def perform + stub_request(:get, "http://www.google.com") - HTTP.get("http://www.google.com") + HTTP.get("http://www.google.com") + end - expect(transaction).to include_event( - "name" => "request.http_rb", - "title" => "GET http://www.google.com" - ) + it "in agent mode", :agent_mode do + start_agent + set_current_transaction(transaction) + perform + + expect(transaction).to include_event( + "name" => "request.http_rb", + "title" => "GET http://www.google.com" + ) + end + + it "in collector mode", :collector_mode do + start_collector_agent + set_current_transaction(transaction) + perform + Appsignal::Transaction.complete_current! + + expect(event_spans.size).to eq(1) + span = event_spans.first + expect(span.name).to eq("request.http_rb") + expect(span.attributes["appsignal.title"]).to eq("GET http://www.google.com") + end end - it "parses a string with non-ascii characters" do - stub_request(:get, "http://www.example.com/áéíóúãÔù") + describe "parsing a string with non-ascii characters" do + def perform + stub_request(:get, "http://www.example.com/áéíóúãÔù") - HTTP.get("http://www.example.com/áéíóúãÔù") + HTTP.get("http://www.example.com/áéíóúãÔù") + end - expect(transaction).to include_event( - "name" => "request.http_rb", - "title" => "GET http://www.example.com" - ) + it "in agent mode", :agent_mode do + start_agent + set_current_transaction(transaction) + perform + + expect(transaction).to include_event( + "name" => "request.http_rb", + "title" => "GET http://www.example.com" + ) + end + + it "in collector mode", :collector_mode do + start_collector_agent + set_current_transaction(transaction) + perform + Appsignal::Transaction.complete_current! + + expect(event_spans.size).to eq(1) + span = event_spans.first + expect(span.name).to eq("request.http_rb") + expect(span.attributes["appsignal.title"]).to eq("GET http://www.example.com") + 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 From 2994098a8591716acfe3e9e01938fd5c630cf025 Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Mon, 8 Jun 2026 12:32:17 +0200 Subject: [PATCH 051/151] Test the Mongo integration with the real driver The subscriber specs fed the monitoring callbacks hand-built doubles, so neither transaction backend's output was exercised against the driver's real event API, and most assertions ran in agent mode only. Build real `Mongo::Monitoring::Event` objects instead and drive the real `started`/`succeeded`/`failed` callbacks; their constructors don't open a connection, so no MongoDB server is needed. Every example now runs in both agent and collector mode (idempotent ones via `it_in_both_modes`, the rest as paired agent/collector examples) against real transactions, with no transaction doubles. This requires the gem, so add a `mongo` gemfile and build matrix entry and gate the specs on it. --- build_matrix.yml | 1 + gemfiles/mongo-collector.gemfile | 6 + .../integrations/mongo_ruby_driver_spec.rb | 281 ++++++++---------- 3 files changed, 123 insertions(+), 165 deletions(-) create mode 100644 gemfiles/mongo-collector.gemfile diff --git a/build_matrix.yml b/build_matrix.yml index eb11c7c28..4b3a68dc5 100644 --- a/build_matrix.yml +++ b/build_matrix.yml @@ -290,6 +290,7 @@ matrix: - "3.4.1" - "3.3.4" - "3.2.5" + - gem: "mongo" - gem: "resque-2" - gem: "resque-3" only: 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/spec/lib/appsignal/integrations/mongo_ruby_driver_spec.rb b/spec/lib/appsignal/integrations/mongo_ruby_driver_spec.rb index a2d71abdd..e2367c3c9 100644 --- a/spec/lib/appsignal/integrations/mongo_ruby_driver_spec.rb +++ b/spec/lib/appsignal/integrations/mongo_ruby_driver_spec.rb @@ -1,15 +1,19 @@ require "appsignal/integrations/mongo_ruby_driver" - - # White-box unit tests of the subscriber's interaction with the Transaction - # API and the C-extension. Pinned to :agent_mode: they assert extension - # mechanics (`start_event`/`finish_event`) that only apply to the agent - # backend; the OTel-backed transaction output is covered by "instrumenting a - # finished query" below in both modes. `start_agent` comes from the mode - # context, so it is not started here. - context "with transaction", :agent_mode do - let(:transaction) { http_request_transaction } - before do - set_current_transaction(transaction) +describe Appsignal::Hooks::MongoMonitorSubscriber do + if DependencyHelper.mongo_present? + let(:subscriber) { Appsignal::Hooks::MongoMonitorSubscriber.new } + let(:address) { Mongo::Address.new("127.0.0.1:27017") } + + # Build real `Mongo::Monitoring::Event` objects so the subscriber is + # exercised against the driver's actual event API rather than doubles. The + # constructors don't open a connection, so no MongoDB server is needed. + def command_started_event( + request_id: 1, command_name: "find", + command: { "foo" => "bar" }, database_name: "test" + ) + Mongo::Monitoring::Event::CommandStarted.new( + command_name, database_name, address, request_id, 1, command + ) end def command_succeeded_event( @@ -33,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) @@ -53,199 +58,145 @@ def perform subscriber.succeeded(succeeded_event) end - it "should sanitize command" do + it "in agent mode", :agent_mode do start_agent - # TODO: additional curly brackets required for issue - # https://github.com/rspec/rspec-mocks/issues/1460 - expect(Appsignal::EventFormatter::MongoRubyDriver::QueryFormatter) - .to receive(:format).with("find", { "foo" => "bar" }) - subscriber.started(event) - end + transaction = http_request_transaction + set_current_transaction(transaction) - it "should store command on the transaction" do - start_agent - subscriber.started(event) + expect(Appsignal).to receive(:add_distribution_value).with( + "mongodb_query_duration", + 0.9919, + :database => "test" + ).and_call_original - expect(transaction.store("mongo_driver")).to eq(1 => { "foo" => "?" }) - end + perform - it "should start an event in the extension" do - start_agent - expect(transaction).to receive(:start_event) + expect(transaction).to include_event( + "name" => "query.mongodb", + "title" => "find | test | SUCCEEDED", + "body" => "{\"foo\":\"?\"}" + ) + end - subscriber.started(event) + 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.name == "query.mongodb" } + expect(span).not_to be_nil + expect(span.parent_span_id).to eq(root_span.span_id) + expect(span.attributes["appsignal.title"]).to eq("find | test | SUCCEEDED") + 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 "#succeeded" do - let(:event) { double } - - it "should finish the event" do - start_agent - expect(subscriber).to receive(:finish).with("SUCCEEDED", event) + 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) } - subscriber.succeeded(event) + def perform + subscriber.started(started_event) + subscriber.failed(failed_event) end - end - - describe "#failed" do - let(:event) { double } - it "should finish the event" do + it "in agent mode", :agent_mode do start_agent - expect(subscriber).to receive(:finish).with("FAILED", event) + transaction = http_request_transaction + set_current_transaction(transaction) - subscriber.failed(event) - end - end + perform - describe "#finish" do - let(:command) { { "foo" => "?" } } - let(:event) do - double( - :request_id => 2, - :command_name => :find, - :database_name => "test", - :duration => 0.9919 + expect(transaction).to include_event( + "name" => "query.mongodb", + "title" => "find | test | FAILED", + "body" => "{\"foo\":\"?\"}" ) end - before do - store = transaction.store("mongo_driver") - store[2] = command - end - - it "should get the query from the store" do - start_agent - expect(transaction).to receive(:store).with("mongo_driver").and_return(command) + it "in collector mode", :collector_mode do + start_collector_agent + transaction = http_request_transaction + set_current_transaction(transaction) + perform + Appsignal::Transaction.complete_current! - subscriber.finish("SUCCEEDED", event) + span = event_spans.find { |s| s.name == "query.mongodb" } + expect(span).not_to be_nil + expect(span.attributes["appsignal.title"]).to eq("find | test | FAILED") + expect(span.attributes["appsignal.body"]).to eq("{\"foo\":\"?\"}") end end - end - - describe "instrumenting a finished query" do - let(:started_event) do - double( - :request_id => 2, - :command_name => "find", - :command => { "foo" => "bar" } - ) - end - let(:finish_event) do - double( - :request_id => 2, - :command_name => :find, - :database_name => "test", - :duration => 0.9919 - ) - end - def perform - subscriber.started(started_event) - subscriber.finish("SUCCEEDED", finish_event) - end - - it "in agent mode", :agent_mode do - start_agent - transaction = http_request_transaction - set_current_transaction(transaction) + # 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 + subscriber.started(started) + subscriber.succeeded(command_succeeded_event(started)) + end - expect(Appsignal).to receive(:add_distribution_value).with( - "mongodb_query_duration", - 0.9919, - :database => "test" - ).and_call_original + 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) + expect(Appsignal).to_not receive(:add_distribution_value) - perform + perform + end - expect(transaction).to include_event( - "name" => "query.mongodb", - "title" => "find | test | SUCCEEDED", - "body" => "{\"foo\":\"?\"}" - ) - end + it "in collector mode", :collector_mode do + start_collector_agent - 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.name == "query.mongodb" } - expect(span).not_to be_nil - expect(span.parent_span_id).to eq(root_span.span_id) - expect(span.attributes["appsignal.title"]).to eq("find | test | SUCCEEDED") - 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 + perform - context "without transaction", :agent_mode do - before do - allow(Appsignal::Transaction).to receive(:current) - .and_return(Appsignal::Transaction::NilTransaction.new) + expect(span_exporter.finished_spans).to be_empty + expect(metric_snapshot("mongodb_query_duration")).to be_nil + end end - it "should not attempt to start an event" do - start_agent - expect(Appsignal::Extension).to_not receive(:start_event) + describe "when the transaction is paused" do + def perform + started = command_started_event + subscriber.started(started) + 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) + transaction.pause! + expect(Appsignal::Extension).to_not receive(:start_event) expect(Appsignal::Extension).to_not receive(:finish_event) expect(Appsignal).to_not receive(:add_distribution_value) perform end - end - it "should not attempt to finish an event" do - start_agent - expect(Appsignal::Extension).to_not receive(:finish_event) - - it "does not record anything" do - start_agent + it "in collector mode", :collector_mode do + start_collector_agent transaction = http_request_transaction set_current_transaction(transaction) transaction.pause! - it "should not attempt to send duration metrics" do - start_agent - expect(Appsignal).to_not receive(:add_distribution_value) - - subscriber.finish("SUCCEEDED", double) - end - end - - context "when appsignal is paused", :agent_mode do - let(:transaction) { double(:paused? => true, :nil_transaction? => false) } - before { allow(Appsignal::Transaction).to receive(:current).and_return(transaction) } - - it "should not attempt to start an event" do - start_agent - expect(Appsignal::Extension).to_not receive(:start_event) - - subscriber.started(double) - end - - it "should not attempt to finish an event" do - start_agent - expect(Appsignal::Extension).to_not receive(:finish_event) - - subscriber.finish("SUCCEEDED", double) - end - - it "should not attempt to send duration metrics" do - start_agent - expect(Appsignal).to_not receive(:add_distribution_value) + perform + Appsignal::Transaction.complete_current! - subscriber.finish("SUCCEEDED", double) + expect(event_spans).to be_empty + expect(metric_snapshot("mongodb_query_duration")).to be_nil + end end end end From e42bbefaaebcbfc4b120a6b45f9f613d091133b5 Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Mon, 8 Jun 2026 13:19:53 +0200 Subject: [PATCH 052/151] Pass raw sample data to the transaction backend Move the C-extension Data serialization out of Transaction and into ExtensionBackend, so backends receive the raw Hash/Array. This lets the OpenTelemetry backend read sample data directly instead of an opaque Data object, mirroring the earlier set_error seam change. No behaviour change in agent mode. --- lib/appsignal/transaction.rb | 9 +++++---- lib/appsignal/transaction/extension_backend.rb | 4 +++- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/lib/appsignal/transaction.rb b/lib/appsignal/transaction.rb index ab57fed06..05a8a4259 100644 --- a/lib/appsignal/transaction.rb +++ b/lib/appsignal/transaction.rb @@ -903,10 +903,11 @@ def set_sample_data(key, data) return end - @backend.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 diff --git a/lib/appsignal/transaction/extension_backend.rb b/lib/appsignal/transaction/extension_backend.rb index 64cccd9b1..90d868034 100644 --- a/lib/appsignal/transaction/extension_backend.rb +++ b/lib/appsignal/transaction/extension_backend.rb @@ -46,8 +46,10 @@ 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, data) + @handle.set_sample_data(key, Appsignal::Utils::Data.generate(data)) end # `backtrace` is a raw Array (or nil); the C extension wants a `Data` From 25079e29187dcb320d05889654a3c3725d1be07d Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Mon, 8 Jun 2026 13:48:43 +0200 Subject: [PATCH 053/151] Record params, session, custom data and tags Route each sample-data category onto the span attributes the collector and trace UI consume: session data to appsignal.request.session_data, custom data to appsignal.custom_data, params by namespace (web to appsignal.request.payload, background jobs to appsignal.function.parameters), and tags to one appsignal.tag. attribute each. error_causes stays off sample data (it rides on the exception event); unknown categories pass through as appsignal.. Dual-mode the sample-data specs into describe + shared perform + an agent-mode example (transaction assertions) and a collector-mode example asserting the emitted root span's attributes. The tag-truncation case stays a per-mode split since truncation is extension-specific. --- .../transaction/opentelemetry_backend.rb | 60 +- spec/lib/appsignal/transaction_spec.rb | 1195 ++++++++++++----- 2 files changed, 951 insertions(+), 304 deletions(-) diff --git a/lib/appsignal/transaction/opentelemetry_backend.rb b/lib/appsignal/transaction/opentelemetry_backend.rb index e53f04fdb..e776c7623 100644 --- a/lib/appsignal/transaction/opentelemetry_backend.rb +++ b/lib/appsignal/transaction/opentelemetry_backend.rb @@ -126,7 +126,30 @@ def set_queue_start(_start) # rubocop:disable Naming/AccessorMethodName def set_metadata(_key, _value) end - def set_sample_data(_key, _data) + # Routes each sample-data category to the attribute the collector and the + # server's trace UI recognize. The JSON-blob categories (params, session + # data, custom data) are serialized as JSON strings; the collector + # re-parses and filters them. `environment` and `breadcrumbs` are handled + # in later branches. `error_causes` is intentionally dropped because causes + # ride on the exception event instead (see #set_error) -- otherwise the + # `else` pass-through would duplicate them. Any other (unknown) category is + # passed through as a JSON `appsignal.` attribute so no data is lost. + 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 "tags" + write_tags(data) + when "error_causes" + # No-op: causes are emitted as the exception event's + # `appsignal.error_causes` attribute (see #set_error), not sample data. + else + @span.set_attribute("appsignal.#{key}", JSON.generate(data)) + end end # Records the error as an OpenTelemetry `exception` span-event on the @@ -172,11 +195,12 @@ def set_error(class_name, message, backtrace, causes) span.status = ::OpenTelemetry::Trace::Status.error end - # Always returns `false` for now: there is no sample data flush in - # collector mode (the OTel span carries the data itself once span - # emission lands in later steps). + # 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(_gc_duration) - false + true end def complete @@ -240,6 +264,32 @@ def placeholder_span_name(namespace) "appsignal.transaction #{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 + + # 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 + def write_event_body_attributes(span, body, body_format) return if body.nil? || body.empty? diff --git a/spec/lib/appsignal/transaction_spec.rb b/spec/lib/appsignal/transaction_spec.rb index 08c8630e0..a942b5f21 100644 --- a/spec/lib/appsignal/transaction_spec.rb +++ b/spec/lib/appsignal/transaction_spec.rb @@ -828,71 +828,184 @@ 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) + describe "adding the params to the transaction" do + def perform + transaction.add_params("key" => "value") + end - transaction._sample - expect(transaction).to include_params(params) + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + transaction._sample + + expect(transaction).to include_params("key" => "value") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + expect(JSON.parse(root_span.attributes["appsignal.request.payload"])) + .to eq("key" => "value") + end end - it "merges the params on the transaction" do - transaction.add_params("abc" => "value") - transaction.add_params("def" => "value") - transaction.add_params { { "xyz" => "value" } } + 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 - transaction._sample - expect(transaction).to include_params( - "abc" => "value", - "def" => "value", - "xyz" => "value" - ) + 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 - it "adds the params to the transaction with a block" do - params = { "key" => "value" } - transaction.add_params { params } + describe "adding the params to the transaction with a block" do + def perform + transaction.add_params { { "key" => "value" } } + end - transaction._sample - expect(transaction).to include_params(params) + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + transaction._sample + + expect(transaction).to include_params("key" => "value") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + expect(JSON.parse(root_span.attributes["appsignal.request.payload"])) + .to eq("key" => "value") + end end - 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 } + 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 - transaction._sample - expect(transaction).to include_params(block_params) + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + transaction._sample + + expect(transaction).to include_params("block" => "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("block" => "value") + end end - it "logs an error if an error occurred storing the params" do - transaction.add_params { raise "uh oh" } + describe "when an error occurs storing the params" do + def perform + transaction.add_params { raise "uh oh" } + end - logs = capture_logs { transaction._sample } - expect(logs).to contains_log( - :error, - "Exception while fetching params: RuntimeError: uh oh" - ) + 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 params: 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 params: RuntimeError: uh oh" + ) + expect(root_span.attributes).to_not have_key("appsignal.request.payload") + end 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) + describe "when the given params value is nil" do + def perform + transaction.add_params("key" => "value") + transaction.add_params(nil) + end - transaction._sample - expect(transaction).to include_params(params) + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + transaction._sample + + expect(transaction).to include_params("key" => "value") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + expect(JSON.parse(root_span.attributes["appsignal.request.payload"])) + .to eq("key" => "value") + end end 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 "sanitizing the params" do + def perform + transaction.add_params("foo" => "value", "baz" => "bat") + end - transaction._sample - expect(transaction).to include_params("foo" => "[FILTERED]", "baz" => "bat") + 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 @@ -905,71 +1018,173 @@ 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 to the transaction" do + def perform + transaction.add_params_if_nil("key" => "value") + end - transaction._sample - expect(transaction).to include_params(params) + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + transaction._sample + + expect(transaction).to include_params("key" => "value") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + expect(JSON.parse(root_span.attributes["appsignal.request.payload"])) + .to eq("key" => "value") + end end - it "adds the params to the transaction with a block" do - params = { "key" => "value" } - transaction.add_params_if_nil { params } + describe "adding the params to the transaction with a block" do + def perform + transaction.add_params_if_nil { { "key" => "value" } } + end - transaction._sample - expect(transaction).to include_params(params) + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + transaction._sample + + expect(transaction).to include_params("key" => "value") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + expect(JSON.parse(root_span.attributes["appsignal.request.payload"])) + .to eq("key" => "value") + end end - 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 } + 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 - transaction._sample - expect(transaction).to include_params(block_params) + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + transaction._sample + + expect(transaction).to include_params("block" => "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("block" => "value") + end 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) + describe "when the given value is nil" do + def perform + transaction.add_params("key" => "value") + transaction.add_params_if_nil(nil) + end - transaction._sample - expect(transaction).to include_params(params) + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + transaction._sample + + expect(transaction).to include_params("key" => "value") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + expect(JSON.parse(root_span.attributes["appsignal.request.payload"])) + .to eq("key" => "value") + end end end context "when the params are set" do - 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 "not updating the params 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_params(preset_params) + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + transaction._sample + + expect(transaction).to include_params("other" => "params") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + expect(JSON.parse(root_span.attributes["appsignal.request.payload"])) + .to eq("other" => "params") + end end - 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 } + 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_params(preset_params) + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + transaction._sample + + expect(transaction).to include_params("other" => "params") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + expect(JSON.parse(root_span.attributes["appsignal.request.payload"])) + .to eq("other" => "params") + end end end context "when the params were set as an empty value" do - 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") + 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_not include_params + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + transaction._sample + + expect(transaction).to_not include_params + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + expect(root_span.attributes).to_not have_key("appsignal.request.payload") + end end end end @@ -981,144 +1196,364 @@ expect(transaction.method(:add_session_data)).to eq(transaction.method(:set_session_data)) end - it "adds the session data to the transaction" do - data = { "key" => "value" } - transaction.add_session_data(data) + describe "adding the session data to the transaction" do + def perform + transaction.add_session_data("key" => "value") + end - transaction._sample - expect(transaction).to include_session_data(data) + 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 - 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" } } + 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 - transaction._sample - expect(transaction).to include_session_data( - "abc" => "value", - "def" => "value", - "xyz" => "value" - ) + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + transaction._sample + + expect(transaction).to include_session_data( + "abc" => "value", + "def" => "value", + "xyz" => "value" + ) + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + expect(JSON.parse(root_span.attributes["appsignal.request.session_data"])).to eq( + "abc" => "value", + "def" => "value", + "xyz" => "value" + ) + end end - it "adds the session data to the transaction with a block" do - data = { "key" => "value" } - transaction.add_session_data { data } + describe "adding the session data to the transaction with a block" do + def perform + transaction.add_session_data { { "key" => "value" } } + end - transaction._sample - expect(transaction).to include_session_data(data) + 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 - 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 } + 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 - transaction._sample - expect(transaction).to include_session_data(block_data) + 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 - 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)) + 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 - transaction._sample - expect(transaction).to include_session_data( - "time" => "#", - "date" => "#" - ) + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + transaction._sample + + expect(transaction).to include_session_data( + "time" => "#", + "date" => "#" + ) + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + expect(JSON.parse(root_span.attributes["appsignal.request.session_data"])).to eq( + "time" => "#", + "date" => "#" + ) + end end - it "logs an error if an error occurred storing the session data" do - transaction.add_session_data { raise "uh oh" } + describe "when an error occurs storing the session data" do + def perform + transaction.add_session_data { raise "uh oh" } + end - logs = capture_logs { transaction._sample } - expect(logs).to contains_log( - :error, - "Exception while fetching session data: RuntimeError: uh oh" - ) + 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 - 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) + describe "when the given session data value is nil" do + def perform + transaction.add_session_data("key" => "value") + transaction.add_session_data(nil) + end - transaction._sample - expect(transaction).to include_session_data(data) + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + transaction._sample + + expect(transaction).to include_session_data("key" => "value") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + expect(JSON.parse(root_span.attributes["appsignal.request.session_data"])) + .to eq("key" => "value") + end end context "with filter_session_data" do let(:options) { { :filter_session_data => ["filtered_key"] } } - it "does not include filtered out session data" do - transaction.add_session_data("data" => "value1", "filtered_key" => "filtered_value") + describe "filtering out session data" do + def perform + transaction.add_session_data("data" => "value1", "filtered_key" => "filtered_value") + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + transaction._sample + + expect(transaction).to include_session_data("data" => "value1") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + # Filtering redacts the value (mode-independent, applied before the + # backend) rather than dropping the key. + expect(JSON.parse(root_span.attributes["appsignal.request.session_data"])) + .to eq("data" => "value1", "filtered_key" => "[FILTERED]") + end + end + end + end + + describe "#add_session_data_if_nil" do + let(:transaction) { new_transaction } + + context "when the session data is not set" do + describe "setting the session data on the transaction" do + def perform + transaction.add_session_data_if_nil("key" => "value") + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + transaction._sample + + expect(transaction).to include_session_data("key" => "value") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + expect(JSON.parse(root_span.attributes["appsignal.request.session_data"])) + .to eq("key" => "value") + end + end + + describe "updating the session data on the transaction with a block" do + def perform + transaction.add_session_data_if_nil { { "key" => "value" } } + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + transaction._sample + + expect(transaction).to include_session_data("key" => "value") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + expect(JSON.parse(root_span.attributes["appsignal.request.session_data"])) + .to eq("key" => "value") + end + end + + describe "updating with the session data block when an argument and block are given" do + def perform + transaction.add_session_data_if_nil("argument" => "value") { { "block" => "value" } } + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + transaction._sample + + expect(transaction).to include_session_data("block" => "value") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete - transaction._sample - expect(transaction).to include_session_data("data" => "value1") + expect(JSON.parse(root_span.attributes["appsignal.request.session_data"])) + .to eq("block" => "value") + end end - end - end - describe "#add_session_data_if_nil" do - let(:transaction) { new_transaction } + describe "when the given value is nil" do + def perform + transaction.add_session_data("key" => "value") + transaction.add_session_data_if_nil(nil) + 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_session_data("key" => "value") + 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.session_data"])) + .to eq("key" => "value") + end 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 } + 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_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_session_data("other" => "data") + 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.session_data"])) + .to eq("other" => "data") + 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) + 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 - 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 include_session_data("other" => "data") + 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(JSON.parse(root_span.attributes["appsignal.request.session_data"])) + .to eq("other" => "data") + end end end end @@ -1269,43 +1704,90 @@ 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 + 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 - expect(transaction).to include_tags( - "valid_key" => "valid_value", - "valid_string_key" => "valid_value", - "both_symbols" => "valid_value", - "integer_value" => 1, - "too_long_value" => "#{"a" * 10_000}...", - long_string => "too_long_key", - "true_tag" => true, - "false_tag" => false - ) - end + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + transaction._sample - it "merges the tags when called multiple times" do - transaction.add_tags(:key1 => "value1") - transaction.add_tags(:key2 => "value2") - transaction._sample + # The extension truncates over-long tag values to 10,000 chars + "...". + expect(transaction).to include_tags( + "valid_key" => "valid_value", + "valid_string_key" => "valid_value", + "both_symbols" => "valid_value", + "integer_value" => 1, + "too_long_value" => "#{"a" * 10_000}...", + long_string => "too_long_key", + "true_tag" => true, + "false_tag" => false + ) + end - expect(transaction).to include_tags( - "key1" => "value1", - "key2" => "value2" - ) + 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( + "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 @@ -1313,34 +1795,83 @@ { :default_tags => { "config_tag" => "config_value", "another_tag" => 123 } } end - it "includes default_tags from config" do - transaction._sample + describe "including default_tags from config" do + def perform + 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_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 - it "transaction tags override default_tags" do - transaction.add_tags("config_tag" => "transaction_value") - transaction._sample + describe "transaction tags override default_tags" do + def perform + transaction.add_tags("config_tag" => "transaction_value") + end - expect(transaction).to include_tags( - "config_tag" => "transaction_value", - "another_tag" => 123 - ) + 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 - it "merges default_tags with transaction tags" do - transaction.add_tags("transaction_tag" => "transaction_value") - transaction._sample + describe "merging default_tags with transaction tags" do + def perform + transaction.add_tags("transaction_tag" => "transaction_value") + end - expect(transaction).to include_tags( - "config_tag" => "config_value", - "another_tag" => 123, - "transaction_tag" => "transaction_value" - ) + 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 @@ -1352,83 +1883,149 @@ 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 From 56b5fb16b6632e4eafa3aa61c1a8472ae992060e Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Mon, 8 Jun 2026 13:52:52 +0200 Subject: [PATCH 054/151] Record request headers in collector mode The transaction's environment sample data is a Rack/CGI env allowlist that mixes true HTTP headers (HTTP_*, CONTENT_LENGTH/CONTENT_TYPE) with non-header CGI vars (REQUEST_METHOD, PATH_INFO, SERVER_*). Only the true headers map to the OpenTelemetry http.request.header.* convention the collector and trace UI read, so emit those normalized to lowercase dashed names and drop the rest. The dropped CGI vars are still reported in agent mode. Dual-mode the header specs: agent asserts the environment sample data, collector asserts the normalized http.request.header.* attributes and that CGI vars are absent. --- .../transaction/opentelemetry_backend.rb | 23 ++ spec/lib/appsignal/transaction_spec.rb | 356 ++++++++++++++---- 2 files changed, 299 insertions(+), 80 deletions(-) diff --git a/lib/appsignal/transaction/opentelemetry_backend.rb b/lib/appsignal/transaction/opentelemetry_backend.rb index e776c7623..ec545d3d7 100644 --- a/lib/appsignal/transaction/opentelemetry_backend.rb +++ b/lib/appsignal/transaction/opentelemetry_backend.rb @@ -142,6 +142,8 @@ def set_sample_data(key, 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) when "error_causes" @@ -278,6 +280,27 @@ def params_attribute 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 diff --git a/spec/lib/appsignal/transaction_spec.rb b/spec/lib/appsignal/transaction_spec.rb index a942b5f21..5c78f53c1 100644 --- a/spec/lib/appsignal/transaction_spec.rb +++ b/spec/lib/appsignal/transaction_spec.rb @@ -1565,71 +1565,186 @@ def perform expect(transaction.method(:add_headers)).to eq(transaction.method(:set_headers)) end - it "adds the headers to the transaction" do - headers = { "PATH_INFO" => "value" } - transaction.add_headers(headers) + 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 - 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_environment( + "HTTP_ACCEPT" => "text/html", + "PATH_INFO" => "/path" + ) + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + # True headers normalized to the OTel convention; non-header CGI vars + # dropped. + expect(root_span.attributes["http.request.header.accept"]).to eq("text/html") + expect(root_span.attributes).to_not have_key("http.request.header.path-info") + end end - 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" } } + 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 - transaction._sample - expect(transaction).to include_environment( - "PATH_INFO" => "value", - "REQUEST_METHOD" => "value", - "HTTP_ACCEPT" => "value" - ) + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + transaction._sample + + expect(transaction).to include_environment( + "HTTP_ACCEPT" => "text/html", + "HTTP_RANGE" => "bytes=0-", + "HTTP_CACHE_CONTROL" => "no-cache" + ) + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + expect(root_span.attributes["http.request.header.accept"]).to eq("text/html") + expect(root_span.attributes["http.request.header.range"]).to eq("bytes=0-") + expect(root_span.attributes["http.request.header.cache-control"]).to eq("no-cache") + end end - it "adds the headers to the transaction with a block" do - headers = { "PATH_INFO" => "value" } - transaction.add_headers { headers } + describe "adding the headers to the transaction with a block" do + def perform + transaction.add_headers { { "HTTP_ACCEPT" => "text/html" } } + 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_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 - 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 } + 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 - transaction._sample - expect(transaction).to include_environment(block_data) + 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 - it "logs an error if an error occurred storing the headers" do - transaction.add_headers { raise "uh oh" } + describe "when an error occurs storing the headers" do + def perform + transaction.add_headers { raise "uh oh" } + end - logs = capture_logs { transaction._sample } - expect(logs).to contains_log( - :error, - "Exception while fetching headers: RuntimeError: uh oh" - ) + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + + logs = capture_logs { transaction._sample } + expect(logs).to contains_log( + :error, + "Exception while fetching headers: RuntimeError: uh oh" + ) + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + + logs = capture_logs { transaction.complete } + expect(logs).to contains_log( + :error, + "Exception while fetching headers: RuntimeError: uh oh" + ) + end end - it "does not update the headers on the transaction if the given value is nil" do - headers = { "PATH_INFO" => "value" } - transaction.add_headers(headers) - transaction.add_headers(nil) + describe "when the given headers value is nil" do + def perform + transaction.add_headers("HTTP_ACCEPT" => "text/html") + transaction.add_headers(nil) + 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_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 => ["MY_HEADER"] } } + let(:options) { { :request_headers => ["HTTP_ACCEPT"] } } - it "does not include filtered out headers" do - transaction.add_headers("MY_HEADER" => "value1", "filtered_key" => "filtered_value") + describe "filtering out headers not in the allowlist" do + def perform + transaction.add_headers("HTTP_ACCEPT" => "text/html", "HTTP_RANGE" => "bytes=0-") + end - transaction._sample - expect(transaction).to include_environment("MY_HEADER" => "value1") + 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 @@ -1642,60 +1757,141 @@ def perform 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) + describe "adding the headers to the transaction" do + def perform + transaction.add_headers_if_nil("HTTP_ACCEPT" => "text/html") + 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_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 - it "adds the headers to the transaction with a block" do - headers = { "PATH_INFO" => "value" } - transaction.add_headers_if_nil { headers } + describe "adding the headers to the transaction with a block" do + def perform + transaction.add_headers_if_nil { { "HTTP_ACCEPT" => "text/html" } } + 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_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 - 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 } + 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 - transaction._sample - expect(transaction).to include_environment(block_data) + 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 - 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) + describe "when the given value is nil" do + def perform + transaction.add_headers("HTTP_ACCEPT" => "text/html") + transaction.add_headers_if_nil(nil) + 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_environment("HTTP_ACCEPT" => "text/html") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + expect(root_span.attributes["http.request.header.accept"]).to eq("text/html") + end end end context "when the headers are set" do - 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 "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 - transaction._sample - expect(transaction).to include_environment(preset_headers) + 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 "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 } + 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 - transaction._sample - expect(transaction).to include_environment(preset_headers) + 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 From adcba5067126c211826566ffd2b813cd2d0485ce Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Mon, 8 Jun 2026 13:59:32 +0200 Subject: [PATCH 055/151] Record metadata as tags; queue start is a no-op Metadata (request path, method, ...) has no dedicated OpenTelemetry attribute but is the same shape as tags, and the collector and trace UI already surface appsignal.tag.*, so emit metadata as a tag. queue_start stays a no-op: nothing in the OTel pipeline consumes it (the agent only uses it to compute a queue_duration scalar, the collector has no handling, and the spans processor reports queue_duration 0 for OTel traces), and emulating it via span timing would distort the trace. Dual-mode the metadata, queue_start and remaining sample-data plumbing specs; collector examples assert the emitted root span. --- .../transaction/opentelemetry_backend.rb | 13 +- spec/lib/appsignal/transaction_spec.rb | 426 +++++++++++++----- 2 files changed, 321 insertions(+), 118 deletions(-) diff --git a/lib/appsignal/transaction/opentelemetry_backend.rb b/lib/appsignal/transaction/opentelemetry_backend.rb index ec545d3d7..4cbd46e07 100644 --- a/lib/appsignal/transaction/opentelemetry_backend.rb +++ b/lib/appsignal/transaction/opentelemetry_backend.rb @@ -120,10 +120,21 @@ def set_namespace(namespace) # rubocop:disable Naming/AccessorMethodName @span.set_attribute("appsignal.namespace", namespace) end + # Intentional no-op. Nothing in the OpenTelemetry pipeline consumes a queue + # start: the agent only uses it to compute a `queue_duration` scalar (it + # does not backdate the transaction), the collector has no handling, and + # the spans processor reports `queue_duration: 0` for OTel traces. A bare + # timestamp is meaningless without a consumer to compute the delta, and + # emulating it via span timing would distort the trace duration, so we drop + # it rather than emit an attribute nothing reads. def set_queue_start(_start) # rubocop:disable Naming/AccessorMethodName end - def set_metadata(_key, _value) + # 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 and the diff --git a/spec/lib/appsignal/transaction_spec.rb b/spec/lib/appsignal/transaction_spec.rb index 5c78f53c1..3947f66d8 100644 --- a/spec/lib/appsignal/transaction_spec.rb +++ b/spec/lib/appsignal/transaction_spec.rb @@ -2503,19 +2503,51 @@ def perform 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 + def perform + transaction.set_queue_start(10) + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + + expect(transaction).to have_queue_start(10) + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete - expect(transaction).to have_queue_start(10) + # Intentional no-op in collector mode: nothing consumes a queue start in + # the OTel pipeline, so no attribute is emitted. + expect(root_span.attributes.keys.grep(/queue/i)).to be_empty + 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 - expect(transaction).to_not have_queue_start + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + expect(root_span.attributes.keys.grep(/queue/i)).to be_empty + end end - it "does not raise an error when the queue start is too big" do + 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") @@ -2527,36 +2559,97 @@ def perform 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 - expect(transaction).to include_metadata("request_method" => "GET") + 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 + + # 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 - expect(transaction).to_not include_metadata("filter_key" => anything) + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete + + expect(root_span.attributes).to_not have_key("appsignal.tag.filter_key") + end end end context "when the key is nil" do - it "does not update the metadata on the transaction" do - transaction.set_metadata(nil, "GET") + describe "not updating the metadata" do + def perform + transaction.set_metadata(nil, "GET") + end - expect(transaction).to_not include_metadata + 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(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 + + 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 end @@ -2564,138 +2657,237 @@ def perform 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 From c7e6479efe3bd034c1091cca8c5cdcf66f8d3803 Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Mon, 8 Jun 2026 14:02:00 +0200 Subject: [PATCH 056/151] Emit breadcrumbs as span events in collector mode Breadcrumbs have no sample-data attribute in the OpenTelemetry pipeline, so emit each as an appsignal.breadcrumb span event on the root span. The breadcrumb's recorded time becomes the event timestamp (not a duplicate attribute); category, action and message are attributes and the metadata Hash is a JSON string, since event attributes are flat. Dual-mode the breadcrumb specs: agent asserts the breadcrumbs sample data, collector asserts the appsignal.breadcrumb events. --- .../transaction/opentelemetry_backend.rb | 23 ++++++ spec/lib/appsignal/transaction_spec.rb | 78 ++++++++++++++++--- 2 files changed, 91 insertions(+), 10 deletions(-) diff --git a/lib/appsignal/transaction/opentelemetry_backend.rb b/lib/appsignal/transaction/opentelemetry_backend.rb index 4cbd46e07..0238de46d 100644 --- a/lib/appsignal/transaction/opentelemetry_backend.rb +++ b/lib/appsignal/transaction/opentelemetry_backend.rb @@ -117,6 +117,7 @@ def set_namespace(namespace) # rubocop:disable Naming/AccessorMethodName # 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", namespace) end @@ -155,6 +156,8 @@ def set_sample_data(key, data) @span.set_attribute("appsignal.custom_data", JSON.generate(data)) when "environment" write_request_headers(data) + when "breadcrumbs" + write_breadcrumbs(data) when "tags" write_tags(data) when "error_causes" @@ -291,6 +294,26 @@ def params_attribute end end + # Breadcrumbs have no sample-data attribute in the OTel pipeline, so emit + # each as an `appsignal.breadcrumb` span event. The breadcrumb's recorded + # time becomes the event timestamp (events carry their own time), so it is + # not duplicated as an attribute; category/action/message are attributes + # and the metadata Hash is a JSON string (event attributes are flat). + def write_breadcrumbs(breadcrumbs) + breadcrumbs.each do |breadcrumb| + @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 + 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/spec/lib/appsignal/transaction_spec.rb b/spec/lib/appsignal/transaction_spec.rb index 3947f66d8..e281eda20 100644 --- a/spec/lib/appsignal/transaction_spec.rb +++ b/spec/lib/appsignal/transaction_spec.rb @@ -2228,8 +2228,13 @@ def perform describe "#add_breadcrumb" do let(:transaction) { new_transaction } + # The OpenTelemetry `appsignal.breadcrumb` events recorded on the root span. + def breadcrumb_events + root_span.events.to_a.select { |event| event.name == "appsignal.breadcrumb" } + end + context "when over the limit" do - before do + def perform 22.times do |i| transaction.add_breadcrumb( "network", @@ -2239,10 +2244,13 @@ def perform 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._sample + 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", @@ -2259,12 +2267,33 @@ def perform "time" => 1_286_704_800 ) end + + it "emits the last 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" => 3) + expect(JSON.parse(events.last.attributes["metadata"])).to eq("code" => 22) + 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") + 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._sample timeframe_end = Time.now.utc.to_i @@ -2276,14 +2305,31 @@ def perform 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 + 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._sample expect(transaction).to_not include_breadcrumbs @@ -2292,6 +2338,18 @@ def perform "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 From a72e9b85284890a6a8ffe48c6d4fb8132bfd774c Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Mon, 8 Jun 2026 14:05:17 +0200 Subject: [PATCH 057/151] Update set_sample_data spec for the raw-data seam The backend now serializes raw Ruby to Data itself, so the delegation spec passes raw data and expects the generated Data forwarded to the handle (mirroring the set_error spec). --- spec/lib/appsignal/transaction/extension_backend_spec.rb | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/spec/lib/appsignal/transaction/extension_backend_spec.rb b/spec/lib/appsignal/transaction/extension_backend_spec.rb index 34708b204..aef75c8f5 100644 --- a/spec/lib/appsignal/transaction/extension_backend_spec.rb +++ b/spec/lib/appsignal/transaction/extension_backend_spec.rb @@ -86,10 +86,12 @@ backend.set_metadata("key", "value") end - it "forwards #set_sample_data to the handle" do - data = Appsignal::Utils::Data.generate({ :a => 1 }) + 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", data) + backend.set_sample_data("params", raw) end it "serializes the backtrace Array to Data and forwards #set_error to the handle" do From 65ea45e704ed56f9393f9d4290ec5df70cde22d2 Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Mon, 8 Jun 2026 14:09:18 +0200 Subject: [PATCH 058/151] Update finish spec and stop a context leak finish now returns true, so the backend spec asserts that. The queue-start RangeError example completes its transaction so the collector-mode run detaches its OTel context instead of leaking it into later examples. --- .../transaction/opentelemetry_backend_spec.rb | 16 ++++++++++++++-- spec/lib/appsignal/transaction_spec.rb | 3 +++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb b/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb index 1b4b6785c..667f776ab 100644 --- a/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb +++ b/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb @@ -290,8 +290,8 @@ def exception_event(backend) end describe "#finish" do - it "returns false so Transaction#complete does not run the sample_data path" do - expect(create_backend.finish(0)).to eq(false) + it "returns true so Transaction#complete runs the sample_data path" do + expect(create_backend.finish(0)).to eq(true) end end @@ -339,6 +339,18 @@ def exception_event(backend) expect(duplicate).not_to be(backend) expect(duplicate.instance_variable_get(:@transaction_id)).to eq("new-id") end + + it "carries a namespace updated via #set_namespace into the duplicate" do + backend = create_backend("http_request") + backend.set_namespace("background_job") + duplicate = backend.duplicate("new-id") + @backends_created << duplicate + duplicate.complete + + # The duplicate selects its span kind from the namespace it was + # constructed with, so the updated namespace must reach it. + expect(span_exporter.finished_spans.first.kind).to eq(:consumer) + end end describe "#to_json" do diff --git a/spec/lib/appsignal/transaction_spec.rb b/spec/lib/appsignal/transaction_spec.rb index e281eda20..f786152c6 100644 --- a/spec/lib/appsignal/transaction_spec.rb +++ b/spec/lib/appsignal/transaction_spec.rb @@ -2611,6 +2611,9 @@ def perform 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 From 4507b3913371078d399d089a67056f5ebbcabd67 Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Mon, 8 Jun 2026 14:16:05 +0200 Subject: [PATCH 059/151] Test metadata/tag key collision in collector mode Both are emitted as appsignal.tag.* span attributes, so a shared key collides. Tags flush at complete (after any set_metadata), so the tag value wins regardless of order. Pin this down with collector-mode tests. --- spec/lib/appsignal/transaction_spec.rb | 28 ++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/spec/lib/appsignal/transaction_spec.rb b/spec/lib/appsignal/transaction_spec.rb index f786152c6..438a8a77f 100644 --- a/spec/lib/appsignal/transaction_spec.rb +++ b/spec/lib/appsignal/transaction_spec.rb @@ -2715,6 +2715,34 @@ def perform 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 } From 837dc27935b76fa804c63429d6de160b4cbb25ed Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Mon, 8 Jun 2026 14:50:19 +0200 Subject: [PATCH 060/151] Dual-mode the Ownership integration specs Owner tags are now covered in both agent mode (include_tags via to_h) and collector mode (root_span.attributes["appsignal.tag.*"]). Namespace-only assertions run in both modes via it_in_both_modes. The several-errors case documents the expected divergence: agent mode splits into two transactions, collector mode stays on one root span with the first error's owner. --- .../appsignal/integrations/ownership_spec.rb | 343 +++++++++++------- 1 file changed, 214 insertions(+), 129 deletions(-) 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 From c1a316b9190b93c73d9cf6fa9b51dabfa4d1b1f6 Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Mon, 8 Jun 2026 14:50:36 +0200 Subject: [PATCH 061/151] Dual-mode the Puma integration specs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Error reporting (with and without an active transaction), request params (web namespace → appsignal.request.payload), session data, request metadata, and response-status tags are now covered in both agent mode and collector mode. Queue start remains agent-only as it has no OpenTelemetry consumer. --- spec/lib/appsignal/integrations/puma_spec.rb | 263 ++++++++++++++----- 1 file changed, 197 insertions(+), 66 deletions(-) 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 From 2d0960004c33f035f806b94716daa41538c9bdc4 Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Mon, 8 Jun 2026 14:51:43 +0200 Subject: [PATCH 062/151] Dual-mode the Resque integration specs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All four behaviors — basic transaction, error tracking, argument filtering, and ActiveJob pass-through — now run in both agent and collector mode. Collector-mode examples assert action, namespace, queue tag, event spans, and (where applicable) exception events and appsignal.function.parameters. --- .../lib/appsignal/integrations/resque_spec.rb | 155 ++++++++++++++---- 1 file changed, 125 insertions(+), 30 deletions(-) diff --git a/spec/lib/appsignal/integrations/resque_spec.rb b/spec/lib/appsignal/integrations/resque_spec.rb index 16208e081..e98eabefa 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,63 @@ def self.perform raise "resque job error" end end) - - expect(Appsignal).to receive(:stop) # Resque calls stop after every job end - around do |example| - keep_transactions { example.run } + + 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 - 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 "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(namespace) + 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 - 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 +86,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(namespace) + 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 +124,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 +151,32 @@ 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(namespace) + 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 "does not set arguments for ActiveJob" do before do stub_const("ActiveJob::QueueAdapters::ResqueAdapter::JobWrapper", Class.new do class << self @@ -114,7 +189,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 +200,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,6 +218,20 @@ def perform(job_data) expect(transaction).to include_event("name" => "perform.resque") expect(transaction).to_not include_params 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(namespace) + 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 From 15b794cd4d0546967d2fc5ef9445721fe14b67bb Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Mon, 8 Jun 2026 14:51:50 +0200 Subject: [PATCH 063/151] Dual-mode the Active Job hook specs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Converts all transaction-level examples in ActiveJobClassInstrumentation to dual-mode: action, namespace, error, params (including filter), tags, metadata, retry executions, wrapped transaction, provider_job_id, and ActionMailer variants. Each example now runs in agent mode (C-extension + to_h matchers) and collector mode (OTel backend + root_span attribute assertions). queue_start remains agent-only — it has no OTel equivalent. The metric describe blocks added in the previous step (queue job count, failed job count, priority job count, queue time) are unchanged. --- spec/lib/appsignal/hooks/activejob_spec.rb | 922 +++++++++++++++------ 1 file changed, 673 insertions(+), 249 deletions(-) diff --git a/spec/lib/appsignal/hooks/activejob_spec.rb b/spec/lib/appsignal/hooks/activejob_spec.rb index e943f788b..e27194876 100644 --- a/spec/lib/appsignal/hooks/activejob_spec.rb +++ b/spec/lib/appsignal/hooks/activejob_spec.rb @@ -73,10 +73,10 @@ end end let(:options) { {} } + let(:start_agent_args) { { :options => options } } before do ActiveJob::Base.queue_adapter = :inline - start_agent(:options => options) stub_const("ActiveJobTestJob", Class.new(ActiveJob::Base) do def perform(*_args) end @@ -103,41 +103,90 @@ def perform(*_args) end end) end - around { |example| keep_transactions { example.run } } - - # The queue job count counter is covered (in both modes) by the - # "emitting the queue job count metric" describe below; this stays - # agent-only because the transaction shape it asserts (action, namespace, - # tags, events) isn't implemented in collector mode yet. - it "reports the name from the ActiveJob integration" do - 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(namespace) + expect(root_span.attributes["appsignal.action_name"]).to eq("ActiveJobTestJob#perform") + expect(exception_events).to be_empty + expect(root_span.attributes).to_not have_key("appsignal.metadata") + expect(JSON.parse(root_span.attributes["appsignal.function.parameters"])).to eq([]) + expect(root_span.attributes["appsignal.tag.active_job_id"]).to be_a(String) + expect(root_span.attributes["appsignal.tag.request_id"]).to be_a(String) + expect(root_span.attributes["appsignal.tag.queue"]).to eq(queue) + expect(root_span.attributes["appsignal.tag.executions"]).to eq(1) + # The agent sibling asserts the perform_*.active_job events; mirror that + # here by checking the event spans exist and nest under the root span. + expect(event_spans.map(&:name)).to include(*expected_perform_events) + perform_span = event_spans.find { |s| s.name == "perform.active_job" } + expect(perform_span).not_to be_nil + expect(perform_span.parent_span_id).to eq(root_span.span_id) + end end context "with custom queue" do - it "reports the custom queue as tag on the transaction" do - tags = { :queue => "custom_queue" } - expect(Appsignal).to receive(:increment_counter) - .with("active_job_queue_job_count", 1, tags.merge(:status => :processed)) - queue_job(ActiveJobCustomQueueTestJob) + describe "reports the custom queue as tag" do + def perform + queue_job(ActiveJobCustomQueueTestJob) + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + + allow(Appsignal).to receive(:increment_counter) + + perform + expect(last_transaction).to include_tags("queue" => "custom_queue") + end + + it "in collector mode", :collector_mode do + start_collector_agent - expect(last_transaction).to include_tags("queue" => "custom_queue") + allow(Appsignal).to receive(:increment_counter) + + perform + last_transaction.complete + + expect(root_span.attributes["appsignal.tag.queue"]).to eq("custom_queue") + end end end @@ -152,67 +201,121 @@ def perform(*_args) end) end - it "reports the priority as tag on the transaction" do - tags = { :queue => queue } - expect(Appsignal).to receive(:increment_counter) - .with("active_job_queue_job_count", 1, tags.merge(:status => :processed)) - expect(Appsignal).to receive(:increment_counter) - .with("active_job_queue_priority_job_count", 1, tags.merge(:priority => 10, - :status => :processed)) + describe "reports the priority as tag" do + def perform + queue_job(ActiveJobPriorityTestJob) + end + + 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" => queue, "priority" => 10) + end + + it "in collector mode", :collector_mode do + start_collector_agent + + allow(Appsignal).to receive(:increment_counter) - queue_job(ActiveJobPriorityTestJob) + perform + last_transaction.complete - expect(last_transaction).to include_tags("queue" => queue, "priority" => 10) + 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(namespace) + 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 { 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(last_transaction).to_not have_error + expect(exception_events).to be_empty + end end end @@ -220,35 +323,74 @@ def perform(*_args) context "with activejob_report_errors set to discard" do let(:options) { { :activejob_report_errors => "discard" } } - it "does not report error on first failure" do - with_test_adapter do - # Prevent the job from being instantly retried so we can test - # what happens before it's retried - allow_any_instance_of(ActiveJobErrorWithRetryTestJob).to receive(:retry_job) + describe "does not report error on first failure" do + def perform + with_test_adapter do + # Prevent the job from being instantly retried so we can test + # what happens before it's retried + allow_any_instance_of(ActiveJobErrorWithRetryTestJob).to receive(:retry_job) - queue_job(ActiveJobErrorWithRetryTestJob) + queue_job(ActiveJobErrorWithRetryTestJob) + end end - transaction = last_transaction - expect(transaction).to_not have_error - expect(transaction).to include_tags("executions" => 1) + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + + perform + + transaction = last_transaction + transaction._sample + expect(transaction).to_not have_error + expect(transaction).to include_tags("executions" => 1) + end + + it "in collector mode", :collector_mode do + start_collector_agent + + perform + last_transaction.complete + + expect(exception_events).to be_empty + expect(root_span.attributes["appsignal.tag.executions"]).to eq(1) + end end - it "reports error when discarding the job" do - allow(Appsignal).to receive(:increment_counter) - tags = { :queue => queue } - expect(Appsignal).to receive(:increment_counter) - .with("active_job_queue_job_count", 1, tags.merge(:status => :failed)) + describe "reports error when discarding the job" do + def perform + allow(Appsignal).to receive(:increment_counter) - with_test_adapter do - expect do + with_test_adapter do queue_job(ActiveJobErrorWithRetryTestJob) - end.to raise_error(RuntimeError, "uh oh") + end end - transaction = last_transaction - expect(transaction).to have_error("RuntimeError", "uh oh") - expect(transaction).to include_tags("executions" => 2) + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + + expect { perform }.to raise_error(RuntimeError, "uh oh") + + transaction = last_transaction + transaction._sample + expect(transaction).to have_error("RuntimeError", "uh oh") + expect(transaction).to include_tags("executions" => 2) + end + + it "in collector mode", :collector_mode do + start_collector_agent + + expect { perform }.to raise_error(RuntimeError, "uh oh") + last_transaction.complete + + event = exception_events.find do |e| + e.attributes["exception.type"] == "RuntimeError" + end + expect(event).not_to be_nil + expect(event.attributes["exception.message"]).to eq("uh oh") + expect(event.attributes["exception.stacktrace"]).to be_a(String) + expect(event.attributes["appsignal.alert_this_error"]).to eq(true) + expect(root_span.attributes["appsignal.tag.executions"]).to eq(2) + end end end end @@ -265,72 +407,132 @@ 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 + + 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 - expect(last_transaction).to include_tags("executions" => 2) + 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(namespace) + 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 @@ -390,22 +592,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 @@ -436,12 +666,29 @@ def perform(*_args) end) end - it "sets provider_job_id as tag" do - queue_job(ProviderWrappedActiveJobTestJob) + describe "sets provider_job_id as tag" do + def perform + queue_job(ProviderWrappedActiveJobTestJob) + end - expect(last_transaction).to include_tags( - "provider_job_id" => "my_provider_job_id" - ) + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + + perform + expect(last_transaction).to include_tags( + "provider_job_id" => "my_provider_job_id" + ) + end + + it "in collector mode", :collector_mode do + start_collector_agent + + perform + last_transaction.complete + + expect(root_span.attributes["appsignal.tag.provider_job_id"]) + .to eq("my_provider_job_id") + end end end @@ -473,22 +720,16 @@ def perform(*_args) end) end - it "sets queue time on transaction" do + # queue_start has no OTel equivalent; stays agent-only. The queue time + # metric is dual-moded separately (see "emitting the queue time metric"). + it "sets queue time on transaction", :agent_mode do + start_agent(**start_agent_args) + queue_job(ProviderWrappedActiveJobTestJob) queue_time = Time.parse("2001-01-01T09:00:00.000000000Z") expect(last_transaction).to have_queue_start((queue_time.to_f * 1_000).to_i) end - - it "reports the queue time" do - allow(Appsignal).to receive(:add_distribution_value) - - queue_job(ProviderWrappedActiveJobTestJob) - - # Asserts 1 hour queue time - expect(Appsignal).to have_received(:add_distribution_value) - .with("active_job_queue_time", 3_600_000.0, :queue => queue) - end end context "with ActionMailer job" do @@ -502,55 +743,67 @@ def welcome(_first_arg = nil, _second_arg = nil) end context "without params" do - it "sets the Action mailer data on the transaction" do - perform_mailer(ActionMailerTestJob, :welcome) + describe "sets the Action mailer data on the transaction" do + def perform + perform_mailer(ActionMailerTestJob, :welcome) + end - transaction = last_transaction - expect(transaction).to have_action("ActionMailerTestJob#welcome") - expect(transaction).to include_params( - ["ActionMailerTestJob", "welcome", "deliver_now"] + active_job_args_wrapper - ) - expect(transaction).to include_tags( - "active_job_id" => kind_of(String), - "request_id" => kind_of(String), - "queue" => "mailers", - "executions" => 1 - ) + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + + perform + + transaction = last_transaction + transaction._sample + expect(transaction).to have_action("ActionMailerTestJob#welcome") + expect(transaction).to include_params( + ["ActionMailerTestJob", "welcome", "deliver_now"] + active_job_args_wrapper + ) + expect(transaction).to include_tags( + "active_job_id" => kind_of(String), + "request_id" => kind_of(String), + "queue" => "mailers", + "executions" => 1 + ) + end + + it "in collector mode", :collector_mode do + start_collector_agent + + perform + last_transaction.complete + + expect(root_span.attributes["appsignal.action_name"]) + .to eq("ActionMailerTestJob#welcome") + expected_params = + ["ActionMailerTestJob", "welcome", "deliver_now"] + active_job_args_wrapper + expect(JSON.parse(root_span.attributes["appsignal.function.parameters"])) + .to eq(expected_params) + expect(root_span.attributes["appsignal.tag.active_job_id"]).to be_a(String) + expect(root_span.attributes["appsignal.tag.request_id"]).to be_a(String) + expect(root_span.attributes["appsignal.tag.queue"]).to eq("mailers") + expect(root_span.attributes["appsignal.tag.executions"]).to eq(1) + end end end context "with multiple arguments" do - it "sets the arguments on the transaction" do - perform_mailer(ActionMailerTestJob, :welcome, method_given_args) + describe "sets the arguments on the transaction" do + def perform + perform_mailer(ActionMailerTestJob, :welcome, method_given_args) + end - transaction = last_transaction - expect(transaction).to have_action("ActionMailerTestJob#welcome") - expect(transaction).to include_params( - ["ActionMailerTestJob", "welcome", - "deliver_now"] + active_job_args_wrapper(:args => method_expected_args) - ) - expect(transaction).to include_tags( - "active_job_id" => kind_of(String), - "request_id" => kind_of(String), - "queue" => "mailers", - "executions" => 1 - ) - end - end + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) - if DependencyHelper.rails_version >= Gem::Version.new("5.2.0") - context "with parameterized arguments" do - it "sets the arguments on the transaction" do - perform_mailer(ActionMailerTestJob, :welcome, parameterized_given_args) + perform transaction = last_transaction + transaction._sample expect(transaction).to have_action("ActionMailerTestJob#welcome") expect(transaction).to include_params( - [ - "ActionMailerTestJob", - "welcome", - "deliver_now" - ] + active_job_args_wrapper(:params => parameterized_expected_args) + ["ActionMailerTestJob", "welcome", + "deliver_now"] + active_job_args_wrapper(:args => method_expected_args) ) expect(transaction).to include_tags( "active_job_id" => kind_of(String), @@ -559,6 +812,80 @@ def welcome(_first_arg = nil, _second_arg = nil) "executions" => 1 ) end + + it "in collector mode", :collector_mode do + start_collector_agent + + perform + last_transaction.complete + + expect(root_span.attributes["appsignal.action_name"]) + .to eq("ActionMailerTestJob#welcome") + expected_params = + ["ActionMailerTestJob", "welcome", + "deliver_now"] + active_job_args_wrapper(:args => method_expected_args) + expect(JSON.parse(root_span.attributes["appsignal.function.parameters"])) + .to eq(expected_params) + expect(root_span.attributes["appsignal.tag.active_job_id"]).to be_a(String) + expect(root_span.attributes["appsignal.tag.request_id"]).to be_a(String) + expect(root_span.attributes["appsignal.tag.queue"]).to eq("mailers") + expect(root_span.attributes["appsignal.tag.executions"]).to eq(1) + end + end + end + + if DependencyHelper.rails_version >= Gem::Version.new("5.2.0") + context "with parameterized arguments" do + describe "sets the arguments on the transaction" do + def perform + perform_mailer(ActionMailerTestJob, :welcome, parameterized_given_args) + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + + perform + + transaction = last_transaction + transaction._sample + expect(transaction).to have_action("ActionMailerTestJob#welcome") + expect(transaction).to include_params( + [ + "ActionMailerTestJob", + "welcome", + "deliver_now" + ] + active_job_args_wrapper(:params => parameterized_expected_args) + ) + expect(transaction).to include_tags( + "active_job_id" => kind_of(String), + "request_id" => kind_of(String), + "queue" => "mailers", + "executions" => 1 + ) + end + + it "in collector mode", :collector_mode do + start_collector_agent + + perform + last_transaction.complete + + expect(root_span.attributes["appsignal.action_name"]) + .to eq("ActionMailerTestJob#welcome") + expected_params = + [ + "ActionMailerTestJob", + "welcome", + "deliver_now" + ] + active_job_args_wrapper(:params => parameterized_expected_args) + expect(JSON.parse(root_span.attributes["appsignal.function.parameters"])) + .to eq(expected_params) + expect(root_span.attributes["appsignal.tag.active_job_id"]).to be_a(String) + expect(root_span.attributes["appsignal.tag.request_id"]).to be_a(String) + expect(root_span.attributes["appsignal.tag.queue"]).to eq("mailers") + expect(root_span.attributes["appsignal.tag.executions"]).to eq(1) + end + end end end end @@ -576,42 +903,25 @@ def welcome(*_args) end) end - it "sets the Action mailer data on the transaction" do - perform_mailer(ActionMailerTestMailDeliveryJob, :welcome) + describe "sets the Action mailer data on the transaction" do + def perform + perform_mailer(ActionMailerTestMailDeliveryJob, :welcome) + end - transaction = last_transaction - expect(transaction).to have_action("ActionMailerTestMailDeliveryJob#welcome") - expect(transaction).to include_params( - [ - "ActionMailerTestMailDeliveryJob", - "welcome", - "deliver_now", - { active_job_internal_key => ["args"], "args" => [] } - ] - ) - expect(transaction).to include_tags( - "active_job_id" => kind_of(String), - "request_id" => kind_of(String), - "queue" => "mailers", - "executions" => 1 - ) - end + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) - context "with method arguments" do - it "sets the Action mailer data on the transaction" do - perform_mailer(ActionMailerTestMailDeliveryJob, :welcome, method_given_args) + perform transaction = last_transaction + transaction._sample expect(transaction).to have_action("ActionMailerTestMailDeliveryJob#welcome") expect(transaction).to include_params( [ "ActionMailerTestMailDeliveryJob", "welcome", "deliver_now", - { - active_job_internal_key => ["args"], - "args" => method_expected_args - } + { active_job_internal_key => ["args"], "args" => [] } ] ) expect(transaction).to include_tags( @@ -621,15 +931,103 @@ def welcome(*_args) "executions" => 1 ) end + + it "in collector mode", :collector_mode do + start_collector_agent + + perform + last_transaction.complete + + expect(root_span.attributes["appsignal.action_name"]) + .to eq("ActionMailerTestMailDeliveryJob#welcome") + expect(JSON.parse(root_span.attributes["appsignal.function.parameters"])) + .to eq([ + "ActionMailerTestMailDeliveryJob", + "welcome", + "deliver_now", + { active_job_internal_key => ["args"], "args" => [] } + ]) + expect(root_span.attributes["appsignal.tag.active_job_id"]).to be_a(String) + expect(root_span.attributes["appsignal.tag.request_id"]).to be_a(String) + expect(root_span.attributes["appsignal.tag.queue"]).to eq("mailers") + expect(root_span.attributes["appsignal.tag.executions"]).to eq(1) + end + end + + context "with method arguments" do + describe "sets the Action mailer data on the transaction" do + def perform + perform_mailer(ActionMailerTestMailDeliveryJob, :welcome, method_given_args) + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + + perform + + transaction = last_transaction + transaction._sample + expect(transaction).to have_action("ActionMailerTestMailDeliveryJob#welcome") + expect(transaction).to include_params( + [ + "ActionMailerTestMailDeliveryJob", + "welcome", + "deliver_now", + { + active_job_internal_key => ["args"], + "args" => method_expected_args + } + ] + ) + expect(transaction).to include_tags( + "active_job_id" => kind_of(String), + "request_id" => kind_of(String), + "queue" => "mailers", + "executions" => 1 + ) + end + + it "in collector mode", :collector_mode do + start_collector_agent + + perform + last_transaction.complete + + expect(root_span.attributes["appsignal.action_name"]) + .to eq("ActionMailerTestMailDeliveryJob#welcome") + expect(JSON.parse(root_span.attributes["appsignal.function.parameters"])) + .to eq([ + "ActionMailerTestMailDeliveryJob", + "welcome", + "deliver_now", + { + active_job_internal_key => ["args"], + "args" => method_expected_args + } + ]) + expect(root_span.attributes["appsignal.tag.active_job_id"]).to be_a(String) + expect(root_span.attributes["appsignal.tag.request_id"]).to be_a(String) + expect(root_span.attributes["appsignal.tag.queue"]).to eq("mailers") + expect(root_span.attributes["appsignal.tag.executions"]).to eq(1) + end + end end context "with parameterized arguments" do - it "sets the Action mailer data on the transaction" do - perform_mailer(ActionMailerTestMailDeliveryJob, :welcome, parameterized_given_args) + describe "sets the Action mailer data on the transaction" do + def perform + perform_mailer(ActionMailerTestMailDeliveryJob, :welcome, parameterized_given_args) + end - transaction = last_transaction - expect(transaction).to have_action("ActionMailerTestMailDeliveryJob#welcome") - expect(transaction).to include_params( + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + + perform + + transaction = last_transaction + transaction._sample + expect(transaction).to have_action("ActionMailerTestMailDeliveryJob#welcome") + expect(transaction).to include_params( [ "ActionMailerTestMailDeliveryJob", "welcome", @@ -641,12 +1039,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 From 67920548e7332c60467a546c9b4725e9f0528252 Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Mon, 8 Jun 2026 14:53:20 +0200 Subject: [PATCH 064/151] Dual-mode the Sidekiq integration specs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Action, namespace, error, params (job namespace → appsignal.function.parameters), tags, metadata (as appsignal.tag.* in collector mode), and instrumentation events are now covered in both agent mode and collector mode across SidekiqDeathHandler, SidekiqErrorHandler, SidekiqMiddleware, and the ActiveJob integration. queue_start stays agent-only: it has no OTel consumer and set_queue_start is a no-op in the OTel backend. --- .../appsignal/integrations/sidekiq_spec.rb | 856 +++++++++++++----- 1 file changed, 645 insertions(+), 211 deletions(-) diff --git a/spec/lib/appsignal/integrations/sidekiq_spec.rb b/spec/lib/appsignal/integrations/sidekiq_spec.rb index 5dc119a21..a0d38de21 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 @@ -271,9 +421,7 @@ class DelayedTestClass; end end let(:plugin) { Appsignal::Integrations::SidekiqMiddleware.new } let(:options) { {} } - before do - start_agent(:options => options) - end + let(:start_agent_args) { { :options => options } } around { |example| keep_transactions { example.run } } def expect_no_yaml_parse_error(logs) @@ -281,31 +429,68 @@ 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 context "with parameter filtering" do let(:options) { { :filter_parameters => ["foo"] } } - it "filters selected arguments" do - perform_sidekiq_job + describe "filters selected arguments" do + def perform + perform_sidekiq_job + end - expect(transaction).to include_params( - [ - "foo", - { - "foo" => "[FILTERED]", - "bar" => "Bar", - "baz" => { "1" => "foo" } - } - ] - ) + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + + expect(transaction).to include_params( + [ + "foo", + { + "foo" => "[FILTERED]", + "bar" => "Bar", + "baz" => { "1" => "foo" } + } + ] + ) + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + + params = JSON.parse(root_span.attributes["appsignal.function.parameters"]) + expect(params).to eq( + [ + "foo", + { + "foo" => "[FILTERED]", + "bar" => "Bar", + "baz" => { "1" => "foo" } + } + ] + ) + end end end @@ -315,10 +500,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 - expect(transaction).to include_params(expected_args << "[encrypted data]") + it "in collector mode", :collector_mode do + start_collector_agent + perform + + params = JSON.parse(root_span.attributes["appsignal.function.parameters"]) + expect(params).to eq(expected_args << "[encrypted data]") + end end end @@ -338,22 +538,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 - expect(transaction).to have_action("DelayedTestClass.foo_method") - expect(transaction).to include_params([{ "bar" => "baz" }]) + 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(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 - expect(transaction).to have_action("Sidekiq::Extensions::DelayedClass#perform") - expect(transaction).to include_params([]) - expect(logs).to contains_log(:warn, "Unable to load YAML") + it "in collector mode", :collector_mode do + start_collector_agent + logs = perform + + expect(root_span.attributes["appsignal.action_name"]) + .to eq("Sidekiq::Extensions::DelayedClass#perform") + params = JSON.parse(root_span.attributes["appsignal.function.parameters"]) + expect(params).to eq([]) + expect(logs).to contains_log(:warn, "Unable to load YAML") + end end end end @@ -374,22 +609,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::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 @@ -397,37 +667,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(namespace) + 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).not_to have_key("appsignal.title") + end end end @@ -435,55 +740,108 @@ 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_job") + 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" + ) + # queue_start has no OTel consumer; agent-only assertion. + 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(namespace) + 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") + 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).not_to have_key("appsignal.title") + end end end @@ -578,8 +936,8 @@ def expect_transaction_to_have_sidekiq_event(transaction) end end around { |example| keep_transactions { example.run } } + before do - start_agent Appsignal.internal_logger = test_logger(log) ActiveJob::Base.queue_adapter = :sidekiq @@ -605,39 +963,24 @@ def perform(*_args) end end - it "reports the transaction from the ActiveJob integration" do - perform_activejob_sidekiq_job(ActiveJobSidekiqTestJob, given_args) - - transaction = last_transaction - expect(transaction).to have_namespace(namespace) - expect(transaction).to have_action("ActiveJobSidekiqTestJob#perform") - expect(transaction).to_not have_error - expect(transaction).to include_metadata("queue" => "default") - expect(transaction).to_not include_environment - expect(transaction).to include_params([expected_args]) - expect(transaction).to include_tags(expected_tags.merge("queue" => "default")) - expect(transaction).to have_queue_start(time.to_i * 1000) - - events = transaction.to_h["events"] - .sort_by { |e| e["start"] } - .map { |event| event["name"] } - expect(events).to eq(expected_perform_events) - end + describe "reports the transaction from the ActiveJob integration" do + def perform + perform_activejob_sidekiq_job(ActiveJobSidekiqTestJob, given_args) + end - context "with error" do - it "reports the error on the transaction from the ActiveRecord integration" do - expect do - perform_activejob_sidekiq_job(ActiveJobSidekiqErrorTestJob, given_args) - end.to raise_error(RuntimeError, "uh oh") + it "in agent mode", :agent_mode do + start_agent + perform transaction = last_transaction expect(transaction).to have_namespace(namespace) - expect(transaction).to have_action("ActiveJobSidekiqErrorTestJob#perform") - expect(transaction).to have_error("RuntimeError", "uh oh") + expect(transaction).to have_action("ActiveJobSidekiqTestJob#perform") + expect(transaction).to_not have_error expect(transaction).to include_metadata("queue" => "default") expect(transaction).to_not include_environment expect(transaction).to include_params([expected_args]) expect(transaction).to include_tags(expected_tags.merge("queue" => "default")) + # queue_start has no OTel consumer; agent-only assertion. expect(transaction).to have_queue_start(time.to_i * 1000) events = transaction.to_h["events"] @@ -645,6 +988,78 @@ 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(namespace) + 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) + 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")) + # queue_start has no OTel consumer; agent-only assertion. + 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(namespace) + 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") + expect(JSON.parse(root_span.attributes["appsignal.function.parameters"])) + .to eq([expected_args]) + sidekiq_span = event_spans.find { |s| s.name == "perform_job.sidekiq" } + expect(sidekiq_span).not_to be_nil + expect(sidekiq_span.parent_span_id).to eq(root_span.span_id) + end + end end context "with ActionMailer" do @@ -657,15 +1072,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 dd435429fe9d1e5484d8838b82cfeb89b8755623 Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Mon, 8 Jun 2026 14:54:11 +0200 Subject: [PATCH 065/151] Dual-mode the Que integration specs Covers action, namespace, errors (exception + standard error), params (positional + keyword), tags, and instrumentation events in both agent mode and collector mode. Collector mode asserts on root_span attributes (appsignal.function.parameters, appsignal.tag.*, appsignal.action_name, appsignal.namespace) and exception events. --- spec/lib/appsignal/integrations/que_spec.rb | 300 ++++++++++++++------ 1 file changed, 214 insertions(+), 86 deletions(-) diff --git a/spec/lib/appsignal/integrations/que_spec.rb b/spec/lib/appsignal/integrations/que_spec.rb index 1fe937dfd..958413b24 100644 --- a/spec/lib/appsignal/integrations/que_spec.rb +++ b/spec/lib/appsignal/integrations/que_spec.rb @@ -25,111 +25,205 @@ def run(post_id, user_id) let(:instance) { job.new(job_attrs) } before do allow(Que).to receive(:execute) - - start_agent end - around { |example| keep_transactions { example.run } } def perform_que_job(job) job._run end context "without exception" do - it "creates a transaction for a job" do - expect do - perform_que_job(instance) - end.to change { created_transactions.length }.by(1) - - transaction = last_transaction - expect(transaction).to have_id - expect(transaction).to have_namespace(Appsignal::Transaction::BACKGROUND_JOB) - expect(transaction).to have_action("MyQueJob#run") - expect(transaction).to_not have_error - expect(transaction).to include_event( - "body" => "", - "body_format" => Appsignal::EventFormatter::DEFAULT, - "count" => 1, - "name" => "perform_job.que", - "title" => "" - ) - expect(transaction).to include_params( - "arguments" => %w[post_id_123 user_id_123] - ) - if DependencyHelper.que2_present? + def perform + perform_que_job(instance) + end + + describe "creates a transaction for a job" do + it "in agent mode", :agent_mode do + start_agent + expect { perform }.to change { created_transactions.length }.by(1) + + transaction = last_transaction + expect(transaction).to have_id + expect(transaction).to have_namespace(Appsignal::Transaction::BACKGROUND_JOB) + expect(transaction).to have_action("MyQueJob#run") + expect(transaction).to_not have_error + expect(transaction).to include_event( + "body" => "", + "body_format" => Appsignal::EventFormatter::DEFAULT, + "count" => 1, + "name" => "perform_job.que", + "title" => "" + ) expect(transaction).to include_params( - "keyword_arguments" => {} + "arguments" => %w[post_id_123 user_id_123] ) - else - expect(transaction).to_not include_params( - "keyword_arguments" => anything + if DependencyHelper.que2_present? + expect(transaction).to include_params( + "keyword_arguments" => {} + ) + else + expect(transaction).to_not include_params( + "keyword_arguments" => anything + ) + end + expect(transaction).to include_tags( + "attempts" => 0, + "id" => 123, + "priority" => 100, + "queue" => "dfl", + "run_at" => fixed_time.to_s ) + expect(transaction).to be_completed + end + + it "in collector mode", :collector_mode do + start_collector_agent + expect { perform }.to change { created_transactions.length }.by(1) + + expect(root_span.kind).to eq(:consumer) + expect(root_span.attributes["appsignal.namespace"]) + .to eq(Appsignal::Transaction::BACKGROUND_JOB) + 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).not_to have_key("appsignal.title") + 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(Appsignal::Transaction::BACKGROUND_JOB) + 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 - expect { perform_que_job(instance) }.to change { created_transactions.length }.by(1) - - transaction = last_transaction - expect(transaction).to have_id - expect(transaction).to have_action("MyQueJob#run") - expect(transaction).to have_namespace(Appsignal::Transaction::BACKGROUND_JOB) - expect(transaction).to have_error(error.class.name, error.message) - expect(transaction).to include_params( - "arguments" => %w[post_id_123 user_id_123] - ) - expect(transaction).to include_tags( - "attempts" => 0, - "id" => 123, - "priority" => 100, - "queue" => "dfl", - "run_at" => fixed_time.to_s - ) - expect(transaction).to be_completed + def perform + perform_que_job(instance) + end + + describe "reports errors and does not re-raise them" do + it "in agent mode", :agent_mode do + start_agent + expect { perform }.to change { created_transactions.length }.by(1) + + transaction = last_transaction + expect(transaction).to have_id + expect(transaction).to have_action("MyQueJob#run") + expect(transaction).to have_namespace(Appsignal::Transaction::BACKGROUND_JOB) + expect(transaction).to have_error(error.class.name, error.message) + expect(transaction).to include_params( + "arguments" => %w[post_id_123 user_id_123] + ) + expect(transaction).to include_tags( + "attempts" => 0, + "id" => 123, + "priority" => 100, + "queue" => "dfl", + "run_at" => fixed_time.to_s + ) + expect(transaction).to be_completed + end + + 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(Appsignal::Transaction::BACKGROUND_JOB) + event = exception_events.find { |e| e.attributes["exception.type"] == error.class.name } + expect(event).not_to be_nil + expect(event.attributes["exception.message"]).to eq(error.message) + expect(event.attributes["exception.stacktrace"]).to be_a(String) + expect(event.attributes["appsignal.alert_this_error"]).to eq(true) + expected_params = { "arguments" => %w[post_id_123 user_id_123] } + expected_params["keyword_arguments"] = {} if DependencyHelper.que2_present? + expect(JSON.parse(root_span.attributes["appsignal.function.parameters"])) + .to eq(expected_params) + expect(root_span.attributes["appsignal.tag.attempts"]).to eq(0) + expect(root_span.attributes["appsignal.tag.id"]).to eq(123) + expect(root_span.attributes["appsignal.tag.priority"]).to eq(100) + expect(root_span.attributes["appsignal.tag.queue"]).to eq("dfl") + expect(root_span.attributes["appsignal.tag.run_at"]).to eq(fixed_time.to_s) + expect(last_transaction).to be_completed + end end end @@ -154,13 +248,31 @@ def run(post_id, user_id: nil) end end - it "reports keyword arguments as parameters" do + def perform perform_que_job(instance) + end - expect(last_transaction).to include_params( - "arguments" => %w[post_id_123], - "keyword_arguments" => { "user_id" => "user_id_123" } - ) + describe "reports keyword arguments as parameters" do + it "in agent mode", :agent_mode do + start_agent + perform + + expect(last_transaction).to include_params( + "arguments" => %w[post_id_123], + "keyword_arguments" => { "user_id" => "user_id_123" } + ) + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + + expect(JSON.parse(root_span.attributes["appsignal.function.parameters"])) + .to eq( + "arguments" => %w[post_id_123], + "keyword_arguments" => { "user_id" => "user_id_123" } + ) + end end end end @@ -174,12 +286,28 @@ def run(*_args) end end - it "uses the custom action" do + def perform perform_que_job(instance) + end - transaction = last_transaction - expect(transaction).to have_action("MyCustomJob#perform") - expect(transaction).to be_completed + describe "uses the custom action" do + it "in agent mode", :agent_mode do + start_agent + perform + + transaction = last_transaction + expect(transaction).to have_action("MyCustomJob#perform") + expect(transaction).to be_completed + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + + expect(root_span.attributes["appsignal.action_name"]) + .to eq("MyCustomJob#perform") + expect(last_transaction).to be_completed + end end end end From f7311824a5cd54daf6b531a3f77895648e699129 Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Mon, 8 Jun 2026 14:54:34 +0200 Subject: [PATCH 066/151] Dual-mode the code ownership integration specs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three owner-tag examples (file annotation, directory, glob) now run in both agent mode (include_tags) and collector mode (appsignal.tag.owner attribute on root_span). Logging-only examples stay untagged — internal_logger is not OTel-routed. --- .../integrations/code_ownership_spec.rb | 88 ++++++++++++++++--- 1 file changed, 74 insertions(+), 14 deletions(-) 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 From fbf53b4471471c23df64bb390da0b20d35be08c1 Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Mon, 8 Jun 2026 14:57:14 +0200 Subject: [PATCH 067/151] Dual-mode the Delayed Job integration specs Action, namespace, error, params, tags, and instrumentation events are now covered in both agent mode and collector mode. In collector mode, params route to appsignal.function.parameters (CONSUMER span) and tags to appsignal.tag.* attributes on the root span. queue_start stays agent-only: the OTel backend drops it (no collector consumer), so only the have_queue_start assertion is kept and only for agent mode. --- .../integrations/delayed_job_plugin_spec.rb | 405 ++++++++++++++---- 1 file changed, 317 insertions(+), 88 deletions(-) diff --git a/spec/lib/appsignal/integrations/delayed_job_plugin_spec.rb b/spec/lib/appsignal/integrations/delayed_job_plugin_spec.rb index 25b4eb3aa..28b644214 100644 --- a/spec/lib/appsignal/integrations/delayed_job_plugin_spec.rb +++ b/spec/lib/appsignal/integrations/delayed_job_plugin_spec.rb @@ -12,7 +12,6 @@ def self.plugins end end) require "appsignal/integrations/delayed_job_plugin" - start_agent(:options => options) end # We haven't found a way to test the hooks, we'll have to do that manually @@ -23,6 +22,7 @@ def self.plugins let(:created_at) { time - 3600 } let(:run_at) { time - 3600 } let(:payload_object) { double(:args => args) } + let(:start_agent_args) { { :options => options } } let(:job_data) do { :id => 123, @@ -41,28 +41,50 @@ def self.plugins def perform Timecop.freeze(time) do - keep_transactions do - plugin.invoke_with_instrumentation(job, invoked_block) - end + plugin.invoke_with_instrumentation(job, invoked_block) end end context "with a normal call" do - it "wraps it in a transaction" do - perform - - transaction = last_transaction - expect(transaction).to have_namespace("background_job") - expect(transaction).to have_action("TestClass#perform") - expect(transaction).to_not have_error - expect(transaction).to include_event(:name => "perform_job.delayed_job") - expect(transaction).to include_tags( - "priority" => 1, - "attempts" => 1, - "queue" => "default", - "id" => "123" - ) - expect(transaction).to include_params(["argument"]) + describe "wraps it in a transaction" do + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + + transaction = last_transaction + expect(transaction).to have_namespace("background_job") + expect(transaction).to have_action("TestClass#perform") + expect(transaction).to_not have_error + expect(transaction).to include_event(:name => "perform_job.delayed_job") + expect(transaction).to include_tags( + "priority" => 1, + "attempts" => 1, + "queue" => "default", + "id" => "123" + ) + expect(transaction).to include_params(["argument"]) + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + + expect(root_span.kind).to eq(:consumer) + expect(root_span.attributes["appsignal.action_name"]).to eq("TestClass#perform") + expect(root_span.attributes["appsignal.namespace"]).to eq("background_job") + expect(exception_events).to be_empty + span = event_spans.find { |s| s.name == "perform_job.delayed_job" } + expect(span).not_to be_nil + expect(span.parent_span_id).to eq(root_span.span_id) + expect(span.attributes).not_to have_key("appsignal.body") + expect(span.attributes).not_to have_key("appsignal.title") + expect(root_span.attributes["appsignal.tag.priority"]).to eq(1) + expect(root_span.attributes["appsignal.tag.attempts"]).to eq(1) + expect(root_span.attributes["appsignal.tag.queue"]).to eq("default") + expect(root_span.attributes["appsignal.tag.id"]).to eq("123") + expect(JSON.parse(root_span.attributes["appsignal.function.parameters"])) + .to eq(["argument"]) + end end context "with more complex params" do @@ -73,19 +95,41 @@ def perform } end - it "adds the more complex arguments" do - perform + describe "adds the more complex arguments" do + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + + expect(last_transaction).to include_params("foo" => "Foo", "bar" => "Bar") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform - expect(last_transaction).to include_params("foo" => "Foo", "bar" => "Bar") + expect(JSON.parse(root_span.attributes["appsignal.function.parameters"])) + .to eq("foo" => "Foo", "bar" => "Bar") + end end context "with parameter filtering" do let(:options) { { :filter_parameters => ["foo"] } } - it "filters selected arguments" do - perform + describe "filters selected arguments" do + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + + expect(last_transaction).to include_params("foo" => "[FILTERED]", "bar" => "Bar") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform - expect(last_transaction).to include_params("foo" => "[FILTERED]", "bar" => "Bar") + expect(JSON.parse(root_span.attributes["appsignal.function.parameters"])) + .to eq("foo" => "[FILTERED]", "bar" => "Bar") + end end end end @@ -93,7 +137,8 @@ def perform context "with run_at in the future" do let(:run_at) { Time.parse("2017-01-01 10:01:00UTC") } - it "reports queue_start with run_at time" do + it "reports queue_start with run_at time", :agent_mode do + start_agent(**start_agent_args) perform expect(last_transaction).to have_queue_start(run_at.to_i * 1000) @@ -105,39 +150,87 @@ def perform { :name => "CustomClassMethod.perform", :payload_object => payload_object } end - it "wraps it in a transaction using the class method job name" do - perform - expect(last_transaction).to have_action("CustomClassMethod.perform") + describe "wraps it in a transaction using the class method job name" do + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + + expect(last_transaction).to have_action("CustomClassMethod.perform") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + + expect(root_span.attributes["appsignal.action_name"]) + .to eq("CustomClassMethod.perform") + end end end context "with custom name call" do - before { perform } - context "with appsignal_name defined" do context "with payload_object being an object" do context "with value" do let(:payload_object) { double(:appsignal_name => "CustomClass#perform") } - it "wraps it in a transaction using the custom name" do - expect(last_transaction).to have_action("CustomClass#perform") + describe "wraps it in a transaction using the custom name" do + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + + expect(last_transaction).to have_action("CustomClass#perform") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + + expect(root_span.attributes["appsignal.action_name"]) + .to eq("CustomClass#perform") + end end end context "with non-String value" do let(:payload_object) { double(:appsignal_name => Object.new) } - it "wraps it in a transaction using the original job name" do - expect(last_transaction).to have_action("TestClass#perform") + describe "wraps it in a transaction using the original job name" do + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + + expect(last_transaction).to have_action("TestClass#perform") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + + expect(root_span.attributes["appsignal.action_name"]) + .to eq("TestClass#perform") + end end end context "with class method name as job" do let(:payload_object) { double(:appsignal_name => "CustomClassMethod.perform") } - it "wraps it in a transaction using the custom name" do - perform - expect(last_transaction).to have_action("CustomClassMethod.perform") + describe "wraps it in a transaction using the custom name" do + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + + expect(last_transaction).to have_action("CustomClassMethod.perform") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + + expect(root_span.attributes["appsignal.action_name"]) + .to eq("CustomClassMethod.perform") + end end end end @@ -146,25 +239,63 @@ def perform context "with value" do let(:payload_object) { double(:appsignal_name => "CustomClassHash#perform") } - it "wraps it in a transaction using the custom name" do - expect(last_transaction).to have_action("CustomClassHash#perform") + describe "wraps it in a transaction using the custom name" do + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + + expect(last_transaction).to have_action("CustomClassHash#perform") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + + expect(root_span.attributes["appsignal.action_name"]) + .to eq("CustomClassHash#perform") + end end end context "with non-String value" do let(:payload_object) { double(:appsignal_name => Object.new) } - it "wraps it in a transaction using the original job name" do - expect(last_transaction).to have_action("TestClass#perform") + describe "wraps it in a transaction using the original job name" do + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + + expect(last_transaction).to have_action("TestClass#perform") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + + expect(root_span.attributes["appsignal.action_name"]) + .to eq("TestClass#perform") + end end end context "with class method name as job" do let(:payload_object) { { :appsignal_name => "CustomClassMethod.perform" } } - it "wraps it in a transaction using the custom name" do - perform - expect(last_transaction).to have_action("CustomClassMethod.perform") + describe "wraps it in a transaction using the custom name" do + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + + expect(last_transaction).to have_action("CustomClassMethod.perform") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + + expect(root_span.attributes["appsignal.action_name"]) + .to eq("CustomClassMethod.perform") + end end end end @@ -185,8 +316,21 @@ def self.appsignal_name # this means ClassActingAsHash returns `Object.new` instead # of `self.appsignal_name`. Since this isn't a valid `String` # we return the default job name as action name. - it "wraps it in a transaction using the original job name" do - expect(last_transaction).to have_action("TestClass#perform") + describe "wraps it in a transaction using the original job name" do + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + + expect(last_transaction).to have_action("TestClass#perform") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + + expect(root_span.attributes["appsignal.action_name"]) + .to eq("TestClass#perform") + end end end end @@ -197,9 +341,20 @@ def self.appsignal_name { :name => "Banana", :payload_object => payload_object } end - it "appends #perform to the class name" do - perform - expect(last_transaction).to have_action("Banana#perform") + describe "appends #perform to the class name" do + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + + expect(last_transaction).to have_action("Banana#perform") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + + expect(root_span.attributes["appsignal.action_name"]).to eq("Banana#perform") + end end end @@ -230,21 +385,45 @@ def self.appsignal_name end let(:args) { ["activejob_argument"] } - it "wraps it in a transaction with the correct params" do - perform + describe "wraps it in a transaction with the correct params" do + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform - transaction = last_transaction - expect(transaction).to have_namespace("background_job") - expect(transaction).to have_action("TestClass#perform") - expect(transaction).to_not have_error - expect(transaction).to include_event("name" => "perform_job.delayed_job") - expect(transaction).to include_tags( - "priority" => 1, - "attempts" => 1, - "queue" => "default", - "id" => "123" - ) - expect(transaction).to include_params(["activejob_argument"]) + transaction = last_transaction + expect(transaction).to have_namespace("background_job") + expect(transaction).to have_action("TestClass#perform") + expect(transaction).to_not have_error + expect(transaction).to include_event("name" => "perform_job.delayed_job") + expect(transaction).to include_tags( + "priority" => 1, + "attempts" => 1, + "queue" => "default", + "id" => "123" + ) + expect(transaction).to include_params(["activejob_argument"]) + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + + expect(root_span.kind).to eq(:consumer) + expect(root_span.attributes["appsignal.action_name"]).to eq("TestClass#perform") + expect(root_span.attributes["appsignal.namespace"]).to eq("background_job") + expect(exception_events).to be_empty + span = event_spans.find { |s| s.name == "perform_job.delayed_job" } + expect(span).not_to be_nil + expect(span.parent_span_id).to eq(root_span.span_id) + expect(span.attributes).not_to have_key("appsignal.body") + expect(span.attributes).not_to have_key("appsignal.title") + expect(root_span.attributes["appsignal.tag.priority"]).to eq(1) + expect(root_span.attributes["appsignal.tag.attempts"]).to eq(1) + expect(root_span.attributes["appsignal.tag.queue"]).to eq("default") + expect(root_span.attributes["appsignal.tag.id"]).to eq("123") + expect(JSON.parse(root_span.attributes["appsignal.function.parameters"])) + .to eq(["activejob_argument"]) + end end context "with more complex params" do @@ -255,35 +434,62 @@ def self.appsignal_name } end - it "adds the more complex arguments" do - perform - transaction = last_transaction - expect(transaction).to have_action("TestClass#perform") - expect(transaction).to include_params( - "foo" => "Foo", - "bar" => "Bar" - ) - end - - context "with parameter filtering" do - let(:options) { { :filter_parameters => ["foo"] } } - - it "filters selected arguments" do + describe "adds the more complex arguments" do + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) perform + transaction = last_transaction expect(transaction).to have_action("TestClass#perform") expect(transaction).to include_params( - "foo" => "[FILTERED]", + "foo" => "Foo", "bar" => "Bar" ) end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + + expect(root_span.attributes["appsignal.action_name"]).to eq("TestClass#perform") + expect(JSON.parse(root_span.attributes["appsignal.function.parameters"])) + .to eq("foo" => "Foo", "bar" => "Bar") + end + end + + context "with parameter filtering" do + let(:options) { { :filter_parameters => ["foo"] } } + + describe "filters selected arguments" do + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + + transaction = last_transaction + expect(transaction).to have_action("TestClass#perform") + expect(transaction).to include_params( + "foo" => "[FILTERED]", + "bar" => "Bar" + ) + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + + expect(root_span.attributes["appsignal.action_name"]).to eq("TestClass#perform") + expect(JSON.parse(root_span.attributes["appsignal.function.parameters"])) + .to eq("foo" => "[FILTERED]", "bar" => "Bar") + end + end end end context "with run_at in the future" do let(:run_at) { Time.parse("2017-01-01 10:01:00UTC") } - it "reports queue_start with run_at time" do + it "reports queue_start with run_at time", :agent_mode do + start_agent(**start_agent_args) perform expect(last_transaction).to have_queue_start(run_at.to_i * 1000) @@ -299,15 +505,36 @@ def self.appsignal_name expect(invoked_block).to receive(:call).and_raise(error) end - it "adds the error to the transaction" do - expect do - perform - end.to raise_error(error) + describe "adds the error to the transaction" do + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + expect do + perform + end.to raise_error(error) + + transaction = last_transaction + expect(transaction).to have_namespace("background_job") + expect(transaction).to have_action("TestClass#perform") + expect(transaction).to have_error("ExampleException", "uh oh") + end - transaction = last_transaction - expect(transaction).to have_namespace("background_job") - expect(transaction).to have_action("TestClass#perform") - expect(transaction).to have_error("ExampleException", "uh oh") + it "in collector mode", :collector_mode do + start_collector_agent + expect do + perform + end.to raise_error(error) + + expect(root_span.kind).to eq(:consumer) + expect(root_span.attributes["appsignal.namespace"]).to eq("background_job") + expect(root_span.attributes["appsignal.action_name"]).to eq("TestClass#perform") + event = root_span.events.find { |e| e.name == "exception" } + expect(event).not_to be_nil + expect(event.attributes["exception.type"]).to eq("ExampleException") + expect(event.attributes["exception.message"]).to eq("uh oh") + expect(event.attributes["exception.stacktrace"]).to be_a(String) + expect(event.attributes["appsignal.alert_this_error"]).to eq(true) + expect(root_span.status.code).to eq(::OpenTelemetry::Trace::Status::ERROR) + end end end end @@ -315,6 +542,8 @@ def self.appsignal_name describe ".extract_value" do let(:plugin) { Appsignal::Integrations::DelayedJobPlugin } + before { start_agent } + context "for a hash" do let(:hash) { { :key => "value", :bool_false => false } } From f1f3632ee4d06ee88e3a22ba53fa5a3245a7c10c Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Mon, 8 Jun 2026 15:04:39 +0200 Subject: [PATCH 068/151] Dual-mode the Action Cable hook specs Action, namespace, error, params, session data, tags and metadata, plus the channel/connection instrumentation events, now run in both agent and collector mode. Queue start stays agent-only: the OTel backend has no consumer for it. --- spec/lib/appsignal/hooks/action_cable_spec.rb | 645 +++++++++++++----- 1 file changed, 465 insertions(+), 180 deletions(-) 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 From f56733e028ac5c090885533078804f80d6f73f9d Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Mon, 8 Jun 2026 15:13:56 +0200 Subject: [PATCH 069/151] Dual-mode the Shoryuken integration specs Action, namespace, error, params and tags, plus the perform_job.shoryuken instrumentation event, now run in both agent and collector mode. Queue start stays agent-only: the OTel backend has no consumer for it. --- .../appsignal/integrations/shoryuken_spec.rb | 289 +++++++++++++----- 1 file changed, 217 insertions(+), 72 deletions(-) diff --git a/spec/lib/appsignal/integrations/shoryuken_spec.rb b/spec/lib/appsignal/integrations/shoryuken_spec.rb index bcf1ad9d1..6dd4db072 100644 --- a/spec/lib/appsignal/integrations/shoryuken_spec.rb +++ b/spec/lib/appsignal/integrations/shoryuken_spec.rb @@ -10,8 +10,12 @@ class DemoShoryukenWorker let(:sqs_msg) { double(:message_id => "msg1", :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 {} @@ -35,38 +39,88 @@ def perform_shoryuken_job(&block) 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 + ) + # queue_start: agent-only; the OTel backend intentionally drops it + # (no collector consumer computes queue_duration from a raw 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(Appsignal::Transaction::BACKGROUND_JOB) + 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).not_to have_key("appsignal.title") + 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_start has no OTel representation; assert no attribute is emitted + expect(root_span.attributes).not_to have_key("appsignal.queue_start") + expect(last_transaction).to be_completed + end end context "with parameter filtering" do let(:options) { { :filter_parameters => ["foo"] } } - it "filters selected arguments" do - perform_shoryuken_job + describe "filters selected arguments" do + def perform + perform_shoryuken_job + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform - expect(last_transaction).to include_params("foo" => "[FILTERED]", "bar" => "Bar") + 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 +128,94 @@ def perform_shoryuken_job(&block) context "with a string as an argument" do let(:body) { "foo bar" } - it "handles string arguments" do - perform_shoryuken_job + describe "handles string arguments" do + def perform + perform_shoryuken_job + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform - expect(last_transaction).to include_params("params" => body) + expect(last_transaction).to include_params("params" => body) + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + + expect(JSON.parse(root_span.attributes["appsignal.function.parameters"])) + .to eq("params" => body) + end end end context "with primitive type as argument" do let(:body) { 1 } - it "handles primitive types as arguments" do - perform_shoryuken_job + 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(last_transaction).to include_params("params" => body) + expect(JSON.parse(root_span.attributes["appsignal.function.parameters"])) + .to eq("params" => body) + end 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 + 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 + end + + it "in collector mode", :collector_mode do + start_collector_agent expect do - perform_shoryuken_job { raise ExampleException, "error message" } - 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("DemoShoryukenWorker#perform") - expect(transaction).to have_namespace(Appsignal::Transaction::BACKGROUND_JOB) - expect(transaction).to have_error("ExampleException", "error message") - expect(transaction).to be_completed + 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(Appsignal::Transaction::BACKGROUND_JOB) + + 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 @@ -132,34 +242,69 @@ def perform_shoryuken_job(&block) end let(:sent_timestamp) { Time.parse("1976-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(Appsignal::Transaction::BACKGROUND_JOB) + 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).not_to have_key("appsignal.title") + 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_start has no OTel representation; assert no attribute is emitted + expect(root_span.attributes).not_to have_key("appsignal.queue_start") + end end end end From 1417ecff4e040380aeb047984bb90787908b50c8 Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Mon, 8 Jun 2026 15:18:04 +0200 Subject: [PATCH 070/151] Dual-mode the Railtie integration specs Action, namespace, error, custom data and tags now run in both agent and collector mode, asserting the emitted root span in collector mode. --- .../appsignal/integrations/railtie_spec.rb | 654 +++++++++++++----- 1 file changed, 492 insertions(+), 162 deletions(-) diff --git a/spec/lib/appsignal/integrations/railtie_spec.rb b/spec/lib/appsignal/integrations/railtie_spec.rb index dbff4aeea..a831bcb44 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(Appsignal::Transaction::HTTP_REQUEST) + 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 From 06b3de1ccc0a8a9c81993e1d0c980e1fdd3c3c30 Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Wed, 10 Jun 2026 11:25:28 +0200 Subject: [PATCH 071/151] Dual-mode the abstract Rack middleware specs Run the namespace, instrumentation event, error reporting, request metadata, parameter, session and parent-instrumentation cases in both agent and collector mode, asserting the emitted span in collector mode. Queue start is a no-op in collector mode, so the collector example asserts no queue start is recorded rather than a value. --- .../rack/abstract_middleware_spec.rb | 695 +++++++++++++++--- 1 file changed, 575 insertions(+), 120 deletions(-) diff --git a/spec/lib/appsignal/rack/abstract_middleware_spec.rb b/spec/lib/appsignal/rack/abstract_middleware_spec.rb index e51bb02dc..5781ac15e 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(Appsignal::Transaction::HTTP_REQUEST) + 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(Appsignal::Transaction::HTTP_REQUEST) + 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) + # `set_queue_start` is an intentional no-op in collector mode: + # nothing in the OpenTelemetry pipeline consumes a queue start. + expect(last_transaction).to_not have_queue_start + 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,114 @@ 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 { |span| span.name == "process_response_body.rack" } + 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 { |span| span.name == "process_response_body.rack" } + expect(response_events).to eq(1) + end + end end context "with error" do let(:app) { lambda { |_env| raise ExampleException, "error message" } } - it "doesn't record the error on the transaction" do - make_request_with_error(ExampleException, "error message") + describe "doesn't record the error on the transaction" do + def perform + setup_parent_transaction + make_request_with_error(ExampleException, "error message") + end - expect(last_transaction).to_not have_error + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + + expect(last_transaction).to_not have_error + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + # The middleware leaves the parent open; finish it so its span + # exports and we can confirm no exception event was recorded. + Appsignal::Transaction.complete_current! + + expect(exception_events).to be_empty + end end end - it "doesn't complete the existing transaction" do + it_in_both_modes "doesn't complete the existing transaction" do + setup_parent_transaction make_request expect(env[Appsignal::Rack::APPSIGNAL_TRANSACTION]).to_not be_completed end context "with custom set action name" do - it "does not overwrite the action name" do - env[Appsignal::Rack::APPSIGNAL_TRANSACTION].set_action("My custom action") - env["appsignal.action"] = "POST /my-action" - make_request + describe "does not overwrite the action name" do + def perform + setup_parent_transaction + env[Appsignal::Rack::APPSIGNAL_TRANSACTION].set_action("My custom action") + env["appsignal.action"] = "POST /my-action" + make_request + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + perform + + expect(last_transaction).to have_action("My custom action") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + Appsignal::Transaction.complete_current! - expect(last_transaction).to have_action("My custom action") + expect(root_span.name).to eq("My custom action") + expect(root_span.attributes["appsignal.action_name"]).to eq("My custom action") + end end end @@ -325,10 +704,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 +731,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 +764,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 +791,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 From a8fddc1b88bf74d2c48fd97d08515ecaa83f647d Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Wed, 10 Jun 2026 11:26:03 +0200 Subject: [PATCH 072/151] Dual-mode the instrumentation middleware specs Run the event, custom action name and missing action name cases in both agent and collector mode, asserting the emitted span in collector mode. --- .../rack/instrumentation_middleware_spec.rb | 68 +++++++++++++++---- 1 file changed, 56 insertions(+), 12 deletions(-) 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 From ffa82b9d67acc78196068b4afe7463592d09b7bd Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Wed, 10 Jun 2026 11:30:52 +0200 Subject: [PATCH 073/151] Dual-mode the Rack EventHandler specs Run the on_start, on_error and on_finish cases in both agent and collector mode, asserting the emitted span, exception events and response status metric in collector mode. Queue start is a no-op in collector mode, so the collector examples assert no queue start is recorded. --- spec/lib/appsignal/rack/event_handler_spec.rb | 861 ++++++++++++++---- 1 file changed, 696 insertions(+), 165 deletions(-) diff --git a/spec/lib/appsignal/rack/event_handler_spec.rb b/spec/lib/appsignal/rack/event_handler_spec.rb index e63d1d86b..17c98bb9c 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,108 @@ def on_error(error) end describe "#on_start" do - it "creates a new transaction" do - expect { on_start }.to change { created_transactions.length }.by(1) + describe "creates a new transaction" do + def perform + on_start + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + use_test_logger + expect { perform }.to change { created_transactions.length }.by(1) - transaction = last_transaction - expect(transaction).to have_id - expect(transaction).to have_namespace(Appsignal::Transaction::HTTP_REQUEST) + transaction = last_transaction + expect(transaction).to have_id + expect(transaction).to have_namespace(Appsignal::Transaction::HTTP_REQUEST) - expect(Appsignal::Transaction.current).to eq(transaction) + 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(Appsignal::Transaction::HTTP_REQUEST) + 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) + end - expect(Appsignal::Transaction.current).to be_kind_of(Appsignal::Transaction::NilTransaction) + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + use_test_logger + perform - expect(last_transaction.backend.queue_start).to eq(queue_start_time) - expect(last_transaction).to include_event( - "name" => "process_request.rack", - "title" => "callback: after_reply" - ) + expect(last_transaction.backend.queue_start).to eq(queue_start_time) + expect(last_transaction).to include_event( + "name" => "process_request.rack", + "title" => "callback: after_reply" + ) + end + + it "in collector mode", :collector_mode do + start_collector_agent + use_test_logger + perform + + # `set_queue_start` is an intentional no-op in collector mode. + expect(last_transaction.backend.queue_start).to be_nil + event = event_spans.find { |span| span.name == "process_request.rack" } + expect(event).not_to be_nil + expect(event.parent_span_id).to eq(root_span.span_id) + expect(event.attributes["appsignal.title"]).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 +186,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 +205,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 +221,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 +236,99 @@ def on_error(error) end describe "#on_error" do - it "reports the error" do - on_start - on_error(ExampleStandardError.new("the error")) + describe "reports the error" do + def perform + on_start + on_error(ExampleStandardError.new("the error")) + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + use_test_logger + perform - expect(last_transaction).to have_error("ExampleStandardError", "the error") + expect(last_transaction).to have_error("ExampleStandardError", "the error") + end + + it "in collector mode", :collector_mode do + start_collector_agent + use_test_logger + perform + # The error is recorded on the still-open span; finish it so the + # exception event exports. + Appsignal::Transaction.complete_current! + + # The error is set while the `process_request.rack` event span is the + # current span (on_start opens it; it is not finished before on_error), + # so the exception event rides on that event span, not the root span. + error_span = span_exporter.finished_spans.find do |span| + Array(span.events).any? { |e| e.name == "exception" } + end + expect(error_span).not_to be_nil + event = error_span.events.find { |e| e.name == "exception" } + expect(event.attributes["exception.type"]).to eq("ExampleStandardError") + expect(event.attributes["exception.message"]).to eq("the error") + expect(event.attributes["exception.stacktrace"]).to be_a(String) + expect(event.attributes["appsignal.alert_this_error"]).to eq(true) + expect(error_span.status.code).to eq(::OpenTelemetry::Trace::Status::ERROR) + end end context "when not active" do let(:appsignal_env) { :inactive_env } - it "does not report the transaction" do - on_start - on_error(ExampleStandardError.new("the error")) + describe "does not report the transaction" do + def perform + on_start + on_error(ExampleStandardError.new("the error")) + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + use_test_logger + perform + + expect(last_transaction).to_not have_error + end - expect(last_transaction).to_not have_error + 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 +350,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 +429,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 +461,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 +490,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_error(ExampleStandardError.new("the error")) on_finish(request, nil) + end - expect(last_transaction).to include_tags("response_status" => 500) + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + use_test_logger + perform + + expect(last_transaction).to_not have_action + expect(last_transaction).to be_completed end - it "increments the response status counter for response status 500" do - expect(Appsignal).to receive(:increment_counter) - .with(:response_status, 1, :status => 500, :namespace => :web) + it "in collector mode", :collector_mode do + start_collector_agent + use_test_logger + perform + + expect(root_span.attributes).to_not have_key("appsignal.action_name") + expect(last_transaction).to be_completed + end + end + describe "does not set a response_status tag" do + def perform + on_start + on_finish(request, nil) + end + + it "in agent mode", :agent_mode do + start_agent(**start_agent_args) + use_test_logger + perform + + expect(last_transaction).to_not include_tags("response_status" => anything) + end + + it "in collector mode", :collector_mode do + start_collector_agent + use_test_logger + perform + + expect(root_span.attributes.keys).to_not include("appsignal.tag.response_status") + end + end + + describe "does not report a response_status counter metric" do + def perform on_start - on_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,65 +826,174 @@ 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 - context "with an error previously recorded by on_error" do - it "sets response status from the response as a tag" do + it "in collector mode", :collector_mode do + start_collector_agent + use_test_logger + perform + + event = event_spans.find { |span| span.name == "process_request.rack" } + expect(event).not_to be_nil + expect(event.parent_span_id).to eq(root_span.span_id) + expect(event.attributes["appsignal.title"]).to eq("callback: on_finish") + end + end + + 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") From 0aece916fce3d5418360157736d64863cd4e344e Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Wed, 10 Jun 2026 11:34:23 +0200 Subject: [PATCH 074/151] Dual-mode the Rails instrumentation specs Run the action name, request metadata, filtered parameter and error cases in both agent and collector mode, asserting the emitted span in collector mode. --- .../rack/rails_instrumentation_spec.rb | 273 ++++++++++++++---- 1 file changed, 223 insertions(+), 50 deletions(-) diff --git a/spec/lib/appsignal/rack/rails_instrumentation_spec.rb b/spec/lib/appsignal/rack/rails_instrumentation_spec.rb index 5b28b5be0..ec5ebde68 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(Appsignal::Transaction::HTTP_REQUEST) + 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 From 9d61854c4d6995f3dc2f9973c653613db5463bd2 Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Wed, 10 Jun 2026 11:35:31 +0200 Subject: [PATCH 075/151] Dual-mode the Grape middleware specs Run the action name, request metadata and error cases in both agent and collector mode, asserting the emitted span in collector mode. --- .../appsignal/rack/grape_middleware_spec.rb | 163 +++++++++++++----- 1 file changed, 118 insertions(+), 45 deletions(-) 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 From b37e7dc454bcee302a56f2e03bf5c4a55fe60d84 Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Wed, 10 Jun 2026 11:36:29 +0200 Subject: [PATCH 076/151] Dual-mode the Hanami middleware specs Run the request parameter, event and action name cases in both agent and collector mode, asserting the emitted span in collector mode. --- .../appsignal/rack/hanami_middleware_spec.rb | 89 +++++++++++++++---- 1 file changed, 74 insertions(+), 15 deletions(-) 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 From 14f8bcd07d93f372781fd349a1172e3e43a3f342 Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Wed, 10 Jun 2026 17:17:34 +0200 Subject: [PATCH 077/151] Dual-mode the Padrino loader specs The action derivation (app name, controller, action and path fallbacks) now runs in both agent and collector mode, asserting the emitted root span name and action attribute in collector mode. --- spec/lib/appsignal/loaders/grape_spec.rb | 2 +- spec/lib/appsignal/loaders/padrino_spec.rb | 200 +++++++++++++++++---- 2 files changed, 162 insertions(+), 40 deletions(-) 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/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 From ba8ad640b3fb79dcc847c119afff52ed544f4c1b Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Wed, 10 Jun 2026 17:17:34 +0200 Subject: [PATCH 078/151] Dual-mode the Hanami loader specs The HanamiIntegration action name now runs in both agent and collector mode, asserting the emitted root span in collector mode. --- spec/lib/appsignal/loaders/hanami_spec.rb | 69 +++++++++++++++++++---- 1 file changed, 57 insertions(+), 12 deletions(-) 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 From b0e77532e284be39db7910057b89ead441c52e30 Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Thu, 11 Jun 2026 09:50:37 +0200 Subject: [PATCH 079/151] Dual-mode Appsignal.monitor and metadata setters Appsignal.monitor and the metadata-setter helpers (tag_request, add_params, add_session_data, add_headers, add_custom_data, add_breadcrumb) were only exercised in agent mode. Cover them in collector mode too, and deepen the send/set/report_error examples to assert the exception event's stacktrace, alert flag and span status. --- spec/lib/appsignal_spec.rb | 507 ++++++++++++++++++++++++++++++------- 1 file changed, 416 insertions(+), 91 deletions(-) diff --git a/spec/lib/appsignal_spec.rb b/spec/lib/appsignal_spec.rb index 10cbb58b6..8d7c2cf00 100644 --- a/spec/lib/appsignal_spec.rb +++ b/spec/lib/appsignal_spec.rb @@ -1150,90 +1150,231 @@ def on_start 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(Appsignal::Transaction::HTTP_REQUEST) + expect(root_span.name).to eq("MyAction") + expect(root_span.attributes["appsignal.action_name"]).to eq("MyAction") + expect(exception_events).to be_empty + expect(event_spans).to be_empty + expect(last_transaction).to be_completed + end end - it "returns the block's return value" do + it_in_both_modes "returns the block's return value" do expect(Appsignal.monitor(:action => nil) { :return_value }).to eq(:return_value) end - it "sets a custom namespace via the namespace argument" do - Appsignal.monitor(:namespace => "custom", :action => nil) + describe "setting a custom namespace via the namespace argument" do + def perform + Appsignal.monitor(:namespace => "custom", :action => nil) + end - expect(last_transaction).to have_namespace("custom") - end + it "in agent mode", :agent_mode do + start_agent + perform - it "doesn't overwrite custom namespace set in the block" do - Appsignal.monitor(:namespace => "custom", :action => nil) do - Appsignal.set_namespace("more custom") + expect(last_transaction).to have_namespace("custom") end - expect(last_transaction).to have_namespace("more custom") + it "in collector mode", :collector_mode do + start_collector_agent + perform + + expect(root_span.attributes["appsignal.namespace"]).to eq("custom") + end end - it "sets the action via the action argument using a string" do - Appsignal.monitor(:action => "custom") + describe "not overwriting a custom namespace set in the block" do + def perform + Appsignal.monitor(:namespace => "custom", :action => nil) do + Appsignal.set_namespace("more custom") + end + end + + it "in agent mode", :agent_mode do + start_agent + perform + + expect(last_transaction).to have_namespace("more custom") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform - expect(last_transaction).to have_action("custom") + expect(root_span.attributes["appsignal.namespace"]).to eq("more custom") + end end - it "sets the action via the action argument using a symbol" do - Appsignal.monitor(:action => :custom) + describe "setting the action via the action argument" do + def perform(action) + Appsignal.monitor(:action => action) + end + + it "in agent mode using a string", :agent_mode do + start_agent + perform("custom") + + expect(last_transaction).to have_action("custom") + end + + it "in collector mode using a string", :collector_mode do + start_collector_agent + perform("custom") + + expect(root_span.name).to eq("custom") + expect(root_span.attributes["appsignal.action_name"]).to eq("custom") + end + + it "in agent mode using a symbol", :agent_mode do + start_agent + perform(:custom) + + expect(last_transaction).to have_action("custom") + end + + it "in collector mode using a symbol", :collector_mode do + start_collector_agent + perform(:custom) - expect(last_transaction).to have_action("custom") + expect(root_span.name).to eq("custom") + expect(root_span.attributes["appsignal.action_name"]).to eq("custom") + end end - it "doesn't overwrite custom action set in the block" do - Appsignal.monitor(:action => "custom") do - Appsignal.set_action("more custom") + describe "not overwriting a custom action set in the block" do + def perform + Appsignal.monitor(:action => "custom") do + Appsignal.set_action("more custom") + end + end + + it "in agent mode", :agent_mode do + start_agent + perform + + expect(last_transaction).to have_action("more custom") end - expect(last_transaction).to have_action("more custom") + it "in collector mode", :collector_mode do + start_collector_agent + perform + + expect(root_span.name).to eq("more custom") + expect(root_span.attributes["appsignal.action_name"]).to eq("more custom") + end end - it "doesn't set the action when value is nil" do - Appsignal.monitor(:action => nil) + describe "not setting the action when the value is nil" do + def perform + Appsignal.monitor(:action => nil) + end - 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 + + it "in collector mode", :collector_mode do + start_collector_agent + perform - expect(last_transaction).to_not have_action + expect(root_span.attributes).to_not have_key("appsignal.action_name") + end end - it "reports exceptions that occur in the block" do - expect do - Appsignal.monitor :action => nil do - raise ExampleException, "error message" - end - end.to raise_error(ExampleException, "error message") + describe "reporting exceptions that occur in the block" do + def perform + expect do + Appsignal.monitor :action => nil do + raise ExampleException, "error message" + end + end.to raise_error(ExampleException, "error message") + end + + it "in agent mode", :agent_mode do + start_agent + perform + + expect(last_transaction).to have_error("ExampleException", "error message") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform - expect(last_transaction).to have_error("ExampleException", "error message") + event = root_span.events.find { |e| e.name == "exception" } + expect(event).not_to be_nil + expect(event.attributes["exception.type"]).to eq("ExampleException") + expect(event.attributes["exception.message"]).to eq("error message") + expect(event.attributes["exception.stacktrace"]).to be_a(String) + expect(event.attributes["appsignal.alert_this_error"]).to eq(true) + expect(root_span.status.code).to eq(::OpenTelemetry::Trace::Status::ERROR) + end end - context "with already active transction" do + context "with an already active transaction" do let(:err_stream) { std_stream } let(:stderr) { err_stream.read } let(:transaction) { http_request_transaction } - before do + + # The parent transaction is built lazily inside each example body (after + # the mode context has started the agent), per the dual-mode start + # principle -- building it in a `before` would create it against the + # wrong backend. + def activate_parent_transaction set_current_transaction(transaction) transaction.set_action("My action") end - it "doesn't create a new transaction" do + it_in_both_modes "doesn't create a new transaction" do + activate_parent_transaction logs = nil expect do logs = @@ -1249,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(Appsignal::Transaction::HTTP_REQUEST) + end end - it "does not overwrite the parent transaction's action" do - silence { Appsignal.monitor(:action => "custom") } + describe "not overwriting the parent transaction's action" do + def perform + activate_parent_transaction + silence { Appsignal.monitor(:action => "custom") } + end + + it "in agent mode", :agent_mode do + start_agent + perform + + expect(transaction).to have_action("My action") + end + + it "in collector mode", :collector_mode do + start_collector_agent + perform + transaction.complete - expect(transaction).to have_action("My action") + expect(root_span.name).to eq("My action") + expect(root_span.attributes["appsignal.action_name"]).to eq("My action") + end end - it "doesn't complete the parent transaction" do + it_in_both_modes "doesn't complete the parent transaction" do + activate_parent_transaction silence { Appsignal.monitor(:action => nil) } expect(transaction).to_not be_completed @@ -1301,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 @@ -1336,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") @@ -1399,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") @@ -1445,23 +1680,44 @@ 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") Appsignal.add_headers("REQUEST_METHOD" => "GET") @@ -1491,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( @@ -1514,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") @@ -1539,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", @@ -1553,6 +1834,11 @@ def on_start { :response => 200 }, fixed_time ) + end + + it "in agent mode", :agent_mode do + start_agent + perform transaction._sample expect(transaction).to include_breadcrumb( @@ -1563,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 @@ -1645,8 +1945,14 @@ def perform expect(root_span.attributes["appsignal.namespace"]) .to eq(Appsignal::Transaction::HTTP_REQUEST) expect(root_span.attributes).not_to have_key("appsignal.action_name") - expect(exception_events.first.attributes["exception.type"]).to eq("ExampleException") - expect(exception_events.first.attributes["exception.message"]).to eq("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 @@ -1692,7 +1998,13 @@ def 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(exception_events.first.attributes["exception.type"]).to eq("StandardError") + + 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 @@ -1765,9 +2077,13 @@ def perform perform transaction.complete - expect(exception_events.first.attributes["exception.type"]).to eq("ExampleException") - expect(exception_events.first.attributes["exception.message"]) - .to eq("I am an exception") + 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 @@ -1864,9 +2180,13 @@ def perform perform expect(root_span).not_to be_nil - expect(exception_events.first.attributes["exception.type"]).to eq("ExampleException") - expect(exception_events.first.attributes["exception.message"]) - .to eq("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 @@ -1935,8 +2255,13 @@ def perform perform transaction.complete - expect(exception_events.first.attributes["exception.type"]).to eq("ExampleException") - expect(exception_events.first.attributes["exception.message"]).to eq("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 From 8b0b3df46662c77ff7b20fe32bf5101c0b62384a Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Thu, 11 Jun 2026 09:18:42 +0200 Subject: [PATCH 080/151] Drop discarded subtraces via ignore_subtrace In collector mode, discarding a transaction now finishes the root span flagged with `appsignal.ignore_subtrace = true`, which the collector hoists and uses to drop the whole subtrace. Previously the discard path returned before the backend teardown, so the OpenTelemetry root span was never finished and its context token was never detached -- leaking the discarded span as the thread's current context. Agent mode is unchanged: the transaction is still dropped without contacting the agent. --- lib/appsignal/transaction.rb | 7 ++ .../transaction/extension_backend.rb | 8 +++ .../transaction/opentelemetry_backend.rb | 54 +++++++++++----- .../transaction/extension_backend_spec.rb | 5 ++ .../transaction/opentelemetry_backend_spec.rb | 44 +++++++++++++ spec/lib/appsignal/transaction_spec.rb | 64 ++++++++++++++++--- 6 files changed, 157 insertions(+), 25 deletions(-) diff --git a/lib/appsignal/transaction.rb b/lib/appsignal/transaction.rb index 05a8a4259..e0f08f1ec 100644 --- a/lib/appsignal/transaction.rb +++ b/lib/appsignal/transaction.rb @@ -214,6 +214,13 @@ def complete 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 diff --git a/lib/appsignal/transaction/extension_backend.rb b/lib/appsignal/transaction/extension_backend.rb index 90d868034..17e8f88bc 100644 --- a/lib/appsignal/transaction/extension_backend.rb +++ b/lib/appsignal/transaction/extension_backend.rb @@ -73,6 +73,14 @@ def complete @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. def supports_multiple_errors? diff --git a/lib/appsignal/transaction/opentelemetry_backend.rb b/lib/appsignal/transaction/opentelemetry_backend.rb index 0238de46d..d3944cbb4 100644 --- a/lib/appsignal/transaction/opentelemetry_backend.rb +++ b/lib/appsignal/transaction/opentelemetry_backend.rb @@ -220,24 +220,22 @@ def finish(_gc_duration) end def complete - # Idempotent: completing twice would re-detach an already-detached - # context (a mismatched-detach error) and re-finish an ended span (a - # warning). The Transaction can be completed directly and then again - # by a `complete_current!` cleanup path, so guard against it. + teardown + end + + # Discarding does not mean "don't send" as it does in agent mode: the + # root span is still finished and exported, but flagged with + # `appsignal.ignore_subtrace = true` so the collector drops the whole + # subtrace (the attribute is hoisted to the subtrace root, last write + # wins). The flag must be written before the span finishes -- attributes + # set on an ended span are dropped. Tearing the span down here also + # detaches the context, so a discarded transaction can't leak its root + # span as the current OTel context onto the thread. + def discard return if @completed - @completed = true - # Drain unfinished event spans defensively: if `start_event` was - # called without a matching `finish_event` (caller bug or aborted - # flow), the root context can't detach in LIFO order until the - # event tokens are released first. - 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 + @span&.set_attribute("appsignal.ignore_subtrace", true) + teardown end def duplicate(new_transaction_id) @@ -272,6 +270,30 @@ def _completed? private + # Detaches the OTel context and finishes the root span. Shared by + # `complete` and `discard`. + # + # Idempotent: tearing down twice would re-detach an already-detached + # context (a mismatched-detach error) and re-finish an ended span (a + # warning). The Transaction can be completed directly and then again by a + # `complete_current!` cleanup path, so guard against it. + def teardown + return if @completed + + @completed = true + # Drain unfinished event spans defensively: if `start_event` was + # called without a matching `finish_event` (caller bug or aborted + # flow), the root context can't detach in LIFO order until the + # event tokens are released first. + 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 + def tracer ::OpenTelemetry.tracer_provider.tracer(TRACER_NAME, Appsignal::VERSION) end diff --git a/spec/lib/appsignal/transaction/extension_backend_spec.rb b/spec/lib/appsignal/transaction/extension_backend_spec.rb index aef75c8f5..4d1de23ad 100644 --- a/spec/lib/appsignal/transaction/extension_backend_spec.rb +++ b/spec/lib/appsignal/transaction/extension_backend_spec.rb @@ -118,6 +118,11 @@ 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("{}") diff --git a/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb b/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb index 667f776ab..8d78d46ed 100644 --- a/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb +++ b/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb @@ -325,6 +325,50 @@ def exception_event(backend) 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 # Multi-error duplicate is dead code in collector mode until errors are # wired up in a later step (one span + multiple `record_exception` events diff --git a/spec/lib/appsignal/transaction_spec.rb b/spec/lib/appsignal/transaction_spec.rb index 438a8a77f..565375f17 100644 --- a/spec/lib/appsignal/transaction_spec.rb +++ b/spec/lib/appsignal/transaction_spec.rb @@ -289,14 +289,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 @@ -308,17 +304,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 - expect(transaction).to be_completed + 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 + + # 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 From 72020195f2ba99ca8c7f5ef41a0a4d4e81256af0 Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Fri, 12 Jun 2026 12:54:41 +0200 Subject: [PATCH 081/151] Map event title to the OTel span name In collector mode the OTel span name is the event's display label, so use the human-readable title as the span name (falling back to the event name when there is none) and carry the event name in the new appsignal.category attribute. Drop appsignal.title, which the collector never read. --- .../transaction/opentelemetry_backend.rb | 19 +++++++-- .../integration/collector_mode_traces_spec.rb | 13 ++++-- .../instrument_shared_examples.rb | 6 ++- spec/lib/appsignal/hooks/dry_monitor_spec.rb | 4 +- spec/lib/appsignal/hooks/excon_spec.rb | 8 ++-- 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 | 4 +- spec/lib/appsignal/integrations/http_spec.rb | 34 +++++++-------- .../integrations/mongo_ruby_driver_spec.rb | 10 +++-- .../appsignal/integrations/net_http_spec.rb | 8 ++-- spec/lib/appsignal/integrations/que_spec.rb | 2 +- .../appsignal/integrations/shoryuken_spec.rb | 4 +- .../appsignal/integrations/sidekiq_spec.rb | 4 +- .../rack/abstract_middleware_spec.rb | 8 +++- spec/lib/appsignal/rack/body_wrapper_spec.rb | 14 ++++--- spec/lib/appsignal/rack/event_handler_spec.rb | 12 ++++-- .../transaction/opentelemetry_backend_spec.rb | 41 +++++++++++++------ spec/lib/appsignal/transaction_spec.rb | 23 +++++++---- spec/lib/appsignal_spec.rb | 16 ++++---- 22 files changed, 160 insertions(+), 104 deletions(-) diff --git a/lib/appsignal/transaction/opentelemetry_backend.rb b/lib/appsignal/transaction/opentelemetry_backend.rb index d3944cbb4..5aff2269e 100644 --- a/lib/appsignal/transaction/opentelemetry_backend.rb +++ b/lib/appsignal/transaction/opentelemetry_backend.rb @@ -88,18 +88,17 @@ def finish_event(name, title, body, body_format, _gc_duration) return if @event_stack.empty? span, token = @event_stack.pop - span.name = name + write_event_name_attributes(span, name, title) write_event_body_attributes(span, body, body_format) - span.set_attribute("appsignal.title", title) if title && !title.empty? ::OpenTelemetry::Context.detach(token) span.finish end def record_event(name, title, body, body_format, duration, _gc_duration) # rubocop:disable Metrics/ParameterLists start_time = Time.now - (duration / 1_000_000_000.0) - span = tracer.start_span(name, :start_timestamp => start_time) + span = tracer.start_span(EVENT_SPAN_PLACEHOLDER_NAME, :start_timestamp => start_time) + write_event_name_attributes(span, name, title) write_event_body_attributes(span, body, body_format) - span.set_attribute("appsignal.title", title) if title && !title.empty? span.finish end @@ -369,6 +368,18 @@ def write_tags(tags) 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? diff --git a/spec/integration/collector_mode_traces_spec.rb b/spec/integration/collector_mode_traces_spec.rb index 5ac393106..38937c96b 100644 --- a/spec/integration/collector_mode_traces_spec.rb +++ b/spec/integration/collector_mode_traces_spec.rb @@ -21,20 +21,25 @@ expect(root).not_to be_nil expect(root.kind).to eq(:SPAN_KIND_SERVER) - # Event spans for each instrumented block are present. - expect(by_name.keys).to include("active_record.sql", "template.render", "partial.render") + # 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(by_name["active_record.sql"].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. - sql = by_name["active_record.sql"] expect(attribute_value(sql, "db.query.text")).to eq("SELECT * FROM users") expect(attribute_value(sql, "db.system.name")).to eq("other_sql") 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 84e648ac1..e3445c095 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 @@ -35,7 +35,7 @@ def perform expect(span.parent_span_id).to eq(root_span.span_id) expect(span.attributes["db.query.text"]).to eq("SQL") expect(span.attributes["db.system.name"]).to eq("other_sql") - expect(span.attributes).not_to have_key("appsignal.title") + expect(span.attributes["appsignal.category"]).to eq("sql.active_record") expect(span.attributes).not_to have_key("appsignal.body") end end @@ -75,7 +75,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).not_to have_key("appsignal.title") + 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 @@ -114,6 +114,8 @@ 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") end end diff --git a/spec/lib/appsignal/hooks/dry_monitor_spec.rb b/spec/lib/appsignal/hooks/dry_monitor_spec.rb index 160981bb9..26ded0915 100644 --- a/spec/lib/appsignal/hooks/dry_monitor_spec.rb +++ b/spec/lib/appsignal/hooks/dry_monitor_spec.rb @@ -75,7 +75,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.title"]).to eq("query.postgres") + expect(attrs["appsignal.category"]).to eq("query.postgres") expect(attrs).not_to have_key("appsignal.body") end end @@ -114,7 +114,7 @@ def perform expect(span.name).to eq("foo") expect(span.parent_span_id).to eq(root_span.span_id) attrs = span.attributes - expect(attrs).not_to have_key("appsignal.title") + expect(attrs["appsignal.category"]).to eq("foo") expect(attrs).not_to have_key("appsignal.body") expect(attrs).not_to have_key("db.query.text") expect(attrs).not_to have_key("db.system.name") diff --git a/spec/lib/appsignal/hooks/excon_spec.rb b/spec/lib/appsignal/hooks/excon_spec.rb index e4ee1c93f..6ce282011 100644 --- a/spec/lib/appsignal/hooks/excon_spec.rb +++ b/spec/lib/appsignal/hooks/excon_spec.rb @@ -54,9 +54,9 @@ def perform expect(event_spans.size).to eq(1) span = event_spans.first - expect(span.name).to eq("request.excon") + expect(span.name).to eq("GET http://www.google.com") expect(span.parent_span_id).to eq(root_span.span_id) - expect(span.attributes["appsignal.title"]).to eq("GET http://www.google.com") + expect(span.attributes["appsignal.category"]).to eq("request.excon") expect(span.attributes).not_to have_key("appsignal.body") end end @@ -88,9 +88,9 @@ def perform expect(event_spans.size).to eq(1) span = event_spans.first - expect(span.name).to eq("response.excon") + expect(span.name).to eq("www.google.com") expect(span.parent_span_id).to eq(root_span.span_id) - expect(span.attributes["appsignal.title"]).to eq("www.google.com") + expect(span.attributes["appsignal.category"]).to eq("response.excon") expect(span.attributes).not_to have_key("appsignal.body") end end diff --git a/spec/lib/appsignal/hooks/redis_client_spec.rb b/spec/lib/appsignal/hooks/redis_client_spec.rb index c9e6cbbc2..79eb6a886 100644 --- a/spec/lib/appsignal/hooks/redis_client_spec.rb +++ b/spec/lib/appsignal/hooks/redis_client_spec.rb @@ -106,10 +106,10 @@ def perform expect(event_spans.size).to eq(1) span = event_spans.first - expect(span.name).to eq("query.redis") + 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.title"]).to eq("stub_id") + expect(span.attributes["appsignal.category"]).to eq("query.redis") expect(span.attributes).not_to have_key("db.query.text") end end @@ -146,10 +146,10 @@ def perform expect(event_spans.size).to eq(1) span = event_spans.first - expect(span.name).to eq("query.redis") + 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.title"]).to eq("stub_id") + expect(span.attributes["appsignal.category"]).to eq("query.redis") expect(span.attributes).not_to have_key("db.query.text") end end @@ -237,10 +237,10 @@ def perform expect(event_spans.size).to eq(1) span = event_spans.first - expect(span.name).to eq("query.redis") + 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.title"]).to eq("stub_id") + expect(span.attributes["appsignal.category"]).to eq("query.redis") expect(span.attributes).not_to have_key("db.query.text") end end @@ -277,10 +277,10 @@ def perform expect(event_spans.size).to eq(1) span = event_spans.first - expect(span.name).to eq("query.redis") + 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.title"]).to eq("stub_id") + expect(span.attributes["appsignal.category"]).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 557ad76e3..c89efb201 100644 --- a/spec/lib/appsignal/hooks/redis_spec.rb +++ b/spec/lib/appsignal/hooks/redis_spec.rb @@ -100,10 +100,10 @@ def perform expect(event_spans.size).to eq(1) span = event_spans.first - expect(span.name).to eq("query.redis") + 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.title"]).to eq("stub_id") + expect(span.attributes["appsignal.category"]).to eq("query.redis") expect(span.attributes).not_to have_key("db.query.text") end end @@ -139,10 +139,10 @@ def perform expect(event_spans.size).to eq(1) span = event_spans.first - expect(span.name).to eq("query.redis") + 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.title"]).to eq("stub_id") + expect(span.attributes["appsignal.category"]).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 48d0368ac..8628b14fb 100644 --- a/spec/lib/appsignal/hooks/sequel_spec.rb +++ b/spec/lib/appsignal/hooks/sequel_spec.rb @@ -48,7 +48,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).not_to have_key("appsignal.title") + expect(span.attributes["appsignal.category"]).to eq("sql.sequel") end end else diff --git a/spec/lib/appsignal/integrations/data_mapper_spec.rb b/spec/lib/appsignal/integrations/data_mapper_spec.rb index 7a5e49285..f9eb7e94d 100644 --- a/spec/lib/appsignal/integrations/data_mapper_spec.rb +++ b/spec/lib/appsignal/integrations/data_mapper_spec.rb @@ -55,12 +55,12 @@ def perform expect(event_spans.size).to eq(1) span = event_spans.first - expect(span.name).to eq("query.data_mapper") + expect(span.name).to eq("DataMapper Query") 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.title"]).to eq("DataMapper Query") + 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) @@ -102,10 +102,10 @@ def perform expect(event_spans.size).to eq(1) span = event_spans.first - expect(span.name).to eq("query.data_mapper") + expect(span.name).to eq("DataMapper Query") expect(span.parent_span_id).to eq(root_span.span_id) attrs = span.attributes - expect(attrs["appsignal.title"]).to eq("DataMapper Query") + 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") diff --git a/spec/lib/appsignal/integrations/delayed_job_plugin_spec.rb b/spec/lib/appsignal/integrations/delayed_job_plugin_spec.rb index 28b644214..2987ca118 100644 --- a/spec/lib/appsignal/integrations/delayed_job_plugin_spec.rb +++ b/spec/lib/appsignal/integrations/delayed_job_plugin_spec.rb @@ -77,7 +77,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).not_to have_key("appsignal.title") + expect(span.attributes["appsignal.category"]).to eq("perform_job.delayed_job") expect(root_span.attributes["appsignal.tag.priority"]).to eq(1) expect(root_span.attributes["appsignal.tag.attempts"]).to eq(1) expect(root_span.attributes["appsignal.tag.queue"]).to eq("default") @@ -416,7 +416,7 @@ def self.appsignal_name 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).not_to have_key("appsignal.title") + expect(span.attributes["appsignal.category"]).to eq("perform_job.delayed_job") expect(root_span.attributes["appsignal.tag.priority"]).to eq(1) expect(root_span.attributes["appsignal.tag.attempts"]).to eq(1) expect(root_span.attributes["appsignal.tag.queue"]).to eq("default") diff --git a/spec/lib/appsignal/integrations/http_spec.rb b/spec/lib/appsignal/integrations/http_spec.rb index 3e9161e70..19cb31f10 100644 --- a/spec/lib/appsignal/integrations/http_spec.rb +++ b/spec/lib/appsignal/integrations/http_spec.rb @@ -37,9 +37,9 @@ def perform .to eq(Appsignal::Transaction::HTTP_REQUEST) expect(event_spans.size).to eq(1) span = event_spans.first - expect(span.name).to eq("request.http_rb") + expect(span.name).to eq("GET http://www.google.com") expect(span.parent_span_id).to eq(root_span.span_id) - expect(span.attributes["appsignal.title"]).to eq("GET http://www.google.com") + expect(span.attributes["appsignal.category"]).to eq("request.http_rb") expect(span.attributes).not_to have_key("appsignal.body") end end @@ -75,9 +75,9 @@ def perform .to eq(Appsignal::Transaction::HTTP_REQUEST) expect(event_spans.size).to eq(1) span = event_spans.first - expect(span.name).to eq("request.http_rb") + expect(span.name).to eq("GET https://www.google.com") expect(span.parent_span_id).to eq(root_span.span_id) - expect(span.attributes["appsignal.title"]).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 @@ -109,7 +109,8 @@ def perform expect(event_spans.size).to eq(1) span = event_spans.first - expect(span.attributes["appsignal.title"]).to eq("GET https://www.google.com") + 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 @@ -141,7 +142,8 @@ def perform expect(event_spans.size).to eq(1) span = event_spans.first - expect(span.attributes["appsignal.title"]).to eq("POST https://www.google.com") + 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 @@ -200,8 +202,8 @@ def to_s expect(event_spans.size).to eq(1) span = event_spans.first - expect(span.name).to eq("request.http_rb") - expect(span.attributes["appsignal.title"]).to eq("GET http://www.google.com") + expect(span.name).to eq("GET http://www.google.com") + expect(span.attributes["appsignal.category"]).to eq("request.http_rb") end end @@ -231,8 +233,8 @@ def perform expect(event_spans.size).to eq(1) span = event_spans.first - expect(span.name).to eq("request.http_rb") - expect(span.attributes["appsignal.title"]).to eq("GET http://www.google.com") + expect(span.name).to eq("GET http://www.google.com") + expect(span.attributes["appsignal.category"]).to eq("request.http_rb") end end @@ -262,8 +264,8 @@ def perform expect(event_spans.size).to eq(1) span = event_spans.first - expect(span.name).to eq("request.http_rb") - expect(span.attributes["appsignal.title"]).to eq("GET http://www.google.com") + expect(span.name).to eq("GET http://www.google.com") + expect(span.attributes["appsignal.category"]).to eq("request.http_rb") end end @@ -293,8 +295,8 @@ def perform expect(event_spans.size).to eq(1) span = event_spans.first - expect(span.name).to eq("request.http_rb") - expect(span.attributes["appsignal.title"]).to eq("GET http://www.google.com") + expect(span.name).to eq("GET http://www.google.com") + expect(span.attributes["appsignal.category"]).to eq("request.http_rb") end end @@ -324,8 +326,8 @@ def perform expect(event_spans.size).to eq(1) span = event_spans.first - expect(span.name).to eq("request.http_rb") - expect(span.attributes["appsignal.title"]).to eq("GET http://www.example.com") + expect(span.name).to eq("GET http://www.example.com") + expect(span.attributes["appsignal.category"]).to eq("request.http_rb") 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 e2367c3c9..e66ed00e7 100644 --- a/spec/lib/appsignal/integrations/mongo_ruby_driver_spec.rb +++ b/spec/lib/appsignal/integrations/mongo_ruby_driver_spec.rb @@ -85,10 +85,11 @@ def perform perform Appsignal::Transaction.complete_current! - span = event_spans.find { |s| s.name == "query.mongodb" } + 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.parent_span_id).to eq(root_span.span_id) - expect(span.attributes["appsignal.title"]).to eq("find | test | SUCCEEDED") + expect(span.attributes["appsignal.category"]).to eq("query.mongodb") expect(span.attributes["appsignal.body"]).to eq("{\"foo\":\"?\"}") snapshot = metric_snapshot("mongodb_query_duration") @@ -128,9 +129,10 @@ def perform perform Appsignal::Transaction.complete_current! - span = event_spans.find { |s| s.name == "query.mongodb" } + span = event_spans.find { |s| s.attributes["appsignal.category"] == "query.mongodb" } expect(span).not_to be_nil - expect(span.attributes["appsignal.title"]).to eq("find | test | FAILED") + expect(span.name).to eq("find | test | FAILED") + expect(span.attributes["appsignal.category"]).to eq("query.mongodb") 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 18530efa3..ecd28bba7 100644 --- a/spec/lib/appsignal/integrations/net_http_spec.rb +++ b/spec/lib/appsignal/integrations/net_http_spec.rb @@ -30,9 +30,9 @@ def perform expect(event_spans.size).to eq(1) span = event_spans.first - expect(span.name).to eq("request.net_http") + expect(span.name).to eq("GET http://www.google.com") expect(span.parent_span_id).to eq(root_span.span_id) - expect(span.attributes["appsignal.title"]).to eq("GET http://www.google.com") + expect(span.attributes["appsignal.category"]).to eq("request.net_http") expect(span.attributes).not_to have_key("appsignal.body") end end @@ -69,9 +69,9 @@ def perform expect(event_spans.size).to eq(1) span = event_spans.first - expect(span.name).to eq("request.net_http") + expect(span.name).to eq("GET https://www.google.com") expect(span.parent_span_id).to eq(root_span.span_id) - expect(span.attributes["appsignal.title"]).to eq("GET https://www.google.com") + expect(span.attributes["appsignal.category"]).to eq("request.net_http") expect(span.attributes).not_to have_key("appsignal.body") end end diff --git a/spec/lib/appsignal/integrations/que_spec.rb b/spec/lib/appsignal/integrations/que_spec.rb index 958413b24..4fab40e43 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).not_to have_key("appsignal.title") + 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"])) diff --git a/spec/lib/appsignal/integrations/shoryuken_spec.rb b/spec/lib/appsignal/integrations/shoryuken_spec.rb index 6dd4db072..028e69682 100644 --- a/spec/lib/appsignal/integrations/shoryuken_spec.rb +++ b/spec/lib/appsignal/integrations/shoryuken_spec.rb @@ -87,7 +87,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).not_to have_key("appsignal.title") + 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") @@ -291,7 +291,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).not_to have_key("appsignal.title") + expect(span.attributes["appsignal.category"]).to eq("perform_job.shoryuken") expect(JSON.parse(root_span.attributes["appsignal.function.parameters"])) .to eq( "msg2" => "foo bar", diff --git a/spec/lib/appsignal/integrations/sidekiq_spec.rb b/spec/lib/appsignal/integrations/sidekiq_spec.rb index a0d38de21..f0378bf22 100644 --- a/spec/lib/appsignal/integrations/sidekiq_spec.rb +++ b/spec/lib/appsignal/integrations/sidekiq_spec.rb @@ -731,7 +731,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).not_to have_key("appsignal.title") + expect(span.attributes["appsignal.category"]).to eq("perform_job.sidekiq") end end end @@ -840,7 +840,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).not_to have_key("appsignal.title") + expect(span.attributes["appsignal.category"]).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 5781ac15e..399a60689 100644 --- a/spec/lib/appsignal/rack/abstract_middleware_spec.rb +++ b/spec/lib/appsignal/rack/abstract_middleware_spec.rb @@ -598,7 +598,9 @@ def setup_parent_transaction body.to_ary response_events = - event_spans.count { |span| span.name == "process_response_body.rack" } + event_spans.count do |span| + span.attributes["appsignal.category"] == "process_response_body.rack" + end expect(response_events).to eq(1) end end @@ -632,7 +634,9 @@ def perform perform response_events = - event_spans.count { |span| span.name == "process_response_body.rack" } + event_spans.count do |span| + span.attributes["appsignal.category"] == "process_response_body.rack" + end expect(response_events).to eq(1) end end diff --git a/spec/lib/appsignal/rack/body_wrapper_spec.rb b/spec/lib/appsignal/rack/body_wrapper_spec.rb index 5dd19ef24..637c1d9dc 100644 --- a/spec/lib/appsignal/rack/body_wrapper_spec.rb +++ b/spec/lib/appsignal/rack/body_wrapper_spec.rb @@ -28,15 +28,17 @@ def expect_collector_no_error def expect_collector_event(name, title = nil) transaction.complete - span = event_spans.find { |s| s.name == name } + # 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.attributes["appsignal.title"]).to eq(title) if title + expect(span.name).to eq(title) if title end def expect_collector_no_event(name) transaction.complete - expect(event_spans.map(&:name)).to_not include(name) + 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 @@ -136,9 +138,11 @@ 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.name == "process_response_body.rack" && - span.attributes["appsignal.title"].to_s.empty? + span.attributes["appsignal.category"] == "process_response_body.rack" && + span.name == span.attributes["appsignal.category"] 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 17c98bb9c..c94f39f67 100644 --- a/spec/lib/appsignal/rack/event_handler_spec.rb +++ b/spec/lib/appsignal/rack/event_handler_spec.rb @@ -168,10 +168,12 @@ def perform # `set_queue_start` is an intentional no-op in collector mode. expect(last_transaction.backend.queue_start).to be_nil - event = event_spans.find { |span| span.name == "process_request.rack" } + 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.attributes["appsignal.title"]).to eq("callback: after_reply") + expect(event.name).to eq("callback: after_reply") end end @@ -903,10 +905,12 @@ def perform use_test_logger perform - event = event_spans.find { |span| span.name == "process_request.rack" } + 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.attributes["appsignal.title"]).to eq("callback: on_finish") + expect(event.name).to eq("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 8d78d46ed..62a32c05e 100644 --- a/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb +++ b/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb @@ -260,7 +260,8 @@ def exception_event(backend) backend.finish_event("sql.query", "title", "body", Appsignal::EventFormatter::DEFAULT, 0) backend.complete - event_span = span_exporter.finished_spans.find { |s| s.name == "sql.query" } + 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 } @@ -484,7 +485,7 @@ def new_transaction_with_otel_backend(namespace = Appsignal::Transaction::HTTP_R end describe "#finish_event" do - it "pops the stack, renames the span, finishes it, and detaches the context" 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) @@ -495,10 +496,15 @@ def new_transaction_with_otel_backend(namespace = Appsignal::Transaction::HTTP_R expect(backend.instance_variable_get(:@event_stack)).to be_empty expect(::OpenTelemetry::Trace.current_span).to eq(root_span) - event_span = span_exporter.finished_spans.find { |s| s.name == "custom.event" } + 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["appsignal.title"]).to eq("Title") + expect(event_span.attributes).not_to have_key("appsignal.title") end it "does nothing if the event stack is empty (unpaired finish_event)" do @@ -517,8 +523,10 @@ def new_transaction_with_otel_backend(namespace = Appsignal::Transaction::HTTP_R backend.record_event("custom.event", "T", "B", Appsignal::EventFormatter::DEFAULT, duration_ns, 0) - span = span_exporter.finished_spans.find { |s| s.name == "custom.event" } + 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. @@ -565,7 +573,8 @@ def new_transaction_with_otel_backend(namespace = Appsignal::Transaction::HTTP_R backend.finish_event("sql.query", "Q", "SELECT 1", Appsignal::EventFormatter::SQL_BODY_FORMAT, 0) - attrs = span_exporter.finished_spans.find { |s| s.name == "sql.query" }.attributes + 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") @@ -577,7 +586,8 @@ def new_transaction_with_otel_backend(namespace = Appsignal::Transaction::HTTP_R backend.finish_event("custom", "T", "Body", Appsignal::EventFormatter::DEFAULT, 0) - attrs = span_exporter.finished_spans.find { |s| s.name == "custom" }.attributes + 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") @@ -592,15 +602,17 @@ def new_transaction_with_otel_backend(namespace = Appsignal::Transaction::HTTP_R backend.finish_event("empty.body", "T", "", Appsignal::EventFormatter::DEFAULT, 0) - no_body = span_exporter.finished_spans.find { |s| s.name == "no.body" } - empty_body = span_exporter.finished_spans.find { |s| s.name == "empty.body" } + 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 "omits appsignal.title when title is empty or nil" do + it "falls back to the event name as the span name when title is empty or nil" do backend = create_backend backend.start_event(0) backend.finish_event("no.title", nil, "Body", @@ -609,8 +621,13 @@ def new_transaction_with_otel_backend(namespace = Appsignal::Transaction::HTTP_R backend.finish_event("empty.title", "", "Body", Appsignal::EventFormatter::DEFAULT, 0) - no_title = span_exporter.finished_spans.find { |s| s.name == "no.title" } - empty_title = span_exporter.finished_spans.find { |s| s.name == "empty.title" } + 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 diff --git a/spec/lib/appsignal/transaction_spec.rb b/spec/lib/appsignal/transaction_spec.rb index 565375f17..82461794c 100644 --- a/spec/lib/appsignal/transaction_spec.rb +++ b/spec/lib/appsignal/transaction_spec.rb @@ -3262,7 +3262,7 @@ def perform transaction.finish_event("query", "title", "body", Appsignal::EventFormatter::DEFAULT) transaction.complete - event_span = event_spans.find { |span| span.name == "query" } + 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 @@ -4365,7 +4365,8 @@ def perform(transaction) Appsignal::Transaction.complete_current! span = event_spans.first - expect(span.name).to eq("custom.event") + 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) @@ -4428,12 +4429,12 @@ def perform(transaction) Appsignal::Transaction.complete_current! span = event_spans.first - expect(span.name).to eq("sql.active_record") + 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.title" => "Query" + "appsignal.category" => "sql.active_record" ) expect(span.attributes).not_to have_key("appsignal.body") end @@ -4466,9 +4467,10 @@ def 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.title" => "Title" + "appsignal.category" => "custom.event" ) expect(span.attributes).not_to have_key("db.query.text") expect(span.attributes).not_to have_key("db.system.name") @@ -4504,8 +4506,8 @@ def perform(transaction) perform(transaction) Appsignal::Transaction.complete_current! - outer = event_spans.find { |s| s.name == "outer.event" } - inner = event_spans.find { |s| s.name == "inner.event" } + 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) @@ -4513,14 +4515,17 @@ def perform(transaction) end describe "with an empty title" do - it "omits the appsignal.title attribute on the span", :collector_mode 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! - expect(event_spans.first.attributes).not_to have_key("appsignal.title") + 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 diff --git a/spec/lib/appsignal_spec.rb b/spec/lib/appsignal_spec.rb index 8d7c2cf00..be5c79d88 100644 --- a/spec/lib/appsignal_spec.rb +++ b/spec/lib/appsignal_spec.rb @@ -2684,9 +2684,9 @@ def perform expect(event_spans.size).to eq(1) span = event_spans.first - expect(span.name).to eq("name") + expect(span.name).to eq("title") expect(span.parent_span_id).to eq(root_span.span_id) - expect(span.attributes["appsignal.title"]).to eq("title") + 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") @@ -2717,8 +2717,8 @@ def perform expect(event_spans.size).to eq(1) span = event_spans.first - expect(span.name).to eq("name") - expect(span.attributes["appsignal.title"]).to eq("title") + expect(span.name).to eq("title") + expect(span.attributes["appsignal.category"]).to eq("name") expect(span.attributes["appsignal.body"]).to eq("body") end end @@ -2747,8 +2747,8 @@ def perform expect(event_spans.size).to eq(1) span = event_spans.first - expect(span.name).to eq("name") - expect(span.attributes["appsignal.title"]).to eq("title") + expect(span.name).to eq("title") + expect(span.attributes["appsignal.category"]).to eq("name") expect(span.attributes["appsignal.body"]).to eq("body") end end @@ -2782,9 +2782,9 @@ def perform expect(event_spans.size).to eq(1) span = event_spans.first - expect(span.name).to eq("name") + expect(span.name).to eq("title") expect(span.parent_span_id).to eq(root_span.span_id) - expect(span.attributes["appsignal.title"]).to eq("title") + 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") From 42669647498f89a6adf2d12eaf9336d5d9ddd7cc Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Mon, 15 Jun 2026 14:33:35 +0200 Subject: [PATCH 082/151] Emit breadcrumbs on the current span In collector mode breadcrumbs were added as span events on the root span, flushed all at once when the transaction completed. They now attach to the span that is current when the breadcrumb is recorded, so a breadcrumb added inside an instrumented event lands on that event's span. This has to happen at add time: by completion the event span has finished, and the OpenTelemetry SDK drops events added to an ended span. Streamed events can't be retracted afterwards, so collector mode keeps the first BREADCRUMB_LIMIT breadcrumbs rather than the last, as agent mode does. --- lib/appsignal/transaction.rb | 11 ++- .../transaction/extension_backend.rb | 7 ++ .../transaction/opentelemetry_backend.rb | 67 ++++++++++++------- spec/lib/appsignal/transaction_spec.rb | 38 +++++++++-- 4 files changed, 90 insertions(+), 33 deletions(-) diff --git a/lib/appsignal/transaction.rb b/lib/appsignal/transaction.rb index e0f08f1ec..5c6ffb01a 100644 --- a/lib/appsignal/transaction.rb +++ b/lib/appsignal/transaction.rb @@ -510,14 +510,21 @@ def add_breadcrumb(category, action, message = "", metadata = {}, time = Time.no return end - @breadcrumbs.push( + breadcrumb = { :time => time.to_i, :category => category, :action => action, :message => message, :metadata => metadata - ) + } + @breadcrumbs.push(breadcrumb) @breadcrumbs = @breadcrumbs.last(BREADCRUMB_LIMIT) + + # Hand the breadcrumb to the backend immediately. The agent backend + # ignores this (it flushes the whole `@breadcrumbs` array at completion via + # `sample_data`); the OpenTelemetry backend emits it right away as a span + # event on the current span, which has already finished by completion time. + @backend.add_breadcrumb(breadcrumb) end # Set an action name for the transaction. diff --git a/lib/appsignal/transaction/extension_backend.rb b/lib/appsignal/transaction/extension_backend.rb index 17e8f88bc..aed038bc3 100644 --- a/lib/appsignal/transaction/extension_backend.rb +++ b/lib/appsignal/transaction/extension_backend.rb @@ -52,6 +52,13 @@ def set_sample_data(key, data) @handle.set_sample_data(key, Appsignal::Utils::Data.generate(data)) end + # No-op: agent mode collects breadcrumbs on the Transaction and flushes the + # whole (last-`BREADCRUMB_LIMIT`) array at completion through + # `set_sample_data("breadcrumbs", …)`. Only the OpenTelemetry backend acts + # on the per-breadcrumb call. + def add_breadcrumb(_breadcrumb) + end + # `backtrace` is a raw Array (or nil); the C extension wants a `Data` # object, so serialize it here. `causes` is unused in agent mode -- the # Transaction reports causes separately via the `error_causes` sample data. diff --git a/lib/appsignal/transaction/opentelemetry_backend.rb b/lib/appsignal/transaction/opentelemetry_backend.rb index 5aff2269e..d3a65ed50 100644 --- a/lib/appsignal/transaction/opentelemetry_backend.rb +++ b/lib/appsignal/transaction/opentelemetry_backend.rb @@ -56,6 +56,7 @@ def initialize(transaction_id, namespace, gc_duration, **) @gc_duration = gc_duration @completed = false @event_stack = [] + @breadcrumb_count = 0 # `start_root_span` (not `start_span`) so the transaction's root # ignores any ambient OTel context. A transaction is a unit of work @@ -140,11 +141,13 @@ def set_metadata(key, value) # Routes each sample-data category to the attribute the collector and the # server's trace UI recognize. The JSON-blob categories (params, session # data, custom data) are serialized as JSON strings; the collector - # re-parses and filters them. `environment` and `breadcrumbs` are handled - # in later branches. `error_causes` is intentionally dropped because causes - # ride on the exception event instead (see #set_error) -- otherwise the - # `else` pass-through would duplicate them. Any other (unknown) category is - # passed through as a JSON `appsignal.` attribute so no data is lost. + # re-parses and filters them. `environment` is handled in a later branch. + # `error_causes` and `breadcrumbs` are intentionally dropped here: causes + # ride on the exception event (see #set_error) and breadcrumbs are emitted + # per-call as span events on the current span (see #add_breadcrumb), so + # flushing either as sample data would duplicate them on the root span. Any + # other (unknown) category is passed through as a JSON `appsignal.` + # attribute so no data is lost. def set_sample_data(key, data) case key when "params" @@ -156,7 +159,9 @@ def set_sample_data(key, data) when "environment" write_request_headers(data) when "breadcrumbs" - write_breadcrumbs(data) + # No-op: breadcrumbs are emitted as `appsignal.breadcrumb` span events + # on the current span at add time (see #add_breadcrumb). Flushing them + # here would re-emit them on the already-finished root span. when "tags" write_tags(data) when "error_causes" @@ -210,6 +215,36 @@ def set_error(class_name, message, backtrace, causes) span.status = ::OpenTelemetry::Trace::Status.error end + # Emits a breadcrumb as an `appsignal.breadcrumb` span event on the span + # that is current *now* -- the event span active when the breadcrumb was + # added, falling back to the root span when none is open. Breadcrumbs are + # emitted immediately (not flushed at completion) because by completion the + # event span has finished, and the OTel SDK drops events added to an ended + # span. + # + # The breadcrumb's recorded time becomes the event timestamp (events carry + # their own time), so it is not duplicated as an attribute; + # category/action/message are attributes and the metadata Hash is a JSON + # string (event attributes are flat). + # + # Capped at `BREADCRUMB_LIMIT` per transaction: streamed events can't be + # retracted, so we keep the first N rather than agent mode's last N. + def add_breadcrumb(breadcrumb) + return if @breadcrumb_count >= Appsignal::Transaction::BREADCRUMB_LIMIT + + @breadcrumb_count += 1 + ::OpenTelemetry::Trace.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 @@ -315,26 +350,6 @@ def params_attribute end end - # Breadcrumbs have no sample-data attribute in the OTel pipeline, so emit - # each as an `appsignal.breadcrumb` span event. The breadcrumb's recorded - # time becomes the event timestamp (events carry their own time), so it is - # not duplicated as an attribute; category/action/message are attributes - # and the metadata Hash is a JSON string (event attributes are flat). - def write_breadcrumbs(breadcrumbs) - breadcrumbs.each do |breadcrumb| - @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 - 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/spec/lib/appsignal/transaction_spec.rb b/spec/lib/appsignal/transaction_spec.rb index 82461794c..c130464d8 100644 --- a/spec/lib/appsignal/transaction_spec.rb +++ b/spec/lib/appsignal/transaction_spec.rb @@ -2274,9 +2274,13 @@ def perform describe "#add_breadcrumb" do let(:transaction) { new_transaction } - # The OpenTelemetry `appsignal.breadcrumb` events recorded on the root span. + # 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 - root_span.events.to_a.select { |event| event.name == "appsignal.breadcrumb" } + 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 @@ -2314,7 +2318,10 @@ def perform ) end - it "emits the last breadcrumb events in collector mode", :collector_mode do + # 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 @@ -2326,8 +2333,29 @@ def perform "action" => "GET http://localhost", "message" => "User made external network request" ) - expect(JSON.parse(events.first.attributes["metadata"])).to eq("code" => 3) - expect(JSON.parse(events.last.attributes["metadata"])).to eq("code" => 22) + 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 From 327e398ef69f76543405bfb0abc30e4975110c0c Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Tue, 16 Jun 2026 15:22:42 +0200 Subject: [PATCH 083/151] Drop gc_duration from the transaction backend seam The Ruby gem has fed a hardcoded 0 for GC duration since transaction and event level GC tracking was removed, so threading it through every Transaction-to-backend call only added noise. Drop it from that interface; the ExtensionBackend keeps passing 0 to the C extension, whose native calls still take the argument. --- lib/appsignal/transaction.rb | 13 ++-- .../transaction/extension_backend.rb | 22 +++---- .../transaction/opentelemetry_backend.rb | 13 ++-- .../transaction/extension_backend_spec.rb | 13 ++-- .../transaction/opentelemetry_backend_spec.rb | 64 +++++++++---------- spec/lib/appsignal/transaction_spec.rb | 16 ++--- 6 files changed, 65 insertions(+), 76 deletions(-) diff --git a/lib/appsignal/transaction.rb b/lib/appsignal/transaction.rb index 5c6ffb01a..37b218cfc 100644 --- a/lib/appsignal/transaction.rb +++ b/lib/appsignal/transaction.rb @@ -181,8 +181,7 @@ def initialize(namespace, id: SecureRandom.uuid, backend: nil) @backend = backend || Appsignal::Backends.transaction.new( @transaction_id, - @namespace, - 0 + @namespace ) run_after_create_hooks @@ -233,7 +232,7 @@ def complete unless duplicate? self.class.last_errors = @error_blocks.keys - should_sample = @backend.finish(0) + should_sample = @backend.finish end report_errors @@ -649,7 +648,7 @@ def add_error(error, &block) def start_event return if paused? - @backend.start_event(0) + @backend.start_event end # @!visibility private @@ -661,8 +660,7 @@ def finish_event(name, title, body, body_format = Appsignal::EventFormatter::DEF name, title || BLANK, body || BLANK, - body_format || Appsignal::EventFormatter::DEFAULT, - 0 + body_format || Appsignal::EventFormatter::DEFAULT ) end @@ -676,8 +674,7 @@ def record_event(name, title, body, duration, body_format = Appsignal::EventForm title || BLANK, body || BLANK, body_format || Appsignal::EventFormatter::DEFAULT, - duration, - 0 + duration ) end diff --git a/lib/appsignal/transaction/extension_backend.rb b/lib/appsignal/transaction/extension_backend.rb index aed038bc3..321d2f089 100644 --- a/lib/appsignal/transaction/extension_backend.rb +++ b/lib/appsignal/transaction/extension_backend.rb @@ -12,22 +12,22 @@ class Transaction # `Appsignal::Transaction#initialize` instantiates one and stores it in # `@backend`. class ExtensionBackend - def initialize(transaction_id, namespace, gc_duration, handle: nil) + def initialize(transaction_id, namespace, handle: nil) @handle = handle || - Appsignal::Extension.start_transaction(transaction_id, namespace, gc_duration) || + Appsignal::Extension.start_transaction(transaction_id, namespace, 0) || Appsignal::Extension::MockTransaction.new end - def start_event(gc_duration) - @handle.start_event(gc_duration) + def start_event + @handle.start_event(0) end - def finish_event(name, title, body, body_format, gc_duration) - @handle.finish_event(name, title, body, body_format, gc_duration) + def finish_event(name, title, body, body_format) + @handle.finish_event(name, title, body, body_format, 0) end - def record_event(name, title, body, body_format, duration, gc_duration) # rubocop:disable Metrics/ParameterLists - @handle.record_event(name, title, body, body_format, duration, gc_duration) + def record_event(name, title, body, body_format, duration) + @handle.record_event(name, title, body, body_format, duration, 0) end def set_action(action) # rubocop:disable Naming/AccessorMethodName @@ -72,8 +72,8 @@ def set_error(class_name, message, backtrace, _causes) @handle.set_error(class_name, message, backtrace_data) end - def finish(gc_duration) - @handle.finish(gc_duration) + def finish + @handle.finish(0) end def complete @@ -95,7 +95,7 @@ def supports_multiple_errors? end def duplicate(new_transaction_id) - self.class.new(new_transaction_id, nil, 0, :handle => @handle.duplicate(new_transaction_id)) + self.class.new(new_transaction_id, nil, :handle => @handle.duplicate(new_transaction_id)) end def to_json # rubocop:disable Lint/ToJSON diff --git a/lib/appsignal/transaction/opentelemetry_backend.rb b/lib/appsignal/transaction/opentelemetry_backend.rb index d3a65ed50..6af93cdc6 100644 --- a/lib/appsignal/transaction/opentelemetry_backend.rb +++ b/lib/appsignal/transaction/opentelemetry_backend.rb @@ -50,10 +50,9 @@ class OpenTelemetryBackend # sanitization on `db.query.text`. SQL_DB_SYSTEM = "other_sql" - def initialize(transaction_id, namespace, gc_duration, **) + def initialize(transaction_id, namespace, **) @transaction_id = transaction_id @namespace = namespace - @gc_duration = gc_duration @completed = false @event_stack = [] @breadcrumb_count = 0 @@ -77,7 +76,7 @@ def initialize(transaction_id, namespace, gc_duration, **) @span.set_attribute("appsignal.namespace", namespace) if namespace end - def start_event(_gc_duration) + def start_event span = tracer.start_span(EVENT_SPAN_PLACEHOLDER_NAME) token = ::OpenTelemetry::Context.attach( ::OpenTelemetry::Trace.context_with_span(span) @@ -85,7 +84,7 @@ def start_event(_gc_duration) @event_stack.push([span, token]) end - def finish_event(name, title, body, body_format, _gc_duration) + def finish_event(name, title, body, body_format) return if @event_stack.empty? span, token = @event_stack.pop @@ -95,7 +94,7 @@ def finish_event(name, title, body, body_format, _gc_duration) span.finish end - def record_event(name, title, body, body_format, duration, _gc_duration) # rubocop:disable Metrics/ParameterLists + def record_event(name, title, body, body_format, duration) start_time = Time.now - (duration / 1_000_000_000.0) span = tracer.start_span(EVENT_SPAN_PLACEHOLDER_NAME, :start_timestamp => start_time) write_event_name_attributes(span, name, title) @@ -249,7 +248,7 @@ def add_breadcrumb(breadcrumb) # 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(_gc_duration) + def finish true end @@ -273,7 +272,7 @@ def discard end def duplicate(new_transaction_id) - self.class.new(new_transaction_id, @namespace, 0) + self.class.new(new_transaction_id, @namespace) end # Multiple errors are recorded as multiple `exception` events on the one diff --git a/spec/lib/appsignal/transaction/extension_backend_spec.rb b/spec/lib/appsignal/transaction/extension_backend_spec.rb index 4d1de23ad..6c66b6489 100644 --- a/spec/lib/appsignal/transaction/extension_backend_spec.rb +++ b/spec/lib/appsignal/transaction/extension_backend_spec.rb @@ -3,7 +3,7 @@ describe Appsignal::Transaction::ExtensionBackend do before { start_agent } - let(:backend) { described_class.new("abc-123", Appsignal::Transaction::HTTP_REQUEST, 0) } + 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 @@ -18,7 +18,6 @@ backend_with_handle = described_class.new( "ignored-id", "ignored-namespace", - 0, :handle => existing_handle ) @@ -30,7 +29,7 @@ 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, 0) + backend = described_class.new("abc-123", Appsignal::Transaction::HTTP_REQUEST) expect(backend.instance_variable_get(:@handle)) .to be_kind_of(Appsignal::Extension::MockTransaction) end @@ -53,17 +52,17 @@ it "forwards #start_event to the handle" do expect(handle).to receive(:start_event).with(0) - backend.start_event(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, 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, 0) + backend.record_event("name", "title", "body", 1, 1000) end it "forwards #set_action to the handle" do @@ -110,7 +109,7 @@ it "forwards #finish to the handle and returns its value" do expect(handle).to receive(:finish).with(0).and_return(true) - expect(backend.finish(0)).to eq(true) + expect(backend.finish).to eq(true) end it "forwards #complete to the handle" do diff --git a/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb b/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb index 62a32c05e..ff7627a2b 100644 --- a/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb +++ b/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb @@ -28,7 +28,7 @@ end def create_backend(namespace = "http_request") - described_class.new("abc-123", namespace, 0).tap { |b| @backends_created << b } + described_class.new("abc-123", namespace).tap { |b| @backends_created << b } end describe "#initialize" do @@ -100,15 +100,15 @@ def create_backend(namespace = "http_request") describe "write methods (no-op for step 2 — implementations land in subsequent steps)" do it "accepts #start_event without raising" do - expect { create_backend.start_event(0) }.not_to raise_error + 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, 0) }.not_to raise_error + 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, 0) }.not_to raise_error + expect { create_backend.record_event("name", "title", "body", 1, 1000) }.not_to raise_error end it "accepts #set_queue_start without raising" do @@ -255,9 +255,9 @@ def exception_event(backend) it "records the exception on the span that is current when called" do backend = create_backend - backend.start_event(0) + backend.start_event backend.set_error("RuntimeError", "boom", ["line 1"], []) - backend.finish_event("sql.query", "title", "body", Appsignal::EventFormatter::DEFAULT, 0) + backend.finish_event("sql.query", "title", "body", Appsignal::EventFormatter::DEFAULT) backend.complete event_span = span_exporter.finished_spans @@ -292,7 +292,7 @@ def exception_event(backend) describe "#finish" do it "returns true so Transaction#complete runs the sample_data path" do - expect(create_backend.finish(0)).to eq(true) + expect(create_backend.finish).to eq(true) end end @@ -423,7 +423,7 @@ def exception_event(backend) before { start_agent } def new_transaction_with_otel_backend(namespace = Appsignal::Transaction::HTTP_REQUEST) - backend = described_class.new("abc-123", namespace, 0) + backend = described_class.new("abc-123", namespace) @backends_created << backend Appsignal::Transaction.new(namespace, :backend => backend) end @@ -472,7 +472,7 @@ def new_transaction_with_otel_backend(namespace = Appsignal::Transaction::HTTP_R backend = create_backend root_span = backend.instance_variable_get(:@span) - backend.start_event(0) + backend.start_event current = ::OpenTelemetry::Trace.current_span expect(current).not_to eq(root_span) @@ -489,9 +489,9 @@ def new_transaction_with_otel_backend(namespace = Appsignal::Transaction::HTTP_R backend = create_backend root_span = backend.instance_variable_get(:@span) - backend.start_event(0) + backend.start_event backend.finish_event("custom.event", "Title", "Body", - Appsignal::EventFormatter::DEFAULT, 0) + Appsignal::EventFormatter::DEFAULT) expect(backend.instance_variable_get(:@event_stack)).to be_empty expect(::OpenTelemetry::Trace.current_span).to eq(root_span) @@ -511,7 +511,7 @@ def new_transaction_with_otel_backend(namespace = Appsignal::Transaction::HTTP_R backend = create_backend expect do backend.finish_event("custom.event", "T", "B", - Appsignal::EventFormatter::DEFAULT, 0) + Appsignal::EventFormatter::DEFAULT) end.not_to raise_error end end @@ -521,7 +521,7 @@ def new_transaction_with_otel_backend(namespace = Appsignal::Transaction::HTTP_R backend = create_backend duration_ns = 1_000_000_000 # 1 second backend.record_event("custom.event", "T", "B", - Appsignal::EventFormatter::DEFAULT, duration_ns, 0) + Appsignal::EventFormatter::DEFAULT, duration_ns) span = span_exporter.finished_spans .find { |s| s.attributes["appsignal.category"] == "custom.event" } @@ -536,7 +536,7 @@ def new_transaction_with_otel_backend(namespace = Appsignal::Transaction::HTTP_R it "does NOT push onto the event stack" do backend = create_backend backend.record_event("custom.event", nil, nil, - Appsignal::EventFormatter::DEFAULT, 1_000, 0) + Appsignal::EventFormatter::DEFAULT, 1_000) expect(backend.instance_variable_get(:@event_stack)).to be_empty end end @@ -546,15 +546,15 @@ def new_transaction_with_otel_backend(namespace = Appsignal::Transaction::HTTP_R backend = create_backend root_span = backend.instance_variable_get(:@span) - backend.start_event(0) + backend.start_event outer_span = backend.instance_variable_get(:@event_stack).last.first - backend.start_event(0) + backend.start_event inner_span = backend.instance_variable_get(:@event_stack).last.first backend.finish_event("inner.event", nil, nil, - Appsignal::EventFormatter::DEFAULT, 0) + Appsignal::EventFormatter::DEFAULT) backend.finish_event("outer.event", nil, nil, - Appsignal::EventFormatter::DEFAULT, 0) + 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" } @@ -569,9 +569,9 @@ def new_transaction_with_otel_backend(namespace = Appsignal::Transaction::HTTP_R 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(0) + backend.start_event backend.finish_event("sql.query", "Q", "SELECT 1", - Appsignal::EventFormatter::SQL_BODY_FORMAT, 0) + Appsignal::EventFormatter::SQL_BODY_FORMAT) attrs = span_exporter.finished_spans .find { |s| s.attributes["appsignal.category"] == "sql.query" }.attributes @@ -582,9 +582,9 @@ def new_transaction_with_otel_backend(namespace = Appsignal::Transaction::HTTP_R it "writes appsignal.body for default bodies (no db.* attributes)" do backend = create_backend - backend.start_event(0) + backend.start_event backend.finish_event("custom", "T", "Body", - Appsignal::EventFormatter::DEFAULT, 0) + Appsignal::EventFormatter::DEFAULT) attrs = span_exporter.finished_spans .find { |s| s.attributes["appsignal.category"] == "custom" }.attributes @@ -595,12 +595,12 @@ def new_transaction_with_otel_backend(namespace = Appsignal::Transaction::HTTP_R it "omits the body attribute entirely when body is empty or nil" do backend = create_backend - backend.start_event(0) + backend.start_event backend.finish_event("no.body", "T", nil, - Appsignal::EventFormatter::DEFAULT, 0) - backend.start_event(0) + Appsignal::EventFormatter::DEFAULT) + backend.start_event backend.finish_event("empty.body", "T", "", - Appsignal::EventFormatter::DEFAULT, 0) + Appsignal::EventFormatter::DEFAULT) no_body = span_exporter.finished_spans .find { |s| s.attributes["appsignal.category"] == "no.body" } @@ -614,12 +614,12 @@ def new_transaction_with_otel_backend(namespace = Appsignal::Transaction::HTTP_R it "falls back to the event name as the span name when title is empty or nil" do backend = create_backend - backend.start_event(0) + backend.start_event backend.finish_event("no.title", nil, "Body", - Appsignal::EventFormatter::DEFAULT, 0) - backend.start_event(0) + Appsignal::EventFormatter::DEFAULT) + backend.start_event backend.finish_event("empty.title", "", "Body", - Appsignal::EventFormatter::DEFAULT, 0) + Appsignal::EventFormatter::DEFAULT) no_title = span_exporter.finished_spans .find { |s| s.attributes["appsignal.category"] == "no.title" } @@ -636,8 +636,8 @@ def new_transaction_with_otel_backend(namespace = Appsignal::Transaction::HTTP_R 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(0) - backend.start_event(0) + backend.start_event + backend.start_event expect { backend.complete }.not_to raise_error expect(backend.instance_variable_get(:@event_stack)).to be_empty diff --git a/spec/lib/appsignal/transaction_spec.rb b/spec/lib/appsignal/transaction_spec.rb index c130464d8..3e81075b0 100644 --- a/spec/lib/appsignal/transaction_spec.rb +++ b/spec/lib/appsignal/transaction_spec.rb @@ -4245,7 +4245,7 @@ def exception_event let(:transaction) { new_transaction } it "starts the event in the extension" do - expect(transaction.backend).to receive(:start_event).with(0).and_call_original + expect(transaction.backend).to receive(:start_event).with(no_args).and_call_original transaction.start_event end @@ -4262,15 +4262,13 @@ def exception_event describe "#finish_event" do let(:transaction) { new_transaction } - let(:fake_gc_time) { 0 } it "should finish the event in the extension" do expect(transaction.backend).to receive(:finish_event).with( "name", "title", "body", - 1, - fake_gc_time + 1 ).and_call_original transaction.finish_event( @@ -4286,8 +4284,7 @@ def exception_event "name", "", "", - 0, - fake_gc_time + 0 ).and_call_original transaction.finish_event( @@ -4310,7 +4307,6 @@ def exception_event describe "#record_event" do let(:transaction) { new_transaction } - let(:fake_gc_time) { 0 } it "should record the event in the extension" do expect(transaction.backend).to receive(:record_event).with( @@ -4318,8 +4314,7 @@ def exception_event "title", "body", 1, - 1000, - fake_gc_time + 1000 ).and_call_original transaction.record_event( @@ -4337,8 +4332,7 @@ def exception_event "", "", 0, - 1000, - fake_gc_time + 1000 ).and_call_original transaction.record_event( From 315e2f5948fd7b5c786236454acae5a69b3a07c0 Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Tue, 16 Jun 2026 15:25:25 +0200 Subject: [PATCH 084/151] Emit queue timing in collector mode `set_queue_start` was a no-op on the OpenTelemetry backend. It now records the queue start as an `appsignal.queue_start` span event (for the per-trace timeline) and, at completion, a distribution metric named `transaction_queue_duration` in the per-namespace and per-namespace-and-host series the queue-time graph reads. Values below a year-2000 epoch-ms floor are ignored, mirroring the agent. Span timing is left untouched. --- .../transaction/opentelemetry_backend.rb | 99 +++++++++++-------- spec/lib/appsignal/hooks/activejob_spec.rb | 5 +- .../appsignal/integrations/shoryuken_spec.rb | 16 ++- .../appsignal/integrations/sidekiq_spec.rb | 10 +- .../rack/abstract_middleware_spec.rb | 6 +- spec/lib/appsignal/rack/event_handler_spec.rb | 4 +- .../transaction/opentelemetry_backend_spec.rb | 49 ++++++++- spec/lib/appsignal/transaction_spec.rb | 30 +++--- 8 files changed, 144 insertions(+), 75 deletions(-) diff --git a/lib/appsignal/transaction/opentelemetry_backend.rb b/lib/appsignal/transaction/opentelemetry_backend.rb index 6af93cdc6..09b5adec8 100644 --- a/lib/appsignal/transaction/opentelemetry_backend.rb +++ b/lib/appsignal/transaction/opentelemetry_backend.rb @@ -1,25 +1,16 @@ # 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 lifecycle plus child spans for each - # instrumented event. Error events land in a subsequent step. - # - # On construction the backend starts an OTel root span and attaches it - # to the current OpenTelemetry context, so any third-party OTel - # instrumentation that runs while the transaction is active nests - # naturally under the transaction's span. `start_event` opens a child - # span and pushes it onto a per-transaction LIFO; `finish_event` pops - # it, renames it from a placeholder to the event name, writes the - # body/title attributes, and closes the span. `record_event` is the - # post-facto variant that creates a span with a backdated start - # timestamp. On `complete` the backend drains any leftover event - # spans, detaches the root context, and finishes the root span. + # 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 TRACER_NAME = "appsignal-ruby" @@ -50,17 +41,23 @@ class OpenTelemetryBackend # 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, **) @transaction_id = transaction_id @namespace = namespace @completed = false @event_stack = [] @breadcrumb_count = 0 + @queue_start = nil + @start_time = Time.now - # `start_root_span` (not `start_span`) so the transaction's root - # ignores any ambient OTel context. A transaction is a unit of work - # in its own right -- nesting it under whatever span happens to be - # current would lose that boundary and mix unrelated traces. + # A transaction is its own unit of work, so start a root span that + # ignores any ambient OTel context instead of nesting under an + # unrelated current span. @span = tracer.start_root_span( placeholder_span_name(namespace), :kind => SPAN_KIND_BY_NAMESPACE.fetch(namespace, DEFAULT_SPAN_KIND) @@ -69,10 +66,8 @@ def initialize(transaction_id, namespace, **) ::OpenTelemetry::Trace.context_with_span(@span) ) - # `Appsignal::Transaction#initialize` sets `@namespace` directly and - # never calls `set_namespace`, so emit the attribute here from the - # constructor namespace -- otherwise a transaction that never calls - # `set_namespace` would carry no namespace for the collector. + # Transaction#initialize sets the namespace directly without calling + # set_namespace, so emit the attribute from here. @span.set_attribute("appsignal.namespace", namespace) if namespace end @@ -120,14 +115,19 @@ def set_namespace(namespace) # rubocop:disable Naming/AccessorMethodName @span.set_attribute("appsignal.namespace", namespace) end - # Intentional no-op. Nothing in the OpenTelemetry pipeline consumes a queue - # start: the agent only uses it to compute a `queue_duration` scalar (it - # does not backdate the transaction), the collector has no handling, and - # the spans processor reports `queue_duration: 0` for OTel traces. A bare - # timestamp is meaningless without a consumer to compute the delta, and - # emulating it via span timing would distort the trace duration, so we drop - # it rather than emit an attribute nothing reads. - def set_queue_start(_start) # rubocop:disable Naming/AccessorMethodName + # 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) # rubocop:disable Naming/AccessorMethodName + 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 @@ -253,6 +253,9 @@ def finish 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 @@ -303,21 +306,15 @@ def _completed? private - # Detaches the OTel context and finishes the root span. Shared by - # `complete` and `discard`. - # - # Idempotent: tearing down twice would re-detach an already-detached - # context (a mismatched-detach error) and re-finish an ended span (a - # warning). The Transaction can be completed directly and then again by a - # `complete_current!` cleanup path, so guard against it. + # 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 - # Drain unfinished event spans defensively: if `start_event` was - # called without a matching `finish_event` (caller bug or aborted - # flow), the root context can't detach in LIFO order until the - # event tokens are released first. + # 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) @@ -327,6 +324,28 @@ def teardown @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? + + 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 diff --git a/spec/lib/appsignal/hooks/activejob_spec.rb b/spec/lib/appsignal/hooks/activejob_spec.rb index e27194876..35d951d82 100644 --- a/spec/lib/appsignal/hooks/activejob_spec.rb +++ b/spec/lib/appsignal/hooks/activejob_spec.rb @@ -720,8 +720,9 @@ def perform(*_args) end) end - # queue_start has no OTel equivalent; stays agent-only. The queue time - # metric is dual-moded separately (see "emitting the queue time metric"). + # `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) diff --git a/spec/lib/appsignal/integrations/shoryuken_spec.rb b/spec/lib/appsignal/integrations/shoryuken_spec.rb index 028e69682..90d441321 100644 --- a/spec/lib/appsignal/integrations/shoryuken_spec.rb +++ b/spec/lib/appsignal/integrations/shoryuken_spec.rb @@ -31,7 +31,7 @@ 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 }) end @@ -66,8 +66,6 @@ def perform "queue" => queue, "SentTimestamp" => sent_timestamp ) - # queue_start: agent-only; the OTel backend intentionally drops it - # (no collector consumer computes queue_duration from a raw timestamp). expect(transaction).to have_queue_start(sent_timestamp) expect(transaction).to be_completed end @@ -93,8 +91,8 @@ def perform 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_start has no OTel representation; assert no attribute is emitted - expect(root_span.attributes).not_to have_key("appsignal.queue_start") + 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 @@ -225,7 +223,7 @@ def perform 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( @@ -240,7 +238,7 @@ def perform { :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 } describe "creates a transaction for the batch" do def perform @@ -302,8 +300,8 @@ def perform # Earliest/oldest timestamp from messages expect(root_span.attributes["appsignal.tag.SentTimestamp"]) .to eq(sent_timestamp.to_s) - # queue_start has no OTel representation; assert no attribute is emitted - expect(root_span.attributes).not_to have_key("appsignal.queue_start") + queue_event = Array(root_span.events).find { |e| e.name == "appsignal.queue_start" } + expect(queue_event.attributes["appsignal.queue_start"]).to eq(sent_timestamp) end end end diff --git a/spec/lib/appsignal/integrations/sidekiq_spec.rb b/spec/lib/appsignal/integrations/sidekiq_spec.rb index f0378bf22..cc245a3e7 100644 --- a/spec/lib/appsignal/integrations/sidekiq_spec.rb +++ b/spec/lib/appsignal/integrations/sidekiq_spec.rb @@ -816,7 +816,6 @@ def perform "queue" => "default", "retry_count" => "0" ) - # queue_start has no OTel consumer; agent-only assertion. expect(transaction).to have_queue_start( Time.parse("2001-01-01 10:00:00UTC").to_i * 1000 ) @@ -835,6 +834,9 @@ def perform 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 @@ -980,7 +982,6 @@ def perform expect(transaction).to_not include_environment expect(transaction).to include_params([expected_args]) expect(transaction).to include_tags(expected_tags.merge("queue" => "default")) - # queue_start has no OTel consumer; agent-only assertion. expect(transaction).to have_queue_start(time.to_i * 1000) events = transaction.to_h["events"] @@ -1002,6 +1003,8 @@ def perform 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) 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 @@ -1029,7 +1032,6 @@ def perform expect(transaction).to_not include_environment expect(transaction).to include_params([expected_args]) expect(transaction).to include_tags(expected_tags.merge("queue" => "default")) - # queue_start has no OTel consumer; agent-only assertion. expect(transaction).to have_queue_start(time.to_i * 1000) events = transaction.to_h["events"] @@ -1053,6 +1055,8 @@ def perform 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" } diff --git a/spec/lib/appsignal/rack/abstract_middleware_spec.rb b/spec/lib/appsignal/rack/abstract_middleware_spec.rb index 399a60689..8da5886d7 100644 --- a/spec/lib/appsignal/rack/abstract_middleware_spec.rb +++ b/spec/lib/appsignal/rack/abstract_middleware_spec.rb @@ -478,9 +478,9 @@ def perform start_collector_agent perform - # `set_queue_start` is an intentional no-op in collector mode: - # nothing in the OpenTelemetry pipeline consumes a queue start. - expect(last_transaction).to_not have_queue_start + 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 diff --git a/spec/lib/appsignal/rack/event_handler_spec.rb b/spec/lib/appsignal/rack/event_handler_spec.rb index c94f39f67..788929905 100644 --- a/spec/lib/appsignal/rack/event_handler_spec.rb +++ b/spec/lib/appsignal/rack/event_handler_spec.rb @@ -166,8 +166,8 @@ def perform use_test_logger perform - # `set_queue_start` is an intentional no-op in collector mode. - expect(last_transaction.backend.queue_start).to be_nil + 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 diff --git a/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb b/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb index ff7627a2b..ca4ca38cd 100644 --- a/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb +++ b/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb @@ -98,7 +98,7 @@ def create_backend(namespace = "http_request") end end - describe "write methods (no-op for step 2 — implementations land in subsequent steps)" do + describe "write method smoke tests" do it "accepts #start_event without raising" do expect { create_backend.start_event }.not_to raise_error end @@ -111,10 +111,6 @@ def create_backend(namespace = "http_request") expect { create_backend.record_event("name", "title", "body", 1, 1000) }.not_to raise_error end - it "accepts #set_queue_start without raising" do - expect { create_backend.set_queue_start(123_456) }.not_to raise_error - end - it "accepts #set_metadata without raising" do expect { create_backend.set_metadata("key", "value") }.not_to raise_error end @@ -124,6 +120,49 @@ def create_backend(namespace = "http_request") 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_job" + ) + expect(metrics).to receive(:add_distribution_value).with( + "transaction_queue_duration", be_within(1_000).of(5_000), + :namespace => "background_job", :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 diff --git a/spec/lib/appsignal/transaction_spec.rb b/spec/lib/appsignal/transaction_spec.rb index 3e81075b0..c3fb22d88 100644 --- a/spec/lib/appsignal/transaction_spec.rb +++ b/spec/lib/appsignal/transaction_spec.rb @@ -2636,25 +2636,32 @@ def perform let(:transaction) { new_transaction } describe "setting the queue start" do - def perform - transaction.set_queue_start(10) - end - it "in agent mode", :agent_mode do start_agent(**start_agent_args) - perform + transaction.set_queue_start(1_000_000_000_000) - expect(transaction).to have_queue_start(10) + expect(transaction).to have_queue_start(1_000_000_000_000) end it "in collector mode", :collector_mode do start_collector_agent - perform + # 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 - # Intentional no-op in collector mode: nothing consumes a queue start in - # the OTel pipeline, so no attribute is emitted. - expect(root_span.attributes.keys.grep(/queue/i)).to be_empty + 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) + + snapshot = metric_snapshot("transaction_queue_duration") + expect(snapshot.data_points.map(&:attributes)).to contain_exactly( + { "namespace" => transaction.namespace }, + { "namespace" => transaction.namespace, "hostname" => an_instance_of(String) } + ) + expect(snapshot.data_points.map(&:sum)).to all(be_within(1_000).of(5_000)) end end @@ -2675,7 +2682,8 @@ def perform perform transaction.complete - expect(root_span.attributes.keys.grep(/queue/i)).to be_empty + expect(Array(root_span.events).map(&:name)).to_not include("appsignal.queue_start") + expect(metric_snapshot("transaction_queue_duration")).to be_nil end end From bd4bf7788454ab0131d9bf7d878b96b24b04f4cb Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Tue, 16 Jun 2026 15:27:42 +0200 Subject: [PATCH 085/151] Move breadcrumb buffering into the agent backend The Transaction kept a last-N breadcrumb buffer that only the agent backend used; in collector mode it was built, capped, and dropped while the OpenTelemetry backend emitted each breadcrumb eagerly. Transaction now just builds the breadcrumb and hands it to the backend. The agent backend owns the buffer (cap, flush at completion, copy into duplicates); the OpenTelemetry backend keeps emitting span events. Each backend keeps the cap policy that fits it. --- lib/appsignal/helpers/instrumentation.rb | 2 +- lib/appsignal/transaction.rb | 22 +++-------- .../transaction/extension_backend.rb | 21 +++++++--- .../transaction/opentelemetry_backend.rb | 23 ++++------- sig/appsignal.rbi | 4 +- sig/appsignal.rbs | 4 +- .../transaction/extension_backend_spec.rb | 38 +++++++++++++++++++ spec/lib/appsignal/transaction_spec.rb | 6 +-- spec/lib/appsignal_spec.rb | 2 +- spec/support/matchers/transaction.rb | 3 +- 10 files changed, 77 insertions(+), 48 deletions(-) diff --git a/lib/appsignal/helpers/instrumentation.rb b/lib/appsignal/helpers/instrumentation.rb index 7c1acbd35..3748e3a6a 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( diff --git a/lib/appsignal/transaction.rb b/lib/appsignal/transaction.rb index 37b218cfc..43d35d073 100644 --- a/lib/appsignal/transaction.rb +++ b/lib/appsignal/transaction.rb @@ -168,7 +168,6 @@ def initialize(namespace, id: SecureRandom.uuid, backend: 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 @@ -509,21 +508,16 @@ def add_breadcrumb(category, action, message = "", metadata = {}, time = Time.no return end - breadcrumb = { + # 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.push(breadcrumb) - @breadcrumbs = @breadcrumbs.last(BREADCRUMB_LIMIT) - - # Hand the breadcrumb to the backend immediately. The agent backend - # ignores this (it flushes the whole `@breadcrumbs` array at completion via - # `sample_data`); the OpenTelemetry backend emits it right away as a span - # event on the current span, which has already finished by completion time. - @backend.add_breadcrumb(breadcrumb) + ) end # Set an action name for the transaction. @@ -696,7 +690,7 @@ def 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 @@ -726,8 +720,6 @@ def internal_set_error(error, &block) private - attr_reader :breadcrumbs - def run_after_create_hooks self.class.after_create.each do |block| block.call(self) @@ -938,7 +930,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) @@ -955,7 +946,6 @@ def duplicate 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/extension_backend.rb b/lib/appsignal/transaction/extension_backend.rb index 321d2f089..d73755145 100644 --- a/lib/appsignal/transaction/extension_backend.rb +++ b/lib/appsignal/transaction/extension_backend.rb @@ -12,10 +12,14 @@ class Transaction # `Appsignal::Transaction#initialize` instantiates one and stores it in # `@backend`. class ExtensionBackend + # @!visibility private + attr_writer :breadcrumbs + def initialize(transaction_id, namespace, handle: nil) @handle = handle || Appsignal::Extension.start_transaction(transaction_id, namespace, 0) || Appsignal::Extension::MockTransaction.new + @breadcrumbs = [] end def start_event @@ -52,11 +56,11 @@ def set_sample_data(key, data) @handle.set_sample_data(key, Appsignal::Utils::Data.generate(data)) end - # No-op: agent mode collects breadcrumbs on the Transaction and flushes the - # whole (last-`BREADCRUMB_LIMIT`) array at completion through - # `set_sample_data("breadcrumbs", …)`. Only the OpenTelemetry backend acts - # on the per-breadcrumb call. - def add_breadcrumb(_breadcrumb) + # 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 # `backtrace` is a raw Array (or nil); the C extension wants a `Data` @@ -77,6 +81,9 @@ def finish end def complete + unless @breadcrumbs.empty? + @handle.set_sample_data("breadcrumbs", Appsignal::Utils::Data.generate(@breadcrumbs)) + end @handle.complete end @@ -95,7 +102,9 @@ def supports_multiple_errors? end def duplicate(new_transaction_id) - self.class.new(new_transaction_id, nil, :handle => @handle.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 diff --git a/lib/appsignal/transaction/opentelemetry_backend.rb b/lib/appsignal/transaction/opentelemetry_backend.rb index 09b5adec8..8c3cb6490 100644 --- a/lib/appsignal/transaction/opentelemetry_backend.rb +++ b/lib/appsignal/transaction/opentelemetry_backend.rb @@ -137,16 +137,12 @@ def set_metadata(key, value) @span.set_attribute("appsignal.tag.#{key}", value) end - # Routes each sample-data category to the attribute the collector and the - # server's trace UI recognize. The JSON-blob categories (params, session - # data, custom data) are serialized as JSON strings; the collector - # re-parses and filters them. `environment` is handled in a later branch. - # `error_causes` and `breadcrumbs` are intentionally dropped here: causes - # ride on the exception event (see #set_error) and breadcrumbs are emitted - # per-call as span events on the current span (see #add_breadcrumb), so - # flushing either as sample data would duplicate them on the root span. Any - # other (unknown) category is passed through as a JSON `appsignal.` - # attribute so no data is lost. + # 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" @@ -157,15 +153,10 @@ def set_sample_data(key, data) @span.set_attribute("appsignal.custom_data", JSON.generate(data)) when "environment" write_request_headers(data) - when "breadcrumbs" - # No-op: breadcrumbs are emitted as `appsignal.breadcrumb` span events - # on the current span at add time (see #add_breadcrumb). Flushing them - # here would re-emit them on the already-finished root span. when "tags" write_tags(data) when "error_causes" - # No-op: causes are emitted as the exception event's - # `appsignal.error_causes` attribute (see #set_error), not sample data. + # Emitted on the exception event (see #set_error), not as sample data. else @span.set_attribute("appsignal.#{key}", JSON.generate(data)) end diff --git a/sig/appsignal.rbi b/sig/appsignal.rbi index 76fe378a8..40530e2dd 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". # @@ -2485,7 +2485,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". # diff --git a/sig/appsignal.rbs b/sig/appsignal.rbs index 75108ebca..3e2e2b9d3 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". # @@ -2321,7 +2321,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". # diff --git a/spec/lib/appsignal/transaction/extension_backend_spec.rb b/spec/lib/appsignal/transaction/extension_backend_spec.rb index 6c66b6489..624a80e3c 100644 --- a/spec/lib/appsignal/transaction/extension_backend_spec.rb +++ b/spec/lib/appsignal/transaction/extension_backend_spec.rb @@ -139,6 +139,44 @@ 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 "#supports_multiple_errors?" do it "returns false (extra errors are reported as duplicate transactions)" do expect(backend.supports_multiple_errors?).to eq(false) diff --git a/spec/lib/appsignal/transaction_spec.rb b/spec/lib/appsignal/transaction_spec.rb index c3fb22d88..4b6c53094 100644 --- a/spec/lib/appsignal/transaction_spec.rb +++ b/spec/lib/appsignal/transaction_spec.rb @@ -2299,7 +2299,7 @@ def perform it "stores last breadcrumbs on the transaction in agent mode", :agent_mode do start_agent(**start_agent_args) perform - transaction._sample + transaction.complete expect(transaction.to_h["sample_data"]["breadcrumbs"].length).to eql(20) expect(transaction.to_h["sample_data"]["breadcrumbs"][0]).to eq( @@ -2368,7 +2368,7 @@ def perform start_agent(**start_agent_args) timeframe_start = Time.now.utc.to_i perform - transaction._sample + transaction.complete timeframe_end = Time.now.utc.to_i expect(transaction).to include_breadcrumb( @@ -2404,7 +2404,7 @@ def perform 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._sample + transaction.complete expect(transaction).to_not include_breadcrumbs expect(logs).to contains_log( diff --git a/spec/lib/appsignal_spec.rb b/spec/lib/appsignal_spec.rb index be5c79d88..7d1bd8e6d 100644 --- a/spec/lib/appsignal_spec.rb +++ b/spec/lib/appsignal_spec.rb @@ -1840,7 +1840,7 @@ def perform start_agent perform - transaction._sample + transaction.complete expect(transaction).to include_breadcrumb( "http", "Network", diff --git a/spec/support/matchers/transaction.rb b/spec/support/matchers/transaction.rb index 25e052d4c..ec02c1fba 100644 --- a/spec/support/matchers/transaction.rb +++ b/spec/support/matchers/transaction.rb @@ -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 From 350936fc9bbb10fe2fe9219520c14656cdfc3f2e Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Tue, 16 Jun 2026 15:30:15 +0200 Subject: [PATCH 086/151] Compute error causes once, project per backend The cause chain was walked twice on every error: a first-line projection for the agent's error_causes sample data and a full-backtrace projection for the backend argument, each mode discarding the other. Transaction now walks it once into neutral data and each backend projects what it needs -- the agent's first-line sample data (with the backtrace formatting moved into the agent backend) or the OpenTelemetry appsignal.error_causes attribute. --- lib/appsignal/transaction.rb | 80 +++---------------- .../transaction/extension_backend.rb | 55 ++++++++++++- .../transaction/opentelemetry_backend.rb | 30 +++---- .../transaction/extension_backend_spec.rb | 45 ++++++++++- .../transaction/opentelemetry_backend_spec.rb | 25 +++--- 5 files changed, 128 insertions(+), 107 deletions(-) diff --git a/lib/appsignal/transaction.rb b/lib/appsignal/transaction.rb index 43d35d073..a7e2d8f94 100644 --- a/lib/appsignal/transaction.rb +++ b/lib/appsignal/transaction.rb @@ -793,52 +793,28 @@ def report_errors_as_duplicates def _set_error(error) @error_set = error - _send_error_to_backend(error) - - # Agent-mode cause channel: a `first_line`-only projection sent as sample - # data. The OpenTelemetry backend stubs `set_sample_data`, so in collector - # mode this is a no-op (causes ride on `appsignal.error_causes` instead). - causes, root_cause_missing = _error_causes(error) - 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 - # Records an error on the backend without touching `@error_set` or the - # `error_causes` sample data, so it can be called both for the first error - # (from `_set_error`) and for additional errors when collapsing multiple - # errors onto one trace (see `#complete`). - # - # The backend receives raw Ruby inputs (a backtrace Array, not a - # pre-serialized Data object) and the rich cause list. The agent backend - # serializes the backtrace to Data itself; the OpenTelemetry backend uses - # the causes to emit the `appsignal.error_causes` attribute. The `:lines` - # key matches the processor's `ErrorSubCause { name, message, lines }`. + # 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, = _error_causes(error) + causes, root_cause_missing = _error_causes(error) @backend.set_error( error.class.name, cleaned_error_message(error), cleaned_backtrace(error.backtrace), - causes.map do |e| + causes.map do |cause| { - :name => e.class.name, - :message => cleaned_error_message(e), - :lines => cleaned_backtrace(e.backtrace) + :name => cause.class.name, + :message => cleaned_error_message(cause), + :backtrace => cleaned_backtrace(cause.backtrace) } - end + end, + root_cause_missing ) end @@ -864,38 +840,6 @@ def _error_causes(error) [causes, root_cause_missing] 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 - end - def set_sample_data(key, data) return unless key && data diff --git a/lib/appsignal/transaction/extension_backend.rb b/lib/appsignal/transaction/extension_backend.rb index d73755145..00b99e397 100644 --- a/lib/appsignal/transaction/extension_backend.rb +++ b/lib/appsignal/transaction/extension_backend.rb @@ -12,6 +12,11 @@ class Transaction # `Appsignal::Transaction#initialize` instantiates one and stores it in # `@backend`. class ExtensionBackend + # rubocop:disable Layout/LineLength + BACKTRACE_REGEX = + %r{(?[\w-]+ \(.+\) )?(?:?/?\w+?.+?):(?:?\d+)(?::in `(?.+)')?$}.freeze + # rubocop:enable Layout/LineLength + # @!visibility private attr_writer :breadcrumbs @@ -63,10 +68,10 @@ def add_breadcrumb(breadcrumb) @breadcrumbs = @breadcrumbs.last(Appsignal::Transaction::BREADCRUMB_LIMIT) end - # `backtrace` is a raw Array (or nil); the C extension wants a `Data` - # object, so serialize it here. `causes` is unused in agent mode -- the - # Transaction reports causes separately via the `error_causes` sample data. - def set_error(class_name, message, backtrace, _causes) + # 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) @@ -74,6 +79,8 @@ def set_error(class_name, message, backtrace, _causes) 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 @@ -121,6 +128,46 @@ def queue_start def _completed? @handle._completed? 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 index 8c3cb6490..3996fcd11 100644 --- a/lib/appsignal/transaction/opentelemetry_backend.rb +++ b/lib/appsignal/transaction/opentelemetry_backend.rb @@ -155,31 +155,19 @@ def set_sample_data(key, data) write_request_headers(data) when "tags" write_tags(data) - when "error_causes" - # Emitted on the exception event (see #set_error), not as sample data. else @span.set_attribute("appsignal.#{key}", JSON.generate(data)) end end - # Records the error as an OpenTelemetry `exception` span-event on the - # span that is current *now* -- which may be an event span, not the root -- - # so the error attaches to the operation that raised it. The Transaction - # records each error at the moment it is added, so the current span is the - # one active at that point. - # - # `appsignal.alert_this_error` makes the collector report the exception - # even when it is on a non-root span: the collector reports every exception - # on the root span, but only flagged ones on child spans. The collector - # computes `appsignal.error_digest` itself, so we don't set it here. - # - # Causes are emitted as a single `appsignal.error_causes` JSON attribute on - # the exception event (not on the span): separate cause events would each - # be stamped with a digest and become their own incident. The processor - # reads `appsignal.error_causes` off the event and expands it into cause - # subdata. The JSON keys (`name`/`message`/`lines`) match the processor's - # `ErrorSubCause` struct. - def set_error(class_name, message, backtrace, causes) + # Records the error as an `exception` event on the span that is current now + # -- which may be an event span, not the root -- so it attaches to the + # operation that raised it. `appsignal.alert_this_error` tells the collector + # to report it even on a child span; 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 = ::OpenTelemetry::Trace.current_span attributes = { @@ -195,7 +183,7 @@ def set_error(class_name, message, backtrace, causes) { "name" => cause[:name], "message" => cause[:message], - "lines" => cause[:lines] || [] + "lines" => cause[:backtrace] || [] } end ) diff --git a/spec/lib/appsignal/transaction/extension_backend_spec.rb b/spec/lib/appsignal/transaction/extension_backend_spec.rb index 624a80e3c..b10c6f1d9 100644 --- a/spec/lib/appsignal/transaction/extension_backend_spec.rb +++ b/spec/lib/appsignal/transaction/extension_backend_spec.rb @@ -94,17 +94,30 @@ 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"], []) + 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, []) + 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 @@ -177,6 +190,34 @@ end end + describe "error_causes projection" do + it "projects causes to the first-line shape" do + causes = [{ + :name => "ArgumentError", + :message => "bad arg", + :backtrace => ["/app/lib/foo.rb:10:in `bar'"] + }] + projected = backend.send(:error_causes_sample_data, causes, false) + + expect(projected.first).to include(:name => "ArgumentError", :message => "bad arg") + expect(projected.first[:first_line]["original"]).to eq("/app/lib/foo.rb:10:in `bar'") + expect(projected.first[:first_line]["line"]).to eq(10) + end + + it "marks the last cause as not the root cause when the chain was truncated" do + causes = [{ :name => "E", :message => "m", :backtrace => ["/app/x.rb:1:in `y'"] }] + + expect(backend.send(:error_causes_sample_data, causes, true).last[:is_root_cause]) + .to eq(false) + end + + it "leaves the first line nil for a cause with no backtrace" do + causes = [{ :name => "E", :message => "m", :backtrace => nil }] + + expect(backend.send(:error_causes_sample_data, causes, false).first[:first_line]).to be_nil + end + end + describe "#supports_multiple_errors?" do it "returns false (extra errors are reported as duplicate transactions)" do expect(backend.supports_multiple_errors?).to eq(false) diff --git a/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb b/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb index ca4ca38cd..5e9177676 100644 --- a/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb +++ b/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb @@ -220,7 +220,7 @@ def exception_event(backend) it "records an exception span-event on the root span" do backend = create_backend - backend.set_error("RuntimeError", "boom", ["line 1", "line 2"], []) + backend.set_error("RuntimeError", "boom", ["line 1", "line 2"], [], false) event = exception_event(backend) expect(event).not_to be_nil @@ -231,7 +231,7 @@ def exception_event(backend) it "sets the span status to error" do backend = create_backend - backend.set_error("RuntimeError", "boom", ["line 1"], []) + backend.set_error("RuntimeError", "boom", ["line 1"], [], false) backend.complete backend_span_id = backend.instance_variable_get(:@span).context.span_id @@ -241,7 +241,7 @@ def exception_event(backend) it "omits exception.stacktrace content when there is no backtrace" do backend = create_backend - backend.set_error("RuntimeError", "boom", nil, []) + backend.set_error("RuntimeError", "boom", nil, [], false) expect(exception_event(backend).attributes["exception.stacktrace"]).to eq("") end @@ -249,10 +249,10 @@ def exception_event(backend) it "emits causes as an appsignal.error_causes JSON attribute matching ErrorSubCause" do backend = create_backend causes = [ - { :name => "ArgumentError", :message => "bad arg", :lines => ["cause 1", "cause 2"] }, - { :name => "KeyError", :message => "missing", :lines => ["cause 3"] } + { :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) + backend.set_error("RuntimeError", "boom", ["line 1"], causes, false) parsed = JSON.parse(exception_event(backend).attributes["appsignal.error_causes"]) expect(parsed).to eq( @@ -267,7 +267,8 @@ def exception_event(backend) backend = create_backend backend.set_error( "RuntimeError", "boom", ["line 1"], - [{ :name => "ArgumentError", :message => "bad arg", :lines => nil }] + [{ :name => "ArgumentError", :message => "bad arg", :backtrace => nil }], + false ) parsed = JSON.parse(exception_event(backend).attributes["appsignal.error_causes"]) @@ -276,14 +277,14 @@ def exception_event(backend) it "does not set appsignal.error_causes when there are no causes" do backend = create_backend - backend.set_error("RuntimeError", "boom", ["line 1"], []) + 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"], []) + 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 @@ -295,7 +296,7 @@ def exception_event(backend) 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"], []) + backend.set_error("RuntimeError", "boom", ["line 1"], [], false) backend.finish_event("sql.query", "title", "body", Appsignal::EventFormatter::DEFAULT) backend.complete @@ -310,8 +311,8 @@ def exception_event(backend) it "records one exception event per call (multiple errors on one span)" do backend = create_backend - backend.set_error("RuntimeError", "first", ["line 1"], []) - backend.set_error("ArgumentError", "second", ["line 2"], []) + 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 From 6f739c20d8cf863d8d582f410679ce1e0ce49a41 Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Tue, 16 Jun 2026 15:31:12 +0200 Subject: [PATCH 087/151] Rename the multi-error backend capability flag `supports_multiple_errors?` described a capability, but the code used it to choose between recording each error eagerly on one trace and deferring to duplicate transactions. Rename it to `records_errors_eagerly?`, rename `report_errors_on_one_trace` to `run_error_blocks`, and replace the scattered comments with one description of the eager-vs-deferred split. --- lib/appsignal/transaction.rb | 44 +++++++++---------- .../transaction/extension_backend.rb | 4 +- .../transaction/opentelemetry_backend.rb | 7 ++- .../transaction/extension_backend_spec.rb | 4 +- .../transaction/opentelemetry_backend_spec.rb | 4 +- 5 files changed, 30 insertions(+), 33 deletions(-) diff --git a/lib/appsignal/transaction.rb b/lib/appsignal/transaction.rb index a7e2d8f94..200db6845 100644 --- a/lib/appsignal/transaction.rb +++ b/lib/appsignal/transaction.rb @@ -706,11 +706,10 @@ def internal_set_error(error, &block) if @error_blocks.empty? _set_error(error) - elsif is_new_error && @backend.supports_multiple_errors? - # Record additional errors as they are added, so the exception event - # lands on the span that is current now rather than the root span at - # completion time. The agent backend instead reports extra errors as - # duplicate transactions at completion (see `#report_errors`). + 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 @@ -732,30 +731,29 @@ def run_before_complete_hooks end end - # Reports every error stored on the transaction at completion time. How - # additional errors (beyond the first, `@error_set`) are reported depends on - # the backend. + # 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.supports_multiple_errors? - report_errors_on_one_trace + if @backend.records_errors_eagerly? + run_error_blocks else report_errors_as_duplicates end end - # Collector mode: every error is recorded as its own `exception` event (on - # the span current when it was added) at `add_error` time, so a single trace - # carries all the errors -- no duplicate transactions. Here we only run the - # error blocks. - # - # Blocks run in the order their errors were added (the first error's blocks - # first), so a later error's block overrides an earlier one on a shared key. - # Every block runs against this transaction, so block-set metadata merges - # onto the root span. This intentionally deviates from the per-error metadata - # isolation in `transaction-otel-backend.md` §6.9: isolating further errors' - # metadata onto their own exception event is deferred, as the processor/UI - # does not read those event attributes yet. - def report_errors_on_one_trace + # 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) } diff --git a/lib/appsignal/transaction/extension_backend.rb b/lib/appsignal/transaction/extension_backend.rb index 00b99e397..b1ccd009e 100644 --- a/lib/appsignal/transaction/extension_backend.rb +++ b/lib/appsignal/transaction/extension_backend.rb @@ -103,8 +103,8 @@ def discard end # The extension transaction holds a single error, so the Transaction - # reports additional errors as duplicate transactions. - def supports_multiple_errors? + # reports additional errors as duplicate transactions instead. + def records_errors_eagerly? false end diff --git a/lib/appsignal/transaction/opentelemetry_backend.rb b/lib/appsignal/transaction/opentelemetry_backend.rb index 3996fcd11..95d154f3c 100644 --- a/lib/appsignal/transaction/opentelemetry_backend.rb +++ b/lib/appsignal/transaction/opentelemetry_backend.rb @@ -257,10 +257,9 @@ def duplicate(new_transaction_id) self.class.new(new_transaction_id, @namespace) end - # Multiple errors are recorded as multiple `exception` events on the one - # root span (each its own incident), so the Transaction does not need to - # duplicate itself per error. - def supports_multiple_errors? + # Each error is recorded eagerly as its own `exception` event on the span + # current when it was added, so the Transaction never duplicates itself. + def records_errors_eagerly? true end diff --git a/spec/lib/appsignal/transaction/extension_backend_spec.rb b/spec/lib/appsignal/transaction/extension_backend_spec.rb index b10c6f1d9..9e517dee6 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 "#supports_multiple_errors?" do + describe "#records_errors_eagerly?" do it "returns false (extra errors are reported as duplicate transactions)" do - expect(backend.supports_multiple_errors?).to eq(false) + 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 index 5e9177676..24176bb6c 100644 --- a/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb +++ b/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb @@ -324,9 +324,9 @@ def exception_event(backend) end end - describe "#supports_multiple_errors?" do + describe "#records_errors_eagerly?" do it "returns true (multiple exception events on one span)" do - expect(create_backend.supports_multiple_errors?).to eq(true) + expect(create_backend.records_errors_eagerly?).to eq(true) end end From 126f1089dc2193caedba8a3ef8038b28b6d01bfb Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Tue, 16 Jun 2026 15:32:56 +0200 Subject: [PATCH 088/151] Add Transaction::BaseBackend base class Introduce an abstract base both transaction backends inherit. It states the backend interface in one place and raises NotImplementedError for anything a backend leaves unimplemented, so the OpenTelemetry backend no longer carries a dead `duplicate` (duplication is agent-only). Test-only introspection (`queue_start`, `_completed?`) moves off the production classes into a module applied in the test suite. --- lib/appsignal/backends.rb | 1 + lib/appsignal/transaction/base_backend.rb | 85 +++++++++++++++++++ .../transaction/extension_backend.rb | 14 +-- .../transaction/opentelemetry_backend.rb | 27 ++---- .../transaction/opentelemetry_backend_spec.rb | 28 +----- spec/support/testing.rb | 25 ++++++ 6 files changed, 123 insertions(+), 57 deletions(-) create mode 100644 lib/appsignal/transaction/base_backend.rb diff --git a/lib/appsignal/backends.rb b/lib/appsignal/backends.rb index f7acc6715..e0d50425c 100644 --- a/lib/appsignal/backends.rb +++ b/lib/appsignal/backends.rb @@ -4,6 +4,7 @@ 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" diff --git a/lib/appsignal/transaction/base_backend.rb b/lib/appsignal/transaction/base_backend.rb new file mode 100644 index 000000000..4dc8f339c --- /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 + raise NotImplementedError + end + + def finish_event(_name, _title, _body, _body_format) + raise NotImplementedError + end + + def record_event(_name, _title, _body, _body_format, _duration) + raise NotImplementedError + end + + # Transaction metadata. + def set_action(_action) # rubocop:disable Naming/AccessorMethodName + raise NotImplementedError + end + + def set_namespace(_namespace) # rubocop:disable Naming/AccessorMethodName + raise NotImplementedError + end + + def set_metadata(_key, _value) + raise NotImplementedError + end + + def set_queue_start(_start) # rubocop:disable Naming/AccessorMethodName + 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 index b1ccd009e..a9f06c956 100644 --- a/lib/appsignal/transaction/extension_backend.rb +++ b/lib/appsignal/transaction/extension_backend.rb @@ -11,7 +11,7 @@ class Transaction # In agent mode `Appsignal::Backends.transaction` returns this class; # `Appsignal::Transaction#initialize` instantiates one and stores it in # `@backend`. - class ExtensionBackend + class ExtensionBackend < BaseBackend # rubocop:disable Layout/LineLength BACKTRACE_REGEX = %r{(?[\w-]+ \(.+\) )?(?:?/?\w+?.+?):(?:?\d+)(?::in `(?.+)')?$}.freeze @@ -21,6 +21,7 @@ class ExtensionBackend 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 @@ -118,17 +119,6 @@ def to_json # rubocop:disable Lint/ToJSON @handle.to_json end - # Test-mode introspection. The `Appsignal::Extension::Transaction` patches - # in `spec/support/testing.rb` add `queue_start` and `_completed?` on the - # underlying handle; matchers reach in via `transaction.backend.`. - def queue_start - @handle.queue_start - end - - def _completed? - @handle._completed? - end - private # Projects the neutral causes to the agent's first-line shape. A truncated diff --git a/lib/appsignal/transaction/opentelemetry_backend.rb b/lib/appsignal/transaction/opentelemetry_backend.rb index 95d154f3c..80b91d8b9 100644 --- a/lib/appsignal/transaction/opentelemetry_backend.rb +++ b/lib/appsignal/transaction/opentelemetry_backend.rb @@ -11,7 +11,7 @@ class Transaction # 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 + class OpenTelemetryBackend < BaseBackend TRACER_NAME = "appsignal-ruby" # Keys correspond to `Appsignal::Transaction::HTTP_REQUEST`, @@ -47,6 +47,7 @@ class OpenTelemetryBackend QUEUE_START_MIN = 946_681_200_000 def initialize(transaction_id, namespace, **) + super() @transaction_id = transaction_id @namespace = namespace @completed = false @@ -253,35 +254,19 @@ def discard teardown end - def duplicate(new_transaction_id) - self.class.new(new_transaction_id, @namespace) - 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. + # 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` (which does `JSON.parse(@backend.to_json)`) - # produces an empty Hash instead of raising. The existing agent-mode - # transaction matchers all read from `to_h`; in collector mode they - # will be replaced by OTel-based assertions in subsequent steps. + # 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 - # Test-mode introspection parity with `ExtensionBackend`. Returns `nil` - # for now since `set_queue_start` is a no-op; `_completed?` toggles on - # `complete` so `be_completed` keeps working. - def queue_start - nil - end - - def _completed? - @completed - end - private # Detaches the OTel context and finishes the root span. Idempotent: the diff --git a/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb b/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb index 24176bb6c..a68557da2 100644 --- a/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb +++ b/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb @@ -411,30 +411,10 @@ def exception_event(backend) end describe "#duplicate" do - # Multi-error duplicate is dead code in collector mode until errors are - # wired up in a later step (one span + multiple `record_exception` events - # will replace the duplicate-per-error model). For now we just preserve - # the shape so `Transaction#complete`'s duplicate loop won't crash. - it "returns a new OpenTelemetryBackend instance with the new id" do - backend = create_backend - duplicate = backend.duplicate("new-id") - @backends_created << duplicate - - expect(duplicate).to be_kind_of(described_class) - expect(duplicate).not_to be(backend) - expect(duplicate.instance_variable_get(:@transaction_id)).to eq("new-id") - end - - it "carries a namespace updated via #set_namespace into the duplicate" do - backend = create_backend("http_request") - backend.set_namespace("background_job") - duplicate = backend.duplicate("new-id") - @backends_created << duplicate - duplicate.complete - - # The duplicate selects its span kind from the namespace it was - # constructed with, so the updated namespace must reach it. - expect(span_exporter.finished_spans.first.kind).to eq(:consumer) + # 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 diff --git a/spec/support/testing.rb b/spec/support/testing.rb index 59001f559..1ad9dd589 100644 --- a/spec/support/testing.rb +++ b/spec/support/testing.rb @@ -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 74fb11412869439f44b0b977ad55b53dca4553cc Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Tue, 16 Jun 2026 19:17:55 +0200 Subject: [PATCH 089/151] Keep error and breadcrumb data on AppSignal spans In collector mode, set_error and add_breadcrumb wrote their span event to the OpenTelemetry current span. When another instrumentation's span is current, that meant attaching AppSignal data to a span AppSignal did not create. Target AppSignal's own current span instead -- the open event span, or the root when none is open. Parenting still follows the global context, so AppSignal and foreign spans keep nesting under each other. Tests fail on any unexpected OpenTelemetry context detach error. --- .../transaction/opentelemetry_backend.rb | 38 +-- .../transaction/opentelemetry_backend_spec.rb | 228 ++++++++++++++++++ spec/lib/appsignal/transaction_spec.rb | 30 +++ 3 files changed, 281 insertions(+), 15 deletions(-) diff --git a/lib/appsignal/transaction/opentelemetry_backend.rb b/lib/appsignal/transaction/opentelemetry_backend.rb index 80b91d8b9..fce6a2cd4 100644 --- a/lib/appsignal/transaction/opentelemetry_backend.rb +++ b/lib/appsignal/transaction/opentelemetry_backend.rb @@ -161,15 +161,16 @@ def set_sample_data(key, data) end end - # Records the error as an `exception` event on the span that is current now - # -- which may be an event span, not the root -- so it attaches to the - # operation that raised it. `appsignal.alert_this_error` tells the collector - # to report it even on a child span; 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. + # 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 = ::OpenTelemetry::Trace.current_span + span = current_span attributes = { "exception.type" => class_name, @@ -194,12 +195,12 @@ def set_error(class_name, message, backtrace, causes, _root_cause_missing) span.status = ::OpenTelemetry::Trace::Status.error end - # Emits a breadcrumb as an `appsignal.breadcrumb` span event on the span - # that is current *now* -- the event span active when the breadcrumb was - # added, falling back to the root span when none is open. Breadcrumbs are - # emitted immediately (not flushed at completion) because by completion the - # event span has finished, and the OTel SDK drops events added to an ended - # span. + # Emits a breadcrumb as an `appsignal.breadcrumb` span event on AppSignal's + # current span -- the open event span, falling back to the root -- not the + # OTel current span, which may belong to another instrumentation. + # Breadcrumbs are emitted immediately (not flushed at completion) because by + # completion the event span has finished, and the OTel SDK drops events + # added to an ended span. # # The breadcrumb's recorded time becomes the event timestamp (events carry # their own time), so it is not duplicated as an attribute; @@ -212,7 +213,7 @@ def add_breadcrumb(breadcrumb) return if @breadcrumb_count >= Appsignal::Transaction::BREADCRUMB_LIMIT @breadcrumb_count += 1 - ::OpenTelemetry::Trace.current_span.add_event( + current_span.add_event( "appsignal.breadcrumb", :timestamp => Time.at(breadcrumb[:time]), :attributes => { @@ -313,6 +314,13 @@ 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 diff --git a/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb b/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb index a68557da2..a4388cea4 100644 --- a/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb +++ b/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb @@ -16,6 +16,16 @@ 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 @@ -25,12 +35,38 @@ # 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 @@ -668,4 +704,196 @@ def new_transaction_with_otel_backend(namespace = Appsignal::Transaction::HTTP_R 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 4b6c53094..19f22de78 100644 --- a/spec/lib/appsignal/transaction_spec.rb +++ b/spec/lib/appsignal/transaction_spec.rb @@ -174,6 +174,36 @@ 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 From 1bd50f7a129334ceef13cbd663dc8b7e72ab1118 Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Tue, 23 Jun 2026 19:56:19 +0200 Subject: [PATCH 090/151] Use web/background namespaces in collector mode The OpenTelemetry backend emitted the internal http_request and background_job namespaces directly. In agent mode the processor converts these to web and background, but nothing does in collector mode, so the raw values leaked through. Convert them in the backend to match, leaving other namespaces untouched. --- .../transaction/opentelemetry_backend.rb | 21 ++++++++++--- .../integration/collector_mode_traces_spec.rb | 3 ++ .../transaction/opentelemetry_backend_spec.rb | 30 +++++++++++++++---- spec/lib/appsignal/transaction_spec.rb | 10 +++---- 4 files changed, 49 insertions(+), 15 deletions(-) diff --git a/lib/appsignal/transaction/opentelemetry_backend.rb b/lib/appsignal/transaction/opentelemetry_backend.rb index fce6a2cd4..a0702fc90 100644 --- a/lib/appsignal/transaction/opentelemetry_backend.rb +++ b/lib/appsignal/transaction/opentelemetry_backend.rb @@ -30,6 +30,14 @@ class OpenTelemetryBackend < BaseBackend # 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 @@ -69,7 +77,7 @@ def initialize(transaction_id, namespace, **) # Transaction#initialize sets the namespace directly without calling # set_namespace, so emit the attribute from here. - @span.set_attribute("appsignal.namespace", namespace) if namespace + @span.set_attribute("appsignal.namespace", display_namespace(namespace)) if namespace end def start_event @@ -113,7 +121,7 @@ def set_namespace(namespace) # rubocop:disable Naming/AccessorMethodName # 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", namespace) + @span.set_attribute("appsignal.namespace", display_namespace(namespace)) end # Queue start has no OTel-native home, so surface it two ways: an @@ -297,12 +305,13 @@ def emit_queue_duration_metric 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 + "transaction_queue_duration", duration_ms, :namespace => namespace ) Appsignal::Metrics::OpenTelemetryBackend.add_distribution_value( "transaction_queue_duration", duration_ms, - :namespace => @namespace, :hostname => hostname + :namespace => namespace, :hostname => hostname ) end @@ -325,6 +334,10 @@ def placeholder_span_name(namespace) "appsignal.transaction #{namespace}" 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 diff --git a/spec/integration/collector_mode_traces_spec.rb b/spec/integration/collector_mode_traces_spec.rb index 38937c96b..81a314c69 100644 --- a/spec/integration/collector_mode_traces_spec.rb +++ b/spec/integration/collector_mode_traces_spec.rb @@ -21,6 +21,9 @@ 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 diff --git a/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb b/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb index a4388cea4..1d0c3242c 100644 --- a/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb +++ b/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb @@ -177,11 +177,11 @@ def event_names(finished) 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_job" + "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_job", :hostname => an_instance_of(String) + :namespace => "background", :hostname => an_instance_of(String) ) backend.set_queue_start(queue_start) @@ -219,11 +219,20 @@ def event_names(finished) end describe "appsignal.namespace attribute" do - it "is set from the constructor namespace" do - create_backend("background_job").complete + # 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("background_job") + expect(span_exporter.finished_spans.first.attributes["appsignal.namespace"]) + .to eq(expected) + end end describe "#set_namespace" do @@ -236,6 +245,15 @@ def event_names(finished) .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") diff --git a/spec/lib/appsignal/transaction_spec.rb b/spec/lib/appsignal/transaction_spec.rb index 19f22de78..7484b08d2 100644 --- a/spec/lib/appsignal/transaction_spec.rb +++ b/spec/lib/appsignal/transaction_spec.rb @@ -2651,13 +2651,12 @@ def perform end context "when set_namespace is never called", :collector_mode do - it "carries the namespace from creation" 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(Appsignal::Transaction::HTTP_REQUEST) + expect(root_span.attributes["appsignal.namespace"]).to eq("web") end end end @@ -2686,10 +2685,11 @@ def perform 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" => transaction.namespace }, - { "namespace" => transaction.namespace, "hostname" => an_instance_of(String) } + { "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 From 390e25df027dafb33c897b7d00fe90ae629d5b2d Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Tue, 23 Jun 2026 20:34:52 +0200 Subject: [PATCH 091/151] Update collector namespace assertions in specs These specs asserted the pre-conversion http_request and background_job namespaces on the emitted span; update them to the converted web and background values. --- spec/lib/appsignal/hooks/activejob_spec.rb | 6 +++--- .../appsignal/integrations/delayed_job_plugin_spec.rb | 6 +++--- spec/lib/appsignal/integrations/http_spec.rb | 4 ++-- spec/lib/appsignal/integrations/que_spec.rb | 6 +++--- spec/lib/appsignal/integrations/railtie_spec.rb | 2 +- spec/lib/appsignal/integrations/resque_spec.rb | 8 ++++---- spec/lib/appsignal/integrations/shoryuken_spec.rb | 6 +++--- spec/lib/appsignal/integrations/sidekiq_spec.rb | 10 +++++----- spec/lib/appsignal/rack/abstract_middleware_spec.rb | 4 ++-- spec/lib/appsignal/rack/event_handler_spec.rb | 2 +- spec/lib/appsignal/rack/rails_instrumentation_spec.rb | 2 +- .../lib/appsignal/rack/sinatra_instrumentation_spec.rb | 4 ++-- spec/lib/appsignal_spec.rb | 6 +++--- 13 files changed, 33 insertions(+), 33 deletions(-) diff --git a/spec/lib/appsignal/hooks/activejob_spec.rb b/spec/lib/appsignal/hooks/activejob_spec.rb index 35d951d82..bdc622f8f 100644 --- a/spec/lib/appsignal/hooks/activejob_spec.rb +++ b/spec/lib/appsignal/hooks/activejob_spec.rb @@ -144,7 +144,7 @@ def perform last_transaction.complete expect(root_span.kind).to eq(:consumer) - expect(root_span.attributes["appsignal.namespace"]).to eq(namespace) + 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") @@ -270,7 +270,7 @@ def perform expect { perform }.to raise_error(RuntimeError, "uh oh") last_transaction.complete - expect(root_span.attributes["appsignal.namespace"]).to eq(namespace) + 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" } @@ -523,7 +523,7 @@ def perform(current_transaction) current_transaction.complete - expect(root_span.attributes["appsignal.namespace"]).to eq(namespace) + 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") diff --git a/spec/lib/appsignal/integrations/delayed_job_plugin_spec.rb b/spec/lib/appsignal/integrations/delayed_job_plugin_spec.rb index 2987ca118..28e39df1e 100644 --- a/spec/lib/appsignal/integrations/delayed_job_plugin_spec.rb +++ b/spec/lib/appsignal/integrations/delayed_job_plugin_spec.rb @@ -71,7 +71,7 @@ def perform expect(root_span.kind).to eq(:consumer) expect(root_span.attributes["appsignal.action_name"]).to eq("TestClass#perform") - expect(root_span.attributes["appsignal.namespace"]).to eq("background_job") + expect(root_span.attributes["appsignal.namespace"]).to eq("background") expect(exception_events).to be_empty span = event_spans.find { |s| s.name == "perform_job.delayed_job" } expect(span).not_to be_nil @@ -410,7 +410,7 @@ def self.appsignal_name expect(root_span.kind).to eq(:consumer) expect(root_span.attributes["appsignal.action_name"]).to eq("TestClass#perform") - expect(root_span.attributes["appsignal.namespace"]).to eq("background_job") + expect(root_span.attributes["appsignal.namespace"]).to eq("background") expect(exception_events).to be_empty span = event_spans.find { |s| s.name == "perform_job.delayed_job" } expect(span).not_to be_nil @@ -525,7 +525,7 @@ def self.appsignal_name end.to raise_error(error) expect(root_span.kind).to eq(:consumer) - expect(root_span.attributes["appsignal.namespace"]).to eq("background_job") + 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 diff --git a/spec/lib/appsignal/integrations/http_spec.rb b/spec/lib/appsignal/integrations/http_spec.rb index 19cb31f10..8d2c9e2ff 100644 --- a/spec/lib/appsignal/integrations/http_spec.rb +++ b/spec/lib/appsignal/integrations/http_spec.rb @@ -34,7 +34,7 @@ def perform Appsignal::Transaction.complete_current! expect(root_span.attributes["appsignal.namespace"]) - .to eq(Appsignal::Transaction::HTTP_REQUEST) + .to eq("web") expect(event_spans.size).to eq(1) span = event_spans.first expect(span.name).to eq("GET http://www.google.com") @@ -72,7 +72,7 @@ def perform Appsignal::Transaction.complete_current! expect(root_span.attributes["appsignal.namespace"]) - .to eq(Appsignal::Transaction::HTTP_REQUEST) + .to eq("web") expect(event_spans.size).to eq(1) span = event_spans.first expect(span.name).to eq("GET https://www.google.com") diff --git a/spec/lib/appsignal/integrations/que_spec.rb b/spec/lib/appsignal/integrations/que_spec.rb index 4fab40e43..45f6c38e5 100644 --- a/spec/lib/appsignal/integrations/que_spec.rb +++ b/spec/lib/appsignal/integrations/que_spec.rb @@ -81,7 +81,7 @@ def perform expect(root_span.kind).to eq(:consumer) expect(root_span.attributes["appsignal.namespace"]) - .to eq(Appsignal::Transaction::BACKGROUND_JOB) + .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" } @@ -146,7 +146,7 @@ def perform 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(Appsignal::Transaction::BACKGROUND_JOB) + .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) @@ -207,7 +207,7 @@ def perform 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(Appsignal::Transaction::BACKGROUND_JOB) + .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) diff --git a/spec/lib/appsignal/integrations/railtie_spec.rb b/spec/lib/appsignal/integrations/railtie_spec.rb index a831bcb44..652d60acb 100644 --- a/spec/lib/appsignal/integrations/railtie_spec.rb +++ b/spec/lib/appsignal/integrations/railtie_spec.rb @@ -420,7 +420,7 @@ def perform expect(root_span.kind).to eq(:server) expect(root_span.attributes["appsignal.namespace"]) - .to eq(Appsignal::Transaction::HTTP_REQUEST) + .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 diff --git a/spec/lib/appsignal/integrations/resque_spec.rb b/spec/lib/appsignal/integrations/resque_spec.rb index e98eabefa..f706d7a57 100644 --- a/spec/lib/appsignal/integrations/resque_spec.rb +++ b/spec/lib/appsignal/integrations/resque_spec.rb @@ -53,7 +53,7 @@ def perform perform expect(root_span.kind).to eq(:consumer) - expect(root_span.attributes["appsignal.namespace"]).to eq(namespace) + 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") @@ -93,7 +93,7 @@ def perform perform expect(root_span.kind).to eq(:consumer) - expect(root_span.attributes["appsignal.namespace"]).to eq(namespace) + 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 @@ -157,7 +157,7 @@ def perform expect(Appsignal).to receive(:stop) perform - expect(root_span.attributes["appsignal.namespace"]).to eq(namespace) + 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) @@ -224,7 +224,7 @@ def perform expect(Appsignal).to receive(:stop) perform - expect(root_span.attributes["appsignal.namespace"]).to eq(namespace) + 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 diff --git a/spec/lib/appsignal/integrations/shoryuken_spec.rb b/spec/lib/appsignal/integrations/shoryuken_spec.rb index 90d441321..cdb298ac5 100644 --- a/spec/lib/appsignal/integrations/shoryuken_spec.rb +++ b/spec/lib/appsignal/integrations/shoryuken_spec.rb @@ -76,7 +76,7 @@ def perform expect(root_span.kind).to eq(:consumer) expect(root_span.attributes["appsignal.namespace"]) - .to eq(Appsignal::Transaction::BACKGROUND_JOB) + .to eq("background") expect(root_span.name).to eq("DemoShoryukenWorker#perform") expect(root_span.attributes["appsignal.action_name"]) .to eq("DemoShoryukenWorker#perform") @@ -204,7 +204,7 @@ def perform expect(root_span.attributes["appsignal.action_name"]) .to eq("DemoShoryukenWorker#perform") expect(root_span.attributes["appsignal.namespace"]) - .to eq(Appsignal::Transaction::BACKGROUND_JOB) + .to eq("background") error_event = exception_events .find { |e| e.attributes["exception.type"] == "ExampleException" } @@ -283,7 +283,7 @@ def perform expect(root_span.attributes["appsignal.action_name"]) .to eq("DemoShoryukenWorker#perform") expect(root_span.attributes["appsignal.namespace"]) - .to eq(Appsignal::Transaction::BACKGROUND_JOB) + .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 diff --git a/spec/lib/appsignal/integrations/sidekiq_spec.rb b/spec/lib/appsignal/integrations/sidekiq_spec.rb index cc245a3e7..a25204f7d 100644 --- a/spec/lib/appsignal/integrations/sidekiq_spec.rb +++ b/spec/lib/appsignal/integrations/sidekiq_spec.rb @@ -711,7 +711,7 @@ def perform perform expect(root_span.kind).to eq(:consumer) - expect(root_span.attributes["appsignal.namespace"]).to eq(namespace) + 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 @@ -772,7 +772,7 @@ def perform perform expect(root_span.kind).to eq(:consumer) - expect(root_span.attributes["appsignal.namespace"]).to eq("background_job") + 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" } @@ -827,7 +827,7 @@ def perform perform expect(root_span.kind).to eq(:consumer) - expect(root_span.attributes["appsignal.namespace"]).to eq(namespace) + 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) @@ -995,7 +995,7 @@ def perform perform expect(root_span.kind).to eq(:consumer) - expect(root_span.attributes["appsignal.namespace"]).to eq(namespace) + 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 @@ -1045,7 +1045,7 @@ def perform perform expect(root_span.kind).to eq(:consumer) - expect(root_span.attributes["appsignal.namespace"]).to eq(namespace) + 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 diff --git a/spec/lib/appsignal/rack/abstract_middleware_spec.rb b/spec/lib/appsignal/rack/abstract_middleware_spec.rb index 8da5886d7..737fd498f 100644 --- a/spec/lib/appsignal/rack/abstract_middleware_spec.rb +++ b/spec/lib/appsignal/rack/abstract_middleware_spec.rb @@ -56,7 +56,7 @@ def perform expect { perform }.to(change { created_transactions.count }.by(1)) expect(root_span.attributes["appsignal.namespace"]) - .to eq(Appsignal::Transaction::HTTP_REQUEST) + .to eq("web") expect(root_span.kind).to eq(:server) end end @@ -199,7 +199,7 @@ def perform expect { perform }.to(change { created_transactions.count }.by(1)) expect(root_span.attributes["appsignal.namespace"]) - .to eq(Appsignal::Transaction::HTTP_REQUEST) + .to eq("web") end end diff --git a/spec/lib/appsignal/rack/event_handler_spec.rb b/spec/lib/appsignal/rack/event_handler_spec.rb index 788929905..10c83e594 100644 --- a/spec/lib/appsignal/rack/event_handler_spec.rb +++ b/spec/lib/appsignal/rack/event_handler_spec.rb @@ -103,7 +103,7 @@ def perform # opened with. Appsignal::Transaction.complete_current! expect(root_span.attributes["appsignal.namespace"]) - .to eq(Appsignal::Transaction::HTTP_REQUEST) + .to eq("web") expect(root_span.kind).to eq(:server) end end diff --git a/spec/lib/appsignal/rack/rails_instrumentation_spec.rb b/spec/lib/appsignal/rack/rails_instrumentation_spec.rb index ec5ebde68..55850bee7 100644 --- a/spec/lib/appsignal/rack/rails_instrumentation_spec.rb +++ b/spec/lib/appsignal/rack/rails_instrumentation_spec.rb @@ -166,7 +166,7 @@ def perform expect(root_span.kind).to eq(:server) expect(root_span.attributes["appsignal.namespace"]) - .to eq(Appsignal::Transaction::HTTP_REQUEST) + .to eq("web") expect(root_span.name).to eq("MockController#index") expect(root_span.attributes["appsignal.action_name"]).to eq("MockController#index") end diff --git a/spec/lib/appsignal/rack/sinatra_instrumentation_spec.rb b/spec/lib/appsignal/rack/sinatra_instrumentation_spec.rb index dda37120e..343ea40ce 100644 --- a/spec/lib/appsignal/rack/sinatra_instrumentation_spec.rb +++ b/spec/lib/appsignal/rack/sinatra_instrumentation_spec.rb @@ -121,7 +121,7 @@ def perform expect { perform }.to(change { created_transactions.count }.by(1)) expect(root_span.attributes["appsignal.namespace"]) - .to eq(Appsignal::Transaction::HTTP_REQUEST) + .to eq("web") expect(root_span.kind).to eq(:server) end end @@ -170,7 +170,7 @@ def perform expect { perform }.to(change { created_transactions.count }.by(1)) expect(root_span.attributes["appsignal.namespace"]) - .to eq(Appsignal::Transaction::HTTP_REQUEST) + .to eq("web") end end diff --git a/spec/lib/appsignal_spec.rb b/spec/lib/appsignal_spec.rb index 7d1bd8e6d..b7b3924d7 100644 --- a/spec/lib/appsignal_spec.rb +++ b/spec/lib/appsignal_spec.rb @@ -1175,7 +1175,7 @@ def perform # 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(Appsignal::Transaction::HTTP_REQUEST) + .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 @@ -1409,7 +1409,7 @@ def perform transaction.complete expect(root_span.attributes["appsignal.namespace"]) - .to eq(Appsignal::Transaction::HTTP_REQUEST) + .to eq("web") end end @@ -1943,7 +1943,7 @@ def perform # 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(Appsignal::Transaction::HTTP_REQUEST) + .to eq("web") expect(root_span.attributes).not_to have_key("appsignal.action_name") event = root_span.events.find { |e| e.name == "exception" } From cc42c5478b22a3b6db42cc6515a707491b290215 Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Tue, 23 Jun 2026 20:41:19 +0200 Subject: [PATCH 092/151] Propagate trace context on Net::HTTP requests In collector mode, outgoing Net::HTTP requests now carry a W3C traceparent header so the called service joins the same trace, the same way OpenTelemetry's own instrumentation does. This also adds the shared pieces the other integrations will reuse: an `opentelemetry_kind:` keyword threaded from `Appsignal.instrument` down to the backend, so an event span can be marked as a client span, and a collector-mode-gated helper that injects the current context onto an outgoing carrier. Both are no-ops in agent mode. --- lib/appsignal/helpers/instrumentation.rb | 10 +++++++- lib/appsignal/integrations/net_http.rb | 7 +++++- lib/appsignal/opentelemetry.rb | 16 +++++++++++++ lib/appsignal/transaction.rb | 14 +++++++---- lib/appsignal/transaction/base_backend.rb | 2 +- .../transaction/extension_backend.rb | 3 ++- .../transaction/opentelemetry_backend.rb | 7 ++++-- sig/appsignal.rbi | 6 +++-- sig/appsignal.rbs | 6 +++-- .../appsignal/integrations/net_http_spec.rb | 19 +++++++++++++++ .../transaction/opentelemetry_backend_spec.rb | 24 +++++++++++++++++++ spec/lib/appsignal/transaction_spec.rb | 10 +++++++- 12 files changed, 109 insertions(+), 15 deletions(-) diff --git a/lib/appsignal/helpers/instrumentation.rb b/lib/appsignal/helpers/instrumentation.rb index 3748e3a6a..cb7ef2f34 100644 --- a/lib/appsignal/helpers/instrumentation.rb +++ b/lib/appsignal/helpers/instrumentation.rb @@ -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/integrations/net_http.rb b/lib/appsignal/integrations/net_http.rb index 731085b91..7dba3fac8 100644 --- a/lib/appsignal/integrations/net_http.rb +++ b/lib/appsignal/integrations/net_http.rb @@ -14,8 +14,13 @@ def request(request, body = nil, &block) Appsignal.instrument( "request.net_http", - "#{request.method} #{use_ssl? ? "https" : "http"}://#{request["host"] || address}" + "#{request.method} #{use_ssl? ? "https" : "http"}://#{request["host"] || address}", + :opentelemetry_kind => :client ) do + # Write trace context onto the outgoing request so the called service + # joins this trace. No-op outside collector mode. The request object + # is a valid carrier (it responds to `[]=`). + Appsignal::OpenTelemetry.inject_context(request) super end end diff --git a/lib/appsignal/opentelemetry.rb b/lib/appsignal/opentelemetry.rb index 4e383fff8..11fb435f3 100644 --- a/lib/appsignal/opentelemetry.rb +++ b/lib/appsignal/opentelemetry.rb @@ -114,6 +114,22 @@ def started? defined?(@started) ? @started : false end + # Write the current trace context onto an outgoing carrier (HTTP request, + # job hash, ...) using the globally configured propagator (W3C + # TraceContext + baggage). Called by integrations on the emit side so a + # downstream service joins the same trace. + # + # No-op unless the SDK has booted ({.started?}); outside collector mode + # there is no context to propagate. The carrier is injected from whatever + # span is current at call time -- inside an `Appsignal.instrument` block + # that is the AppSignal event span, so the written `traceparent` reflects + # it. + def inject_context(carrier) + return unless started? + + ::OpenTelemetry.propagation.inject(carrier) + end + # @!visibility private # # Test-only. Drops the started flag so subsequent tests start from a diff --git a/lib/appsignal/transaction.rb b/lib/appsignal/transaction.rb index 200db6845..7180e9626 100644 --- a/lib/appsignal/transaction.rb +++ b/lib/appsignal/transaction.rb @@ -639,10 +639,10 @@ def add_error(error, &block) # @!visibility private # @see Helpers::Instrumentation#instrument - def start_event + def start_event(opentelemetry_kind: nil) return if paused? - @backend.start_event + @backend.start_event(:opentelemetry_kind => opentelemetry_kind) end # @!visibility private @@ -674,8 +674,14 @@ def record_event(name, title, body, duration, body_format = Appsignal::EventForm # @!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) diff --git a/lib/appsignal/transaction/base_backend.rb b/lib/appsignal/transaction/base_backend.rb index 4dc8f339c..10c05f2be 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 + def start_event(opentelemetry_kind: nil) raise NotImplementedError end diff --git a/lib/appsignal/transaction/extension_backend.rb b/lib/appsignal/transaction/extension_backend.rb index a9f06c956..5c2aeef26 100644 --- a/lib/appsignal/transaction/extension_backend.rb +++ b/lib/appsignal/transaction/extension_backend.rb @@ -28,7 +28,8 @@ def initialize(transaction_id, namespace, handle: nil) @breadcrumbs = [] end - def start_event + # 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 diff --git a/lib/appsignal/transaction/opentelemetry_backend.rb b/lib/appsignal/transaction/opentelemetry_backend.rb index a0702fc90..c54236bf6 100644 --- a/lib/appsignal/transaction/opentelemetry_backend.rb +++ b/lib/appsignal/transaction/opentelemetry_backend.rb @@ -80,8 +80,11 @@ def initialize(transaction_id, namespace, **) @span.set_attribute("appsignal.namespace", display_namespace(namespace)) if namespace end - def start_event - span = tracer.start_span(EVENT_SPAN_PLACEHOLDER_NAME) + # `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) ) diff --git a/sig/appsignal.rbi b/sig/appsignal.rbi index 40530e2dd..6103cc763 100644 --- a/sig/appsignal.rbi +++ b/sig/appsignal.rbi @@ -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. # @@ -2574,10 +2575,11 @@ module Appsignal title: T.nilable(String), body: T.nilable(String), body_format: Integer, + opentelemetry_kind: T.untyped, block: T.untyped ).returns(Object) end - def instrument(name, title = nil, body = nil, body_format = Appsignal::EventFormatter::DEFAULT, &block); end + def instrument(name, title = nil, body = nil, body_format = Appsignal::EventFormatter::DEFAULT, opentelemetry_kind: nil, &block); end # Instrumentation helper for SQL queries. # diff --git a/sig/appsignal.rbs b/sig/appsignal.rbs index 3e2e2b9d3..a1c210a48 100644 --- a/sig/appsignal.rbs +++ b/sig/appsignal.rbs @@ -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. @@ -2405,7 +2406,8 @@ module Appsignal String name, ?String? title, ?String? body, - ?Integer body_format + ?Integer body_format, + ?opentelemetry_kind: untyped ) -> Object # Instrumentation helper for SQL queries. diff --git a/spec/lib/appsignal/integrations/net_http_spec.rb b/spec/lib/appsignal/integrations/net_http_spec.rb index ecd28bba7..76238c2f0 100644 --- a/spec/lib/appsignal/integrations/net_http_spec.rb +++ b/spec/lib/appsignal/integrations/net_http_spec.rb @@ -31,9 +31,15 @@ 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.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 @@ -70,9 +76,22 @@ 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.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 + + # Reads the `traceparent` header off the recorded outgoing request to `url`. + def injected_traceparent(url) + traceparent = nil + expect( + a_request(:get, url).with { |request| traceparent = request.headers["Traceparent"] } + ).to have_been_made + traceparent + end end diff --git a/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb b/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb index 1d0c3242c..7f6e81026 100644 --- a/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb +++ b/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb @@ -156,6 +156,30 @@ def event_names(finished) 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 } diff --git a/spec/lib/appsignal/transaction_spec.rb b/spec/lib/appsignal/transaction_spec.rb index 7484b08d2..d08025d7b 100644 --- a/spec/lib/appsignal/transaction_spec.rb +++ b/spec/lib/appsignal/transaction_spec.rb @@ -4283,11 +4283,19 @@ def exception_event let(:transaction) { new_transaction } it "starts the event in the extension" do - expect(transaction.backend).to receive(:start_event).with(no_args).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! From 835f993e4d88c285f6f3f187f178a0d6dca028fa Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Tue, 23 Jun 2026 21:13:19 +0200 Subject: [PATCH 093/151] Read incoming trace context in web requests In collector mode, a web request that arrives with a W3C traceparent now continues that trace: the transaction's root span is parented under the upstream span instead of starting its own. This is the read-side counterpart to outgoing Net::HTTP propagation. The Rack middleware and event handler extract the context from the request and pass it to the transaction through a new `opentelemetry_context:` keyword, which the backend uses as the root span's parent. One chokepoint covers Rails, Sinatra, Padrino, Grape and Hanami. Background jobs, which link rather than parent, come later. opentelemetry-common, which provides the Rack header getter, becomes an explicit collector-mode dependency. --- lib/appsignal/opentelemetry.rb | 36 ++++++++++++--- lib/appsignal/opentelemetry/dependencies.rb | 1 + lib/appsignal/rack/abstract_middleware.rb | 5 ++- lib/appsignal/rack/event_handler.rb | 5 ++- lib/appsignal/transaction.rb | 14 ++++-- .../transaction/extension_backend.rb | 4 +- .../transaction/opentelemetry_backend.rb | 37 ++++++++++++---- sig/appsignal.rbi | 4 +- sig/appsignal.rbs | 2 +- spec/lib/appsignal/opentelemetry_spec.rb | 21 +++++++++ .../rack/abstract_middleware_spec.rb | 21 +++++++++ spec/lib/appsignal/rack/event_handler_spec.rb | 23 ++++++++++ .../transaction/opentelemetry_backend_spec.rb | 44 +++++++++++++++++++ 13 files changed, 193 insertions(+), 24 deletions(-) diff --git a/lib/appsignal/opentelemetry.rb b/lib/appsignal/opentelemetry.rb index 11fb435f3..c5e7c7867 100644 --- a/lib/appsignal/opentelemetry.rb +++ b/lib/appsignal/opentelemetry.rb @@ -36,12 +36,7 @@ def configure(config) ENV["OTEL_METRICS_EXPORTER"] = "none" ENV["OTEL_LOGS_EXPORTER"] = "none" - require "opentelemetry/sdk" - require "opentelemetry/exporter/otlp" - require "opentelemetry-metrics-sdk" - require "opentelemetry-exporter-otlp-metrics" - require "opentelemetry-logs-sdk" - require "opentelemetry-exporter-otlp-logs" + 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 @@ -130,6 +125,22 @@ def inject_context(carrier) ::OpenTelemetry.propagation.inject(carrier) 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) + return unless started? + + ::OpenTelemetry.propagation.extract( + env, + :getter => ::OpenTelemetry::Common::Propagation.rack_env_getter + ) + end + # @!visibility private # # Test-only. Drops the started flag so subsequent tests start from a @@ -192,6 +203,19 @@ def build_resource(config) private + # 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. diff --git a/lib/appsignal/opentelemetry/dependencies.rb b/lib/appsignal/opentelemetry/dependencies.rb index 4e10cb61c..ce13ebfd4 100644 --- a/lib/appsignal/opentelemetry/dependencies.rb +++ b/lib/appsignal/opentelemetry/dependencies.rb @@ -23,6 +23,7 @@ module OpenTelemetry # 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", 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 7180e9626..86b9dd7b4 100644 --- a/lib/appsignal/transaction.rb +++ b/lib/appsignal/transaction.rb @@ -29,7 +29,7 @@ class << self # # @param namespace [String] Namespace of the to be created transaction. # @return [Transaction] - def create(namespace) + def create(namespace, opentelemetry_context: nil) # Reset the transaction if it was already completed but not cleared if Thread.current[:appsignal_transaction]&.completed? Thread.current[:appsignal_transaction] = nil @@ -37,7 +37,12 @@ def create(namespace) if Thread.current[:appsignal_transaction].nil? # If not, start a new transaction - set_current_transaction(Appsignal::Transaction.new(namespace)) + set_current_transaction( + Appsignal::Transaction.new( + namespace, + :opentelemetry_context => opentelemetry_context + ) + ) else transaction = current # Otherwise, log the issue about trying to start another transaction @@ -160,7 +165,7 @@ def last_errors # @param namespace [String] Namespace of the to be created transaction. # @see create # @!visibility private - def initialize(namespace, id: SecureRandom.uuid, backend: nil) + def initialize(namespace, id: SecureRandom.uuid, backend: nil, opentelemetry_context: nil) @transaction_id = id @action = nil @namespace = namespace @@ -180,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 5c2aeef26..027e393e4 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 c54236bf6..786c809e5 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 @@ -64,13 +64,8 @@ def initialize(transaction_id, namespace, **) @queue_start = nil @start_time = Time.now - # A transaction is its own unit of work, so start a root span that - # ignores any ambient OTel context instead of nesting under an - # unrelated current span. - @span = tracer.start_root_span( - placeholder_span_name(namespace), - :kind => SPAN_KIND_BY_NAMESPACE.fetch(namespace, DEFAULT_SPAN_KIND) - ) + kind = SPAN_KIND_BY_NAMESPACE.fetch(namespace, DEFAULT_SPAN_KIND) + @span = start_transaction_span(namespace, kind, opentelemetry_context) @context_token = ::OpenTelemetry::Context.attach( ::OpenTelemetry::Trace.context_with_span(@span) ) @@ -337,6 +332,32 @@ def placeholder_span_name(namespace) "appsignal.transaction #{namespace}" end + # Open the transaction's root span. When the request carried an incoming + # trace context (a SERVER-kind unit of work continuing an upstream trace), + # parent under it so the transaction joins that trace. Otherwise -- no + # context, an invalid remote span, or a CONSUMER-kind unit of work (jobs, + # which link rather than parent; handled later) -- start a fresh 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) + if parent_under?(opentelemetry_context, kind) + tracer.start_span( + placeholder_span_name(namespace), + :with_parent => opentelemetry_context, + :kind => kind + ) + else + tracer.start_root_span(placeholder_span_name(namespace), :kind => kind) + end + end + + def parent_under?(opentelemetry_context, kind) + return false unless opentelemetry_context + return false if kind == :consumer + + ::OpenTelemetry::Trace.current_span(opentelemetry_context).context.valid? + end + def display_namespace(namespace) DISPLAY_NAMESPACE.fetch(namespace, namespace) end diff --git a/sig/appsignal.rbi b/sig/appsignal.rbi index 6103cc763..c7ea4c8a6 100644 --- a/sig/appsignal.rbi +++ b/sig/appsignal.rbi @@ -1659,8 +1659,8 @@ module Appsignal # transaction. # # _@param_ `namespace` — Namespace of the to be created transaction. - sig { params(namespace: String).returns(Transaction) } - def self.create(namespace); end + sig { params(namespace: String, opentelemetry_context: T.untyped).returns(Transaction) } + def self.create(namespace, opentelemetry_context: nil); end # Returns currently active transaction or a {NilTransaction} if none is # active. diff --git a/sig/appsignal.rbs b/sig/appsignal.rbs index a1c210a48..e5eea8673 100644 --- a/sig/appsignal.rbs +++ b/sig/appsignal.rbs @@ -1530,7 +1530,7 @@ module Appsignal # transaction. # # _@param_ `namespace` — Namespace of the to be created transaction. - def self.create: (String namespace) -> Transaction + def self.create: (String namespace, ?opentelemetry_context: untyped) -> Transaction # Returns currently active transaction or a {NilTransaction} if none is # active. diff --git a/spec/lib/appsignal/opentelemetry_spec.rb b/spec/lib/appsignal/opentelemetry_spec.rb index 5d31e7ab9..25a9f6985 100644 --- a/spec/lib/appsignal/opentelemetry_spec.rb +++ b/spec/lib/appsignal/opentelemetry_spec.rb @@ -212,6 +212,27 @@ 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 ".build_resource" do it "maps AppSignal config attributes onto the resource" do resource = described_class.build_resource( 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..2b7f503dc 100644 --- a/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb +++ b/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb @@ -89,6 +89,50 @@ 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 + ::OpenTelemetry.propagation.extract( + { "traceparent" => "00-#{trace_id_hex}-#{span_id_hex}-01" } + ) + 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 "does not parent a consumer transaction (jobs link instead; Phase 3)" do + backend = create_backend_with_context("background_job", remote_context) + 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.kind).to eq(:consumer) + end + end + it "attaches the new span as the OpenTelemetry current span" do backend = create_backend expect(::OpenTelemetry::Trace.current_span) From e88f60d10bdf721411eb92f17049dda6915574bf Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Tue, 23 Jun 2026 20:59:48 +0200 Subject: [PATCH 094/151] Bind the OTLP mock server to a random port The collector-mode integration tests' mock server bound a fixed port, which collides when two suite runs share a machine. Bind to an OS-assigned free port and read it back for the endpoint instead. --- spec/spec_helper.rb | 4 +++- spec/support/helpers/otlp_collector_server.rb | 14 +++++++++++--- spec/support/shared_contexts/collector_mode.rb | 3 ++- 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 9eaa71a87..b3ebcbcae 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -104,8 +104,10 @@ def spec_system_tmp_dir # connections; when those specs run, relax the rule just enough to reach the # mock server bound by `OTLPCollectorServer`. if DependencyHelper.opentelemetry_present? - WebMock.disable_net_connect!(:allow => "127.0.0.1:#{OTLPCollectorServer::PORT}") + # 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 diff --git a/spec/support/helpers/otlp_collector_server.rb b/spec/support/helpers/otlp_collector_server.rb index 3c391e1a5..db4ef05ff 100644 --- a/spec/support/helpers/otlp_collector_server.rb +++ b/spec/support/helpers/otlp_collector_server.rb @@ -16,17 +16,22 @@ # 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 - PORT = 9090 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}" + "http://127.0.0.1:#{port}" end # Env vars that put a spawned runner into collector mode, pointed at this @@ -51,7 +56,10 @@ def clear def boot! return if @booted - @server = TCPServer.new("127.0.0.1", PORT) + # 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 diff --git a/spec/support/shared_contexts/collector_mode.rb b/spec/support/shared_contexts/collector_mode.rb index 8fdfc48c5..bed7692d1 100644 --- a/spec/support/shared_contexts/collector_mode.rb +++ b/spec/support/shared_contexts/collector_mode.rb @@ -70,7 +70,8 @@ # group's own `let` override. def start_collector_agent args = (defined?(start_agent_args) ? start_agent_args : {}).dup - args[:options] = { :collector_endpoint => "http://127.0.0.1:9090" }.merge(args[:options] || {}) + 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 From 51f6df40daaefea8f0ddc28f27aa07fde27ff402 Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Tue, 23 Jun 2026 21:29:56 +0200 Subject: [PATCH 095/151] Link background jobs back to the enqueuing trace A consumer transaction (a background job) is its own unit of work, so it starts a new trace rather than continuing the enqueuer's. When the job carries an incoming trace context, record the relationship with a span link instead of a parent, matching how OpenTelemetry instruments jobs. Web transactions keep parenting under the incoming context. --- .../transaction/opentelemetry_backend.rb | 46 ++++++++++++------- .../transaction/opentelemetry_backend_spec.rb | 18 +++++++- 2 files changed, 46 insertions(+), 18 deletions(-) diff --git a/lib/appsignal/transaction/opentelemetry_backend.rb b/lib/appsignal/transaction/opentelemetry_backend.rb index 786c809e5..51267dd60 100644 --- a/lib/appsignal/transaction/opentelemetry_backend.rb +++ b/lib/appsignal/transaction/opentelemetry_backend.rb @@ -332,30 +332,42 @@ def placeholder_span_name(namespace) "appsignal.transaction #{namespace}" end - # Open the transaction's root span. When the request carried an incoming - # trace context (a SERVER-kind unit of work continuing an upstream trace), - # parent under it so the transaction joins that trace. Otherwise -- no - # context, an invalid remote span, or a CONSUMER-kind unit of work (jobs, - # which link rather than parent; handled later) -- start a fresh root span - # that ignores any ambient OTel context, as a transaction is its own unit - # of work. + # 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) - if parent_under?(opentelemetry_context, kind) - tracer.start_span( - placeholder_span_name(namespace), - :with_parent => opentelemetry_context, - :kind => kind + 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(placeholder_span_name(namespace), :kind => kind) + tracer.start_root_span(name, :kind => kind) end end - def parent_under?(opentelemetry_context, kind) - return false unless opentelemetry_context - return false if kind == :consumer + # 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 - ::OpenTelemetry::Trace.current_span(opentelemetry_context).context.valid? + context = ::OpenTelemetry::Trace.current_span(opentelemetry_context).context + context if context.valid? end def display_namespace(namespace) diff --git a/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb b/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb index 2b7f503dc..a1c91d98f 100644 --- a/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb +++ b/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb @@ -122,14 +122,30 @@ def create_backend_with_context(namespace, context) expect(root.parent_span_id).to eq(::OpenTelemetry::Trace::INVALID_SPAN_ID) end - it "does not parent a consumer transaction (jobs link instead; Phase 3)" do + 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 From 1e27466f0c33e890236afe03f7597f0d15b284c9 Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Tue, 23 Jun 2026 21:31:57 +0200 Subject: [PATCH 096/151] Link Sidekiq jobs back to the enqueuing trace On perform, read the trace context off the job and pass it to the transaction so the job's trace links back to the span that enqueued it. Reads both carriers a job can arrive with: top-level traceparent keys and a nested __otel_headers hash, matching OpenTelemetry's Sidekiq and ActiveJob instrumentations. Excludes the trace headers from the job metadata so they don't surface as transaction tags. --- lib/appsignal/integrations/sidekiq.rb | 9 +++- lib/appsignal/opentelemetry.rb | 18 +++++++ .../appsignal/integrations/sidekiq_spec.rb | 53 +++++++++++++++++++ 3 files changed, 78 insertions(+), 2 deletions(-) diff --git a/lib/appsignal/integrations/sidekiq.rb b/lib/appsignal/integrations/sidekiq.rb index 98a8e0ab1..4809ecb8a 100644 --- a/lib/appsignal/integrations/sidekiq.rb +++ b/lib/appsignal/integrations/sidekiq.rb @@ -76,7 +76,7 @@ class SidekiqMiddleware EXCLUDED_JOB_KEYS = %w[ args backtrace class created_at enqueued_at error_backtrace error_class error_message failed_at jid retried_at retry wrapped cattr tags retry_for - unique_for + unique_for traceparent tracestate __otel_headers ].freeze def self.sidekiq8? @@ -88,7 +88,12 @@ def self.sidekiq8? def call(_worker, item, _queue, &block) job_status = nil action_name = formatted_action_name(item) - transaction = Appsignal::Transaction.create(Appsignal::Transaction::BACKGROUND_JOB) + # Read trace context off the job so the transaction links back to the + # enqueuer. No-op outside collector mode. + transaction = Appsignal::Transaction.create( + Appsignal::Transaction::BACKGROUND_JOB, + :opentelemetry_context => Appsignal::OpenTelemetry.extract_job_context(item) + ) transaction.set_action_if_nil(action_name) formatted_metadata(item).each do |key, value| diff --git a/lib/appsignal/opentelemetry.rb b/lib/appsignal/opentelemetry.rb index c5e7c7867..70bcb991e 100644 --- a/lib/appsignal/opentelemetry.rb +++ b/lib/appsignal/opentelemetry.rb @@ -141,6 +141,24 @@ def extract_rack_context(env) ) end + # Read the trace context off an incoming background job hash, so a + # transaction created for the job can link back to the enqueuer. Returns + # an `OpenTelemetry::Context`, or `nil` when the SDK has not booted. + # + # Reads both carriers a job can arrive with: top-level `traceparent` / + # `tracestate` keys (how OpenTelemetry's Sidekiq instrumentation injects) + # and a nested `__otel_headers` hash (how its ActiveJob instrumentation + # does). The nested keys win when both are present, since ActiveJob is the + # outer, more specific layer. + def extract_job_context(item) + return unless started? + + carrier = item + nested = item["__otel_headers"] + carrier = item.merge(nested) if nested.is_a?(Hash) + ::OpenTelemetry.propagation.extract(carrier) + end + # @!visibility private # # Test-only. Drops the started flag so subsequent tests start from a diff --git a/spec/lib/appsignal/integrations/sidekiq_spec.rb b/spec/lib/appsignal/integrations/sidekiq_spec.rb index a25204f7d..43f810d4c 100644 --- a/spec/lib/appsignal/integrations/sidekiq_spec.rb +++ b/spec/lib/appsignal/integrations/sidekiq_spec.rb @@ -451,6 +451,59 @@ def expect_no_yaml_parse_error(logs) 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 + 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"] } } From 29854ab7332a172d8781e886284b1057181925d8 Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Thu, 25 Jun 2026 14:49:49 +0200 Subject: [PATCH 097/151] Record Sidekiq enqueues as an AppSignal event Add a Sidekiq client middleware that records an enqueue_job.sidekiq event, so enqueuing shows up under the active transaction in both agent and collector mode. In collector mode the event span carries producer kind and the trace context is written onto the job, so the job that later performs links back to it. Like any AppSignal event, this only records when a transaction is active (enqueuing from a web request or another job). An enqueue with no transaction is a transparent pass-through and does not propagate. --- lib/appsignal/hooks/sidekiq.rb | 2 +- lib/appsignal/integrations/sidekiq.rb | 23 ++++--- spec/lib/appsignal/hooks/sidekiq_spec.rb | 25 ++------ .../appsignal/integrations/sidekiq_spec.rb | 62 ++++++++++++------- 4 files changed, 56 insertions(+), 56 deletions(-) diff --git a/lib/appsignal/hooks/sidekiq.rb b/lib/appsignal/hooks/sidekiq.rb index f78a0839f..62f25912a 100644 --- a/lib/appsignal/hooks/sidekiq.rb +++ b/lib/appsignal/hooks/sidekiq.rb @@ -44,7 +44,7 @@ def install end # Servers enqueue jobs too, so they need the client middleware that - # records the enqueue event. + # writes the trace context onto outgoing jobs. config.client_middleware do |chain| chain.add Appsignal::Integrations::SidekiqClientMiddleware end diff --git a/lib/appsignal/integrations/sidekiq.rb b/lib/appsignal/integrations/sidekiq.rb index 4809ecb8a..3195b9520 100644 --- a/lib/appsignal/integrations/sidekiq.rb +++ b/lib/appsignal/integrations/sidekiq.rb @@ -49,23 +49,22 @@ def call(exception, sidekiq_context, _sidekiq_config = nil) end end - # Sidekiq client middleware that runs on enqueue. Records an - # `enqueue.sidekiq` event so the enqueue shows up under the active - # transaction. + # Client middleware that runs on enqueue. Records an `enqueue_job.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) - # 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? - - Appsignal.instrument("enqueue.sidekiq", "enqueue #{job["class"]} job", &block) + def call(_worker_class, job, _queue, _redis_pool) + Appsignal.instrument("enqueue_job.sidekiq", :opentelemetry_kind => :producer) do + Appsignal::OpenTelemetry.inject_context(job) + yield + end end end diff --git a/spec/lib/appsignal/hooks/sidekiq_spec.rb b/spec/lib/appsignal/hooks/sidekiq_spec.rb index 7a2ab9c27..8160cab51 100644 --- a/spec/lib/appsignal/hooks/sidekiq_spec.rb +++ b/spec/lib/appsignal/hooks/sidekiq_spec.rb @@ -16,14 +16,8 @@ end describe "#install" do - # Mirror Sidekiq's own middleware chain, which removes any existing entry - # for a class before adding it, so a class is only ever present once even - # if it's registered from more than one config block. class SidekiqMiddlewareMockWithPrepend < Array - def add(middleware) - delete(middleware) - push(middleware) - end + alias add << alias exists? include? unless method_defined? :prepend @@ -35,10 +29,7 @@ def prepend(middleware) end class SidekiqMiddlewareMockWithoutPrepend < Array - def add(middleware) - delete(middleware) - push(middleware) - end + alias add << alias exists? include? undef_method :prepend if method_defined? :prepend # For Ruby >= 2.5 @@ -97,17 +88,11 @@ def add_middleware(middleware) expect(Sidekiq.error_handlers).to include(Appsignal::Integrations::SidekiqErrorHandler) end - it "adds the AppSignal client middleware to the client middleware chain exactly once" do + it "adds the AppSignal client middleware to the client middleware chain" do Sidekiq.middleware_mock = SidekiqMiddlewareMockWithPrepend described_class.new.install - - # The client middleware is registered from both the server and client - # config blocks. Sidekiq only runs one of them per process and dedupes - # by class, so it must end up registered exactly once. - registrations = Sidekiq.client_middleware.count do |middleware| - middleware == Appsignal::Integrations::SidekiqClientMiddleware - end - expect(registrations).to eq(1) + expect(Sidekiq.client_middleware) + .to include(Appsignal::Integrations::SidekiqClientMiddleware) end context "when Sidekiq middleware responds to prepend method" do # Sidekiq 3.3.0 and newer diff --git a/spec/lib/appsignal/integrations/sidekiq_spec.rb b/spec/lib/appsignal/integrations/sidekiq_spec.rb index 43f810d4c..28d7c2cfe 100644 --- a/spec/lib/appsignal/integrations/sidekiq_spec.rb +++ b/spec/lib/appsignal/integrations/sidekiq_spec.rb @@ -327,48 +327,62 @@ def perform 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. - 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") + # Records an enqueue event on the transaction; no wire context in agent mode. + event_names = transaction.to_h["events"].map { |event| event["name"] } + expect(event_names).to include("enqueue_job.sidekiq") + expect(job).to_not have_key("traceparent") end - end - context "without an active transaction" do - it "passes through without recording" do - expect { |block| plugin.call("TestClass", job, "default", nil, &block) } - .to yield_control + it "in 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. + producer = event_spans.find { |s| s.name == "enqueue_job.sidekiq" } + expect(producer.kind).to eq(:producer) + expect(producer.parent_span_id).to eq(root_span.span_id) - expect(created_transactions).to be_empty + # The job carries the producer span's trace context, so the job that + # performs can link back to it. + expect(job["traceparent"]) + .to eq("00-#{producer.hex_trace_id}-#{producer.hex_span_id}-01") end end - context "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) + 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(:enqueued) + expect(job).to_not have_key("traceparent") + end - result = transaction.suppress_job_enqueue_events { enqueue } - expect(result).to eq(:enqueued) + it "in collector mode", :collector_mode do + start_collector_agent - # 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") + # 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_job.sidekiq") + expect(job).to_not have_key("traceparent") end end end @@ -1058,6 +1072,8 @@ def perform 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 From 681a07085121778f3f084e1f645754897da3fc3b Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Thu, 25 Jun 2026 15:05:10 +0200 Subject: [PATCH 098/151] Build remote parent context in the backend spec The backend unit test built its remote parent context by parsing a traceparent through the global OpenTelemetry propagator. That global is only installed as a side effect of booting the SDK, so the test passed only when some other example had booted first, and failed when the file ran alone. Build the context directly instead. The backend's job is to parent under a context it is given, not to extract one, so the test no longer depends on any global state. --- .../transaction/opentelemetry_backend_spec.rb | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb b/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb index a1c91d98f..081744c87 100644 --- a/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb +++ b/spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb @@ -93,8 +93,21 @@ def event_names(finished) let(:trace_id_hex) { "0af7651916cd43dd8448eb211c80319c" } let(:span_id_hex) { "b7ad6b7169203331" } let(:remote_context) do - ::OpenTelemetry.propagation.extract( - { "traceparent" => "00-#{trace_id_hex}-#{span_id_hex}-01" } + # 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 From 3fe676d201a4f26140c040f3d91f2e8a55f5959c Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Thu, 25 Jun 2026 15:05:10 +0200 Subject: [PATCH 099/151] Reset the OTel propagator after collector specs Booting the SDK installs the global W3C propagator as a side effect and nothing ever resets it, so it leaked from collector-mode examples into the rest of the suite. That let unrelated specs pass on a propagator they never set up themselves, an order-dependency that broke when those specs ran alone. Reset it to the API default in the collector-mode teardown. --- spec/support/shared_contexts/collector_mode.rb | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/spec/support/shared_contexts/collector_mode.rb b/spec/support/shared_contexts/collector_mode.rb index bed7692d1..a5a796e7c 100644 --- a/spec/support/shared_contexts/collector_mode.rb +++ b/spec/support/shared_contexts/collector_mode.rb @@ -58,6 +58,13 @@ # `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. From 0f51a9dc924196da1be1cb297a12ad4ef0a9d4ed Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Thu, 25 Jun 2026 16:31:35 +0200 Subject: [PATCH 100/151] Read serialized __otel_headers job context OpenTelemetry's ActiveJob instrumentation serializes the trace headers with ActiveJob's argument serializer, so __otel_headers arrives on the job as an array of [key, value] pairs, not a hash. The extract path only recognized a hash, so it silently dropped the real carrier. Accept the array shape too. The spec was exercising an unrealistic hash fixture, so it passed without covering this; it now uses the real array form. --- lib/appsignal/opentelemetry.rb | 17 ++++++++++++++--- spec/lib/appsignal/integrations/sidekiq_spec.rb | 5 ++++- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/lib/appsignal/opentelemetry.rb b/lib/appsignal/opentelemetry.rb index 70bcb991e..ed29a3b0f 100644 --- a/lib/appsignal/opentelemetry.rb +++ b/lib/appsignal/opentelemetry.rb @@ -147,14 +147,17 @@ def extract_rack_context(env) # # Reads both carriers a job can arrive with: top-level `traceparent` / # `tracestate` keys (how OpenTelemetry's Sidekiq instrumentation injects) - # and a nested `__otel_headers` hash (how its ActiveJob instrumentation - # does). The nested keys win when both are present, since ActiveJob is the - # outer, more specific layer. + # and a nested `__otel_headers` (how its ActiveJob instrumentation does). + # ActiveJob runs the headers through ActiveJob's argument serializer, so + # `__otel_headers` arrives as an array of `[key, value]` pairs, not a hash; + # accept both shapes. The nested keys win when present, since ActiveJob is + # the outer, more specific layer. def extract_job_context(item) return unless started? 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 @@ -221,6 +224,14 @@ def build_resource(config) 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}. diff --git a/spec/lib/appsignal/integrations/sidekiq_spec.rb b/spec/lib/appsignal/integrations/sidekiq_spec.rb index 28d7c2cfe..6f48245be 100644 --- a/spec/lib/appsignal/integrations/sidekiq_spec.rb +++ b/spec/lib/appsignal/integrations/sidekiq_spec.rb @@ -500,7 +500,10 @@ def expect_linked_back_to_remote end context "with a traceparent nested under __otel_headers (ActiveJob style)" do - let(:item) { super().merge("__otel_headers" => { "traceparent" => traceparent }) } + # 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) From 346aeedc95cea803edac451c35b58d269aceee63 Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Thu, 25 Jun 2026 16:44:57 +0200 Subject: [PATCH 101/151] Record DataMapper queries as client spans Thread an opentelemetry_kind through the record_event backend seam, the same way start_event already carries it, and tag DataMapper queries as client spans in collector mode. No-op in agent mode. --- lib/appsignal/integrations/data_mapper.rb | 6 ++++-- lib/appsignal/transaction.rb | 12 ++++++++++-- lib/appsignal/transaction/base_backend.rb | 2 +- lib/appsignal/transaction/extension_backend.rb | 3 ++- lib/appsignal/transaction/opentelemetry_backend.rb | 10 ++++++++-- spec/lib/appsignal/integrations/data_mapper_spec.rb | 2 ++ spec/lib/appsignal/transaction_spec.rb | 6 ++++-- 7 files changed, 31 insertions(+), 10 deletions(-) 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/transaction.rb b/lib/appsignal/transaction.rb index 86b9dd7b4..3a85e7922 100644 --- a/lib/appsignal/transaction.rb +++ b/lib/appsignal/transaction.rb @@ -666,7 +666,14 @@ def finish_event(name, title, body, body_format = Appsignal::EventFormatter::DEF # @!visibility private # @see Helpers::Instrumentation#instrument - def record_event(name, title, body, duration, body_format = Appsignal::EventFormatter::DEFAULT) + def record_event( + name, + title, + body, + duration, + body_format = Appsignal::EventFormatter::DEFAULT, + opentelemetry_kind: nil + ) return if paused? @backend.record_event( @@ -674,7 +681,8 @@ def record_event(name, title, body, duration, body_format = Appsignal::EventForm title || BLANK, body || BLANK, body_format || Appsignal::EventFormatter::DEFAULT, - duration + duration, + :opentelemetry_kind => opentelemetry_kind ) end diff --git a/lib/appsignal/transaction/base_backend.rb b/lib/appsignal/transaction/base_backend.rb index 10c05f2be..7d31ec024 100644 --- a/lib/appsignal/transaction/base_backend.rb +++ b/lib/appsignal/transaction/base_backend.rb @@ -18,7 +18,7 @@ def finish_event(_name, _title, _body, _body_format) raise NotImplementedError end - def record_event(_name, _title, _body, _body_format, _duration) + def record_event(_name, _title, _body, _body_format, _duration, opentelemetry_kind: nil) raise NotImplementedError end diff --git a/lib/appsignal/transaction/extension_backend.rb b/lib/appsignal/transaction/extension_backend.rb index 027e393e4..57cac2b50 100644 --- a/lib/appsignal/transaction/extension_backend.rb +++ b/lib/appsignal/transaction/extension_backend.rb @@ -39,7 +39,8 @@ def finish_event(name, title, body, body_format) @handle.finish_event(name, title, body, body_format, 0) end - def record_event(name, title, body, body_format, duration) + # 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 @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 51267dd60..1b6fadab2 100644 --- a/lib/appsignal/transaction/opentelemetry_backend.rb +++ b/lib/appsignal/transaction/opentelemetry_backend.rb @@ -96,9 +96,15 @@ def finish_event(name, title, body, body_format) span.finish end - def record_event(name, title, body, body_format, duration) + # `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) start_time = Time.now - (duration / 1_000_000_000.0) - span = tracer.start_span(EVENT_SPAN_PLACEHOLDER_NAME, :start_timestamp => start_time) + 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 diff --git a/spec/lib/appsignal/integrations/data_mapper_spec.rb b/spec/lib/appsignal/integrations/data_mapper_spec.rb index f9eb7e94d..57240a140 100644 --- a/spec/lib/appsignal/integrations/data_mapper_spec.rb +++ b/spec/lib/appsignal/integrations/data_mapper_spec.rb @@ -56,6 +56,7 @@ def perform 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") @@ -103,6 +104,7 @@ def perform 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") diff --git a/spec/lib/appsignal/transaction_spec.rb b/spec/lib/appsignal/transaction_spec.rb index d08025d7b..31dc2efbc 100644 --- a/spec/lib/appsignal/transaction_spec.rb +++ b/spec/lib/appsignal/transaction_spec.rb @@ -4360,7 +4360,8 @@ def exception_event "title", "body", 1, - 1000 + 1000, + :opentelemetry_kind => nil ).and_call_original transaction.record_event( @@ -4378,7 +4379,8 @@ def exception_event "", "", 0, - 1000 + 1000, + :opentelemetry_kind => nil ).and_call_original transaction.record_event( From 32af878bfffa59b029e807fb9560081b3b1a33be Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Thu, 25 Jun 2026 16:52:57 +0200 Subject: [PATCH 102/151] Record Redis queries as client spans Tag Redis queries with the client span kind in collector mode. No-op in agent mode. --- lib/appsignal/integrations/redis.rb | 7 ++++++- spec/lib/appsignal/hooks/redis_spec.rb | 2 ++ 2 files changed, 8 insertions(+), 1 deletion(-) 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/spec/lib/appsignal/hooks/redis_spec.rb b/spec/lib/appsignal/hooks/redis_spec.rb index c89efb201..28db616a4 100644 --- a/spec/lib/appsignal/hooks/redis_spec.rb +++ b/spec/lib/appsignal/hooks/redis_spec.rb @@ -101,6 +101,7 @@ def perform 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") @@ -140,6 +141,7 @@ def perform 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") From c76b0f454409dd9c3987837412e93908e218757a Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Thu, 25 Jun 2026 16:52:57 +0200 Subject: [PATCH 103/151] Record redis-client queries as client spans Tag redis-client queries with the client span kind in collector mode. No-op in agent mode. --- lib/appsignal/integrations/redis_client.rb | 7 ++++++- spec/lib/appsignal/hooks/redis_client_spec.rb | 2 ++ 2 files changed, 8 insertions(+), 1 deletion(-) 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/spec/lib/appsignal/hooks/redis_client_spec.rb b/spec/lib/appsignal/hooks/redis_client_spec.rb index 79eb6a886..a9fa819cf 100644 --- a/spec/lib/appsignal/hooks/redis_client_spec.rb +++ b/spec/lib/appsignal/hooks/redis_client_spec.rb @@ -107,6 +107,7 @@ def perform 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") @@ -147,6 +148,7 @@ def perform 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") From 7ec9b8f14f5c0bffb5c95959211b2994deea1ca5 Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Thu, 25 Jun 2026 16:52:57 +0200 Subject: [PATCH 104/151] Record Sequel queries as client spans Tag Sequel SQL queries with the client span kind in collector mode. No-op in agent mode. --- lib/appsignal/hooks/sequel.rb | 6 ++++-- spec/lib/appsignal/hooks/sequel_spec.rb | 1 + 2 files changed, 5 insertions(+), 2 deletions(-) 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/spec/lib/appsignal/hooks/sequel_spec.rb b/spec/lib/appsignal/hooks/sequel_spec.rb index 8628b14fb..d82805af7 100644 --- a/spec/lib/appsignal/hooks/sequel_spec.rb +++ b/spec/lib/appsignal/hooks/sequel_spec.rb @@ -45,6 +45,7 @@ def perform 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") From d38b7a7fb07d61ad89b7f610a9c3a7cf6c307532 Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Thu, 25 Jun 2026 16:52:57 +0200 Subject: [PATCH 105/151] Record MongoDB queries as client spans Tag MongoDB queries with the client span kind in collector mode. No-op in agent mode. --- lib/appsignal/integrations/mongo_ruby_driver.rb | 4 ++-- spec/lib/appsignal/integrations/mongo_ruby_driver_spec.rb | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/appsignal/integrations/mongo_ruby_driver.rb b/lib/appsignal/integrations/mongo_ruby_driver.rb index f353df3b4..97a336c7a 100644 --- a/lib/appsignal/integrations/mongo_ruby_driver.rb +++ b/lib/appsignal/integrations/mongo_ruby_driver.rb @@ -21,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 diff --git a/spec/lib/appsignal/integrations/mongo_ruby_driver_spec.rb b/spec/lib/appsignal/integrations/mongo_ruby_driver_spec.rb index e66ed00e7..9c5bc9aa0 100644 --- a/spec/lib/appsignal/integrations/mongo_ruby_driver_spec.rb +++ b/spec/lib/appsignal/integrations/mongo_ruby_driver_spec.rb @@ -88,6 +88,7 @@ def perform 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\":\"?\"}") @@ -132,6 +133,7 @@ def perform 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 From da2fd50ee05414ddf97ac2b122660174e0eb43b6 Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Thu, 25 Jun 2026 16:52:57 +0200 Subject: [PATCH 106/151] Propagate trace context on HTTP.rb requests Write trace context onto outgoing HTTP.rb requests so the called service joins the trace, and tag the request as a client span. Moves the hook to HTTP::Client#perform, where the live request headers are reachable, which also collapses the http5/http6 split into one prepend. Each redirect hop is now its own event. Collector mode only. --- lib/appsignal/hooks/http.rb | 23 +------ lib/appsignal/integrations/http.rb | 38 ++++++------ spec/lib/appsignal/hooks/http_spec.rb | 18 +----- spec/lib/appsignal/integrations/http_spec.rb | 65 ++++++++++++++++++++ 4 files changed, 90 insertions(+), 54 deletions(-) diff --git a/lib/appsignal/hooks/http.rb b/lib/appsignal/hooks/http.rb index f6c00421c..36a344e68 100644 --- a/lib/appsignal/hooks/http.rb +++ b/lib/appsignal/hooks/http.rb @@ -6,32 +6,15 @@ class Hooks class HttpHook < Appsignal::Hooks::Hook register :http_rb - def self.http6_or_higher? - Gem::Version.new(HTTP::VERSION) >= Gem::Version.new("6.0.0") - end - def dependencies_present? defined?(HTTP::Client) && Appsignal.config && Appsignal.config[:instrument_http_rb] end def install require "appsignal/integrations/http" - # `Client#request` takes positional options in http5 and keyword options - # in http6. - integration = - if self.class.http6_or_higher? - Appsignal::Integrations::HttpIntegration::KeywordOptions - else - Appsignal::Integrations::HttpIntegration::HashOptions - end - HTTP::Client.prepend integration - # In http6 a chained request (`.follow`, `.headers`, ...) goes through - # `HTTP::Session#request` instead of `HTTP::Client#request`, so - # instrument it too (keyword options). http5 has no Session; chained - # requests run through `Client#request` there. - if defined?(HTTP::Session) - HTTP::Session.prepend Appsignal::Integrations::HttpIntegration::KeywordOptions - end + # `perform` has the same signature in http5 and http6, so a single + # prepend module works for both. + HTTP::Client.prepend Appsignal::Integrations::HttpIntegration Appsignal::Environment.report_enabled("http_rb") end diff --git a/lib/appsignal/integrations/http.rb b/lib/appsignal/integrations/http.rb index fa65cfbec..8f8fd966d 100644 --- a/lib/appsignal/integrations/http.rb +++ b/lib/appsignal/integrations/http.rb @@ -9,27 +9,27 @@ 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 - # stays a single `request.http_rb` event spanning every hop. That boundary - # lives in more than one place: a bare request runs through - # `HTTP::Client#request`, but in http6 a chained request (`.follow`, - # `.headers`, `.timeout`, ...) runs through `HTTP::Session#request` - # instead, which never touches `Client#request`. The hook prepends one of - # these onto each. `Client#request` takes positional options in http5 and - # keyword options in http6; `Session#request` (http6 only) takes keyword - # options. - module HashOptions - def request(verb, uri, opts = {}) - HttpIntegration.instrument(verb, uri) { super } - end - end - - module KeywordOptions - def request(verb, uri, **opts) - HttpIntegration.instrument(verb, uri) { super } + # `perform` is the single send chokepoint in both http5 and http6 (its + # signature is identical), called once per request and once per redirect + # hop. Instrumenting here keeps a single prepend module across versions and + # gives each hop its own event with trace context injected into that hop's + # outgoing request. + def perform(req, options) + HttpIntegration.instrument(req.verb, req.uri) do + # Write trace context onto the outgoing request headers so the called + # service joins this trace. No-op outside collector mode. `req.headers` + # is the live outgoing header set and a valid carrier (it responds to + # `[]=`). + Appsignal::OpenTelemetry.inject_context(req.headers) + super end end end diff --git a/spec/lib/appsignal/hooks/http_spec.rb b/spec/lib/appsignal/hooks/http_spec.rb index 3df5f9175..f7c6384dc 100644 --- a/spec/lib/appsignal/hooks/http_spec.rb +++ b/spec/lib/appsignal/hooks/http_spec.rb @@ -12,21 +12,9 @@ it { is_expected.to be_truthy } end - if DependencyHelper.http6_present? - it "installs the HTTP plugin with keyword options" do - expect(HTTP::Client.included_modules) - .to include(Appsignal::Integrations::HttpIntegration::KeywordOptions) - end - - it "instruments chained requests through HTTP::Session" do - expect(HTTP::Session.included_modules) - .to include(Appsignal::Integrations::HttpIntegration::KeywordOptions) - end - else - it "installs the HTTP plugin with hash options" do - expect(HTTP::Client.included_modules) - .to include(Appsignal::Integrations::HttpIntegration::HashOptions) - end + it "installs the HTTP plugin" do + expect(HTTP::Client.included_modules) + .to include(Appsignal::Integrations::HttpIntegration) end end diff --git a/spec/lib/appsignal/integrations/http_spec.rb b/spec/lib/appsignal/integrations/http_spec.rb index 8d2c9e2ff..6389b12d0 100644 --- a/spec/lib/appsignal/integrations/http_spec.rb +++ b/spec/lib/appsignal/integrations/http_spec.rb @@ -38,9 +38,15 @@ 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.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 @@ -76,9 +82,13 @@ 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.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 @@ -331,5 +341,60 @@ def perform end end end + + describe "following redirects" do + # `perform` is the single send chokepoint, called once per request and + # once per redirect hop, so each hop becomes its own `request.http_rb` + # event. (Before this moved to `perform`, the `request`-level event + # spanned all hops.) + 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" } + expect(events.map { |event| event["title"] }).to eq( + [ + "GET http://www.google.com", + "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(2) + expect(event_spans.map(&:name)).to contain_exactly( + "GET http://www.google.com", + "GET http://www.example.com" + ) + event_spans.each do |span| + expect(span.kind).to eq(:client) + 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 From 4cd64dae595d97a19abc0a16e6dfd65c10d009a5 Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Thu, 25 Jun 2026 16:52:57 +0200 Subject: [PATCH 107/151] Propagate trace context on Resque jobs Link a performing job back to the trace that enqueued it, and record the enqueue as a producer event that writes trace context onto the job payload. Mirrors the Sidekiq integration. Collector mode only. --- lib/appsignal/hooks/resque.rb | 2 +- lib/appsignal/integrations/resque.rb | 25 ++-- .../lib/appsignal/integrations/resque_spec.rb | 115 ++++++++++++++++++ 3 files changed, 131 insertions(+), 11 deletions(-) diff --git a/lib/appsignal/hooks/resque.rb b/lib/appsignal/hooks/resque.rb index 01183fd25..1cd159e17 100644 --- a/lib/appsignal/hooks/resque.rb +++ b/lib/appsignal/hooks/resque.rb @@ -15,7 +15,7 @@ def install Resque::Job.prepend Appsignal::Integrations::ResqueIntegration # Resque enqueues through the `Resque.push` singleton method, so prepend - # onto its singleton class to record the enqueue event. + # onto its singleton class to write the trace context onto outgoing jobs. Resque.singleton_class.prepend Appsignal::Integrations::ResquePushIntegration end end diff --git a/lib/appsignal/integrations/resque.rb b/lib/appsignal/integrations/resque.rb index 70e86d451..3da74cc1e 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_job.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,11 @@ def perform # # @!visibility private module ResquePushIntegration - 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? - - Appsignal.instrument("enqueue.resque", "enqueue #{item["class"]} job") { super } + def push(queue, item) + Appsignal.instrument("enqueue_job.resque", :opentelemetry_kind => :producer) do + Appsignal::OpenTelemetry.inject_context(item) + super + end end end diff --git a/spec/lib/appsignal/integrations/resque_spec.rb b/spec/lib/appsignal/integrations/resque_spec.rb index f706d7a57..e203cc6df 100644 --- a/spec/lib/appsignal/integrations/resque_spec.rb +++ b/spec/lib/appsignal/integrations/resque_spec.rb @@ -64,6 +64,45 @@ def perform 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" } + + 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 + describe "tracks the error on the transaction" do def perform expect do @@ -176,6 +215,82 @@ def perform 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" => [] } } + + 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; no wire context in agent mode. + event_names = transaction.to_h["events"].map { |event| event["name"] } + expect(event_names).to include("enqueue_job.resque") + 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. + producer = event_spans.find { |s| s.name == "enqueue_job.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_job.resque") + expect(item).to_not have_key("traceparent") + end + end + end + describe "does not set arguments for ActiveJob" do before do stub_const("ActiveJob::QueueAdapters::ResqueAdapter::JobWrapper", Class.new do From b5b398b4df25107b70ca509ae26b4b74104908ca Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Thu, 25 Jun 2026 16:52:57 +0200 Subject: [PATCH 108/151] Propagate trace context on Excon requests Tag Excon requests as client spans and add an inject-only middleware that writes trace context onto the outgoing request. Collector mode only. --- lib/appsignal/hooks/excon.rb | 3 ++ lib/appsignal/integrations/excon.rb | 2 +- .../excon/appsignal_middleware.rb | 21 ++++++++++ spec/lib/appsignal/hooks/excon_spec.rb | 40 ++++++++++++++++++- 4 files changed, 63 insertions(+), 3 deletions(-) create mode 100644 lib/appsignal/integrations/excon/appsignal_middleware.rb diff --git a/lib/appsignal/hooks/excon.rb b/lib/appsignal/hooks/excon.rb index ff09de8bf..cefa3be41 100644 --- a/lib/appsignal/hooks/excon.rb +++ b/lib/appsignal/hooks/excon.rb @@ -12,7 +12,10 @@ def dependencies_present? def install require "appsignal/integrations/excon" + require "appsignal/integrations/excon/appsignal_middleware" ::Excon.defaults[:instrumentor] = Appsignal::Integrations::ExconIntegration + ::Excon.defaults[:middlewares] = + ::Excon.defaults[:middlewares] + [Appsignal::Integrations::ExconMiddleware] end end end diff --git a/lib/appsignal/integrations/excon.rb b/lib/appsignal/integrations/excon.rb index b659caffb..56a92dd1f 100644 --- a/lib/appsignal/integrations/excon.rb +++ b/lib/appsignal/integrations/excon.rb @@ -22,7 +22,7 @@ def self.instrument(name, data, &block) else "#{data[:method].to_s.upcase} #{data[:scheme]}://#{data[:host]}" end - Appsignal.instrument(rails_name, title, &block) + Appsignal.instrument(rails_name, title, :opentelemetry_kind => :client, &block) end end end diff --git a/lib/appsignal/integrations/excon/appsignal_middleware.rb b/lib/appsignal/integrations/excon/appsignal_middleware.rb new file mode 100644 index 000000000..32b736834 --- /dev/null +++ b/lib/appsignal/integrations/excon/appsignal_middleware.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Appsignal + module Integrations + # Excon middleware that writes trace context onto the outgoing request, so + # the called service joins this trace. The existing Excon instrumentor + # records the event span; this middleware only injects. + # + # @!visibility private + class ExconMiddleware < ::Excon::Middleware::Base + def request_call(datum) + datum[:headers] ||= {} + # Inject from whatever span is current. The instrumentor's event span is + # active during the request, so the written `traceparent` reflects the + # Excon client event. No-op outside collector mode. + Appsignal::OpenTelemetry.inject_context(datum[:headers]) + super + end + end + end +end diff --git a/spec/lib/appsignal/hooks/excon_spec.rb b/spec/lib/appsignal/hooks/excon_spec.rb index 6ce282011..342e86221 100644 --- a/spec/lib/appsignal/hooks/excon_spec.rb +++ b/spec/lib/appsignal/hooks/excon_spec.rb @@ -3,7 +3,19 @@ before do stub_const("Excon", Class.new do def self.defaults - @defaults ||= {} + @defaults ||= { :middlewares => [] } + end + end) + stub_const("Excon::Middleware", Module.new) + stub_const("Excon::Middleware::Base", Class.new do + def initialize(stack = nil) + @stack = stack + end + + # The default `request_call` just returns the datum, standing in for the + # rest of the (empty) middleware stack. + def request_call(datum) + datum end end) Appsignal::Hooks::ExconHook.new.install @@ -20,6 +32,10 @@ def self.defaults it "adds the AppSignal instrumentor to Excon" do expect(Excon.defaults[:instrumentor]).to eql(Appsignal::Integrations::ExconIntegration) end + + it "adds the AppSignal middleware to Excon" do + expect(Excon.defaults[:middlewares]).to include(Appsignal::Integrations::ExconMiddleware) + end end describe "instrumentation" do @@ -30,7 +46,21 @@ def perform :method => :get, :scheme => "http" } - Excon.defaults[:instrumentor].instrument("excon.request", data) {} # rubocop:disable Lint/EmptyBlock + Excon.defaults[:instrumentor].instrument("excon.request", data) do + # The middleware injects from whatever span is current. The + # instrumentor's event span is active here, mirroring a real + # request where the middleware runs inside the instrumented block. + datum = inject_with_middleware + ensure + @injected_headers = datum && datum[:headers] + end + end + + # Runs the AppSignal Excon middleware's `request_call` over an empty + # datum, returning the datum so we can read the injected headers. + def inject_with_middleware + middleware = Appsignal::Integrations::ExconMiddleware.new + middleware.request_call({}) end it "in agent mode", :agent_mode do @@ -55,9 +85,15 @@ 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.kind).to eq(:client) expect(span.parent_span_id).to eq(root_span.span_id) expect(span.attributes["appsignal.category"]).to eq("request.excon") expect(span.attributes).not_to have_key("appsignal.body") + + # The middleware wrote a W3C traceparent for the client span onto the + # outgoing request headers, so the called service joins this trace. + expect(@injected_headers["traceparent"]) + .to eq("00-#{span.hex_trace_id}-#{span.hex_span_id}-01") end end From 9e7ef27d0ed0eb8e793760731a0463669cf9ce8d Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Thu, 25 Jun 2026 17:06:23 +0200 Subject: [PATCH 109/151] Fix and test Excon trace context injection The inject middleware was appended after Excon's Mock middleware, which ends the request chain, so its request_call never ran and no traceparent was written. Insert it just before Mock instead, where it also runs inside the instrumentor's event span so the injected context reflects the client span. Add an excon gemfile and an integration test that drives a real Excon request and asserts the outgoing traceparent matches the client span, which the stubbed hook spec could not prove. --- build_matrix.yml | 11 +--- gemfiles/excon-collector.gemfile | 6 ++ gemfiles/excon.gemfile | 5 ++ lib/appsignal/hooks/excon.rb | 15 ++++- spec/lib/appsignal/hooks/excon_spec.rb | 19 +++++- spec/lib/appsignal/integrations/excon_spec.rb | 58 +++++++++++++++++++ 6 files changed, 99 insertions(+), 15 deletions(-) create mode 100644 gemfiles/excon-collector.gemfile create mode 100644 gemfiles/excon.gemfile create mode 100644 spec/lib/appsignal/integrations/excon_spec.rb diff --git a/build_matrix.yml b/build_matrix.yml index 4b3a68dc5..c29ad3d4b 100644 --- a/build_matrix.yml +++ b/build_matrix.yml @@ -162,16 +162,7 @@ matrix: - "3.2.5" - "3.1.6" - "3.0.7" - - gem: "faraday-1" - - gem: "faraday-2" - only: - ruby: - - "4.0.0" - - "3.4.1" - - "3.3.4" - - "3.2.5" - - "3.1.6" - - "3.0.7" + - gem: "excon" - gem: "grape" - gem: "hanami-2.0" only: diff --git a/gemfiles/excon-collector.gemfile b/gemfiles/excon-collector.gemfile new file mode 100644 index 000000000..d8567df01 --- /dev/null +++ b/gemfiles/excon-collector.gemfile @@ -0,0 +1,6 @@ +# DO NOT EDIT +# This is a generated file by the `rake build_matrix:gemfiles:generate` task. +# It layers the optional OpenTelemetry gems (gemfiles/collector.rb) on top of excon.gemfile. + +eval_gemfile File.expand_path("excon.gemfile", __dir__) +eval_gemfile File.expand_path("collector.rb", __dir__) diff --git a/gemfiles/excon.gemfile b/gemfiles/excon.gemfile new file mode 100644 index 000000000..526081e0b --- /dev/null +++ b/gemfiles/excon.gemfile @@ -0,0 +1,5 @@ +source "https://rubygems.org" + +gem "excon" + +gemspec :path => "../" diff --git a/lib/appsignal/hooks/excon.rb b/lib/appsignal/hooks/excon.rb index cefa3be41..09b308438 100644 --- a/lib/appsignal/hooks/excon.rb +++ b/lib/appsignal/hooks/excon.rb @@ -14,8 +14,19 @@ def install require "appsignal/integrations/excon" require "appsignal/integrations/excon/appsignal_middleware" ::Excon.defaults[:instrumentor] = Appsignal::Integrations::ExconIntegration - ::Excon.defaults[:middlewares] = - ::Excon.defaults[:middlewares] + [Appsignal::Integrations::ExconMiddleware] + + # Insert our middleware just before the Mock middleware (the innermost, + # where the response is produced) so its `request_call` runs before the + # request is sent -- and, being inside the Instrumentor middleware, while + # our event span is current, so the injected `traceparent` reflects it. + # Appending to the end would place it after Mock, which short-circuits + # the request chain before reaching it. + middlewares = ::Excon.defaults[:middlewares].dup + return if middlewares.include?(Appsignal::Integrations::ExconMiddleware) + + index = middlewares.index(::Excon::Middleware::Mock) || middlewares.length + middlewares.insert(index, Appsignal::Integrations::ExconMiddleware) + ::Excon.defaults[:middlewares] = middlewares end end end diff --git a/spec/lib/appsignal/hooks/excon_spec.rb b/spec/lib/appsignal/hooks/excon_spec.rb index 342e86221..5d4f26820 100644 --- a/spec/lib/appsignal/hooks/excon_spec.rb +++ b/spec/lib/appsignal/hooks/excon_spec.rb @@ -3,7 +3,9 @@ before do stub_const("Excon", Class.new do def self.defaults - @defaults ||= { :middlewares => [] } + # Mock is the innermost default middleware; the hook inserts ours + # before it. Referenced lazily so it's resolved after the stub below. + @defaults ||= { :middlewares => [Excon::Middleware::Mock] } end end) stub_const("Excon::Middleware", Module.new) @@ -18,6 +20,7 @@ def request_call(datum) datum end end) + stub_const("Excon::Middleware::Mock", Class.new(Excon::Middleware::Base)) Appsignal::Hooks::ExconHook.new.install end @@ -33,8 +36,18 @@ def request_call(datum) expect(Excon.defaults[:instrumentor]).to eql(Appsignal::Integrations::ExconIntegration) end - it "adds the AppSignal middleware to Excon" do - expect(Excon.defaults[:middlewares]).to include(Appsignal::Integrations::ExconMiddleware) + 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 diff --git a/spec/lib/appsignal/integrations/excon_spec.rb b/spec/lib/appsignal/integrations/excon_spec.rb new file mode 100644 index 000000000..672e57ac6 --- /dev/null +++ b/spec/lib/appsignal/integrations/excon_spec.rb @@ -0,0 +1,58 @@ +require "excon" +require "appsignal/integrations/excon" +require "appsignal/integrations/excon/appsignal_middleware" + +# Integration test against the real Excon gem (the hooks/excon_spec.rb suite +# stubs Excon). Verifies, end to end, that the inject-only middleware writes +# trace context onto a live outgoing request while the instrumentor's client +# event span is current -- the ordering the stubbed suite can't prove. +describe "Excon integration" do + before { Appsignal::Hooks::ExconHook.new.install } + + describe "a GET request" do + def perform + stub_request(:get, "http://www.example.com/") + Excon.get("http://www.example.com/") + end + + it "in agent mode", :agent_mode do + start_agent + transaction = http_request_transaction + set_current_transaction(transaction) + perform + + expect(transaction).to include_event( + "name" => "request.excon", + "title" => "GET http://www.example.com" + ) + end + + it "in collector mode", :collector_mode do + start_collector_agent + transaction = http_request_transaction + set_current_transaction(transaction) + perform + Appsignal::Transaction.complete_current! + + span = event_spans.find { |s| s.attributes["appsignal.category"] == "request.excon" } + expect(span).not_to be_nil + expect(span.kind).to eq(:client) + expect(span.parent_span_id).to eq(root_span.span_id) + + # The injected traceparent must reflect the Excon CLIENT event span, not + # the root span -- proving the middleware runs inside the instrumentor's + # event span on a real request. + expect(injected_traceparent("http://www.example.com/")) + .to eq("00-#{span.hex_trace_id}-#{span.hex_span_id}-01") + end + end + + # Reads the `traceparent` header off the recorded outgoing request to `url`. + def injected_traceparent(url) + traceparent = nil + expect( + a_request(:get, url).with { |request| traceparent = request.headers["Traceparent"] } + ).to have_been_made + traceparent + end +end From fd22795936e05ed09c9fcb879aa29e2b58650fa3 Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Thu, 25 Jun 2026 16:54:54 +0200 Subject: [PATCH 110/151] Tag database event spans as OTel client spans ActiveRecord SQL (via ActiveSupport::Notifications) and ROM SQL (via dry-monitor) are outgoing calls to a datastore, so give their event spans CLIENT kind in collector mode, matching the dedicated database integrations. Span kind is immutable, so it is set when the event starts; the lookup is kept narrow so unrelated events stay INTERNAL. --- .../active_support_notifications.rb | 20 +++++++++++-------- lib/appsignal/integrations/dry_monitor.rb | 7 ++++++- .../finish_with_state_shared_examples.rb | 2 ++ .../instrument_shared_examples.rb | 8 ++++++++ .../start_finish_shared_examples.rb | 4 ++++ spec/lib/appsignal/hooks/dry_monitor_spec.rb | 5 +++++ 6 files changed, 37 insertions(+), 9 deletions(-) diff --git a/lib/appsignal/integrations/active_support_notifications.rb b/lib/appsignal/integrations/active_support_notifications.rb index 074219964..001897f39 100644 --- a/lib/appsignal/integrations/active_support_notifications.rb +++ b/lib/appsignal/integrations/active_support_notifications.rb @@ -7,17 +7,21 @@ module ActiveSupportNotificationsIntegration class << self BANG = "!" - # Events a dedicated AppSignal integration already records, so the - # generic notifications path must not record them a second time. The - # ActiveJob hook owns `enqueue.active_job` (it wraps the enqueue in its - # own event, with Rails' native notification nested inside), and the - # Faraday integration owns `request.faraday`. - SUPPRESSED_EVENT_NAMES = ["enqueue.active_job", "request.faraday"].freeze + # 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 def start_event(name) - return unless record_event?(name) + # Events that start with a bang are internal to Rails + return if name[0] == BANG - 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/dry_monitor.rb b/lib/appsignal/integrations/dry_monitor.rb index bad3e495b..e2ef8b5f5 100644 --- a/lib/appsignal/integrations/dry_monitor.rb +++ b/lib/appsignal/integrations/dry_monitor.rb @@ -4,8 +4,13 @@ module Appsignal module Integrations # @!visibility private module DryMonitorIntegration + # ROM emits its SQL queries as dry-monitor `"sql"` events; tag those as + # CLIENT in collector mode to match the dedicated DB integrations. Span + # kind is immutable, so it has to be set here at event start. def instrument(event_id, payload = {}, &block) - Appsignal::Transaction.current.start_event + Appsignal::Transaction.current.start_event( + :opentelemetry_kind => event_id.to_s == "sql" ? :client : nil + ) super ensure diff --git a/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 72d222072..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 @@ -37,6 +37,8 @@ def perform 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 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 e3445c095..6335728bb 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 @@ -33,6 +33,8 @@ def perform 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") @@ -74,6 +76,8 @@ def perform 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") @@ -186,6 +190,8 @@ def perform 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 @@ -228,6 +234,8 @@ def perform 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 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 d3a8714f7..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 @@ -37,6 +37,8 @@ def perform 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") @@ -80,6 +82,8 @@ def perform 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 diff --git a/spec/lib/appsignal/hooks/dry_monitor_spec.rb b/spec/lib/appsignal/hooks/dry_monitor_spec.rb index 26ded0915..c51798cbf 100644 --- a/spec/lib/appsignal/hooks/dry_monitor_spec.rb +++ b/spec/lib/appsignal/hooks/dry_monitor_spec.rb @@ -72,6 +72,9 @@ def perform span = event_spans.first expect(span.name).to eq("query.postgres") expect(span.parent_span_id).to eq(root_span.span_id) + # ROM emits its queries as dry-monitor `sql` events; a query is an + # outgoing call, so it carries CLIENT kind. + expect(span.kind).to eq(:client) attrs = span.attributes expect(attrs["db.query.text"]).to eq("SELECT * FROM users") expect(attrs["db.system.name"]).to eq("other_sql") @@ -113,6 +116,8 @@ def perform span = event_spans.first expect(span.name).to eq("foo") expect(span.parent_span_id).to eq(root_span.span_id) + # A non-SQL dry event is not an outgoing call, so it keeps the default kind. + expect(span.kind).to eq(:internal) attrs = span.attributes expect(attrs["appsignal.category"]).to eq("foo") expect(attrs).not_to have_key("appsignal.body") From 01450f00239a25d0ef0ef4f885d1b5ca67807733 Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Thu, 25 Jun 2026 16:55:03 +0200 Subject: [PATCH 111/151] Propagate trace context across ActiveJob jobs Carry W3C trace context over the ActiveJob enqueue/perform boundary in collector mode, wire-compatible with OpenTelemetry's ActiveJob instrumentation. On enqueue, record an enqueue.active_job producer event and inject the context onto the job under __otel_headers. The event reuses the name of Rails' native enqueue notification, which the AppSignal notifications path now suppresses so the enqueue is recorded once. Stock serialize/deserialize only carry a fixed key set, so patch both plus an accessor to round-trip it, running the value through ActiveJob's argument serializer to match OpenTelemetry byte-for-byte. On perform, read the context back and link the job to the enqueuer -- but only when ActiveJob starts its own transaction, so a wrapping integration like Sidekiq doesn't extract twice. --- lib/appsignal/hooks/active_job.rb | 90 ++++++++----- .../active_support_notifications.rb | 17 ++- spec/lib/appsignal/hooks/activejob_spec.rb | 123 +++++++++++++----- 3 files changed, 160 insertions(+), 70 deletions(-) diff --git a/lib/appsignal/hooks/active_job.rb b/lib/appsignal/hooks/active_job.rb index 2b6446d0b..2e45a6690 100644 --- a/lib/appsignal/hooks/active_job.rb +++ b/lib/appsignal/hooks/active_job.rb @@ -29,8 +29,11 @@ def install ActiveSupport.on_load(:active_job) do ::ActiveJob::Base .extend ::Appsignal::Hooks::ActiveJobHook::ActiveJobClassInstrumentation + # Carry W3C trace context across the enqueue/perform boundary in + # collector mode (no-ops otherwise). The patches are cheap and + # mode-gated inside their method bodies, so install them unconditionally. ::ActiveJob::Base - .prepend ::Appsignal::Hooks::ActiveJobHook::ActiveJobEnqueueInstrumentation + .prepend ::Appsignal::Hooks::ActiveJobHook::ActiveJobTraceContext next unless Appsignal::Hooks::ActiveJobHook.version_7_1_or_higher? @@ -43,33 +46,6 @@ def install end end - # Records an `enqueue.active_job` event when a job is enqueued, so the - # enqueue shows up on the active transaction's timeline (e.g. when - # enqueuing from within a web request or another job). - # - # Wrapping `enqueue` ourselves -- rather than relying on Rails' native - # `enqueue.active_job` notification, which the AppSignal notifications - # path now suppresses -- gives us a single event we own. Like all - # AppSignal events, this only records when there's an active transaction; - # an enqueue with no transaction is a transparent pass-through. - # - # @!visibility private - module ActiveJobEnqueueInstrumentation - def enqueue(*, **) - Appsignal.instrument("enqueue.active_job", "enqueue #{self.class.name} job") do - # Active Job enqueues through an adapter (Sidekiq, Resque, ...) that - # has its own enqueue instrumentation. Suppress it so the enqueue is - # recorded once, as this event, rather than as nested Active Job + - # adapter events. - if Appsignal::Transaction.current? - Appsignal::Transaction.current.suppress_job_enqueue_events { super } - else - super - end - end - end - end - module ActiveJobClassInstrumentation def execute(job) enqueued_at = job["enqueued_at"] @@ -91,8 +67,17 @@ def execute(job) # We don't have a separate integration for this QueueAdapter like # we do for Sidekiq. # + # Read the trace context off the job so the transaction links back + # to the enqueuer (no-op outside collector mode). Only here, in the + # standalone branch: when a wrapper integration (e.g. Sidekiq) + # created the transaction, it already extracted, so we must not + # extract a second time. + # # Prefer job_id from provider, instead of ActiveJob's internal ID. - Appsignal::Transaction.create(Appsignal::Transaction::BACKGROUND_JOB) + Appsignal::Transaction.create( + Appsignal::Transaction::BACKGROUND_JOB, + :opentelemetry_context => Appsignal::OpenTelemetry.extract_job_context(job) + ) end begin @@ -152,6 +137,53 @@ def transaction_set_error(transaction, exception) end end + # Reads and writes W3C trace context on the ActiveJob enqueue/perform + # boundary, wire-compatible with OpenTelemetry's ActiveJob instrumentation. + # All of this no-ops outside collector mode. + # + # Context rides on the job under `__otel_headers`, the same carrier OTel + # uses. Stock `serialize`/`deserialize` only carry a fixed key set, so -- + # like OTel -- we patch both plus an accessor to round-trip it. The on-wire + # value is run through ActiveJob's argument serializer (an array of + # `[key, value]` pairs), matching OTel byte-for-byte so an AppSignal- and an + # OTel-instrumented service read each other's jobs. + module ActiveJobTraceContext + # Inject on enqueue from inside a producer event, so the job carries this + # transaction's context and the perform later links back. Mirrors the + # Sidekiq client middleware: an AppSignal event (a producer span in + # collector mode), not a direct SDK span. `Appsignal.instrument` is a + # transparent pass-through when there's no active transaction, and + # `inject_context` no-ops outside collector mode. + def enqueue(*) + Appsignal.instrument("enqueue.active_job", :opentelemetry_kind => :producer) do + Appsignal::OpenTelemetry.inject_context(__otel_headers) + super + end + end + + def serialize + super.tap do |data| + next unless Appsignal::OpenTelemetry.started? + next if __otel_headers.empty? + + data["__otel_headers"] = ::ActiveJob::Arguments.serialize(__otel_headers) + 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/integrations/active_support_notifications.rb b/lib/appsignal/integrations/active_support_notifications.rb index 001897f39..6d7ef1bb3 100644 --- a/lib/appsignal/integrations/active_support_notifications.rb +++ b/lib/appsignal/integrations/active_support_notifications.rb @@ -15,9 +15,15 @@ class << self # 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. + SUPPRESSED_EVENT_NAMES = ["enqueue.active_job"].freeze + def start_event(name) - # Events that start with a bang are internal to Rails - return if name[0] == BANG + return unless record_event?(name) Appsignal::Transaction.current.start_event( :opentelemetry_kind => CLIENT_EVENT_NAMES.include?(name.to_s) ? :client : nil @@ -37,11 +43,10 @@ def finish_event(name, payload = {}) end # Events starting with a bang are internal to Rails; suppressed events - # are recorded by a dedicated integration instead. Both `start_event` - # and `finish_event` gate on this so the event stack stays balanced. + # are recorded elsewhere. 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[0] != BANG && !SUPPRESSED_EVENT_NAMES.include?(name.to_s) end end diff --git a/spec/lib/appsignal/hooks/activejob_spec.rb b/spec/lib/appsignal/hooks/activejob_spec.rb index bdc622f8f..3d25c45ad 100644 --- a/spec/lib/appsignal/hooks/activejob_spec.rb +++ b/spec/lib/appsignal/hooks/activejob_spec.rb @@ -536,55 +536,108 @@ def perform(current_transaction) end end - context "when enqueuing a job" do - before { ActiveJob::Base.queue_adapter = :test } + context "with distributed trace context" do + let(:trace_id_hex) { "0af7651916cd43dd8448eb211c80319c" } + let(:span_id_hex) { "b7ad6b7169203331" } + let(:traceparent) { "00-#{trace_id_hex}-#{span_id_hex}-01" } + + describe "serializing context onto the job" do + it "round-trips __otel_headers through serialize/deserialize in collector mode", + :collector_mode do + start_collector_agent + + job = ActiveJobTestJob.new + job.__otel_headers = { "traceparent" => traceparent } + data = job.serialize + + # Wire-compatible with OpenTelemetry: headers ride as an array of + # [key, value] pairs (ActiveJob's argument-serializer output), not a + # hash. + expect(data["__otel_headers"]).to eq([["traceparent", traceparent]]) + expect(ActiveJobTestJob.deserialize(data).__otel_headers) + .to eq("traceparent" => traceparent) + end + + it "leaves the job untouched outside collector mode", :agent_mode do + start_agent(**start_agent_args) + + job = ActiveJobTestJob.new + job.__otel_headers = { "traceparent" => traceparent } - context "with an active transaction" do - it "records a single enqueue.active_job event on the transaction" do + expect(job.serialize).to_not have_key("__otel_headers") + end + end + + describe "injecting context on enqueue" do + before { ActiveJob::Base.queue_adapter = :test } + + # Returns the enqueuing transaction so the example can read its events. + def enqueue_within_transaction transaction = http_request_transaction set_current_transaction(transaction) - ActiveJobTestJob.perform_later + transaction + end - # Exactly one enqueue event: ours. Rails' 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") + 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. + producer = event_spans.find { |s| s.name == "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 - 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 }) + it "records an enqueue event without wire context in agent mode", :agent_mode do + start_agent(**start_agent_args) + transaction = enqueue_within_transaction - expect(ActiveJob::Base.queue_adapter.enqueued_jobs.count).to eq(1) + # Exactly one enqueue event: ours. The native `enqueue.active_job` + # notification is suppressed so it isn't recorded a second time. + event_names = transaction.to_h["events"].map { |event| event["name"] } + expect(event_names.count("enqueue.active_job")).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 "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 - # 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 - 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? - method.call(*args) - 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 - ActiveJobTestJob.perform_later + 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(suppressed_during_enqueue).to be(true) + expect(last_transaction.to_h["metadata"].keys).to_not include("__otel_headers") end end end From 4c41014e2245072e7f3e66f4e3921f0abfc44713 Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Thu, 25 Jun 2026 17:10:29 +0200 Subject: [PATCH 112/151] Rename Sidekiq enqueue event to enqueue.sidekiq Match the enqueue. shape now shared with ActiveJob, dropping the enqueue_job. prefix. Renames the event only; behaviour is unchanged. --- lib/appsignal/integrations/sidekiq.rb | 4 ++-- spec/lib/appsignal/integrations/sidekiq_spec.rb | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/appsignal/integrations/sidekiq.rb b/lib/appsignal/integrations/sidekiq.rb index 3195b9520..16cd78712 100644 --- a/lib/appsignal/integrations/sidekiq.rb +++ b/lib/appsignal/integrations/sidekiq.rb @@ -49,7 +49,7 @@ def call(exception, sidekiq_context, _sidekiq_config = nil) end end - # Client middleware that runs on enqueue. Records an `enqueue_job.sidekiq` + # 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. @@ -61,7 +61,7 @@ def call(exception, sidekiq_context, _sidekiq_config = nil) # @!visibility private class SidekiqClientMiddleware def call(_worker_class, job, _queue, _redis_pool) - Appsignal.instrument("enqueue_job.sidekiq", :opentelemetry_kind => :producer) do + Appsignal.instrument("enqueue.sidekiq", :opentelemetry_kind => :producer) do Appsignal::OpenTelemetry.inject_context(job) yield end diff --git a/spec/lib/appsignal/integrations/sidekiq_spec.rb b/spec/lib/appsignal/integrations/sidekiq_spec.rb index 6f48245be..961a79ed2 100644 --- a/spec/lib/appsignal/integrations/sidekiq_spec.rb +++ b/spec/lib/appsignal/integrations/sidekiq_spec.rb @@ -342,7 +342,7 @@ def enqueue # Records an enqueue event on the transaction; no wire context in agent mode. event_names = transaction.to_h["events"].map { |event| event["name"] } - expect(event_names).to include("enqueue_job.sidekiq") + expect(event_names).to include("enqueue.sidekiq") expect(job).to_not have_key("traceparent") end @@ -355,7 +355,7 @@ def enqueue Appsignal::Transaction.complete_current! # The enqueue is a producer event span under the active transaction. - producer = event_spans.find { |s| s.name == "enqueue_job.sidekiq" } + producer = event_spans.find { |s| s.name == "enqueue.sidekiq" } expect(producer.kind).to eq(:producer) expect(producer.parent_span_id).to eq(root_span.span_id) @@ -381,7 +381,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_job.sidekiq") + expect(span_exporter.finished_spans.map(&:name)).to_not include("enqueue.sidekiq") expect(job).to_not have_key("traceparent") end end From cc7b0a65727ed66917a8086daad7f14b9e364006 Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Thu, 25 Jun 2026 17:05:15 +0200 Subject: [PATCH 113/151] Read incoming trace context in Webmachine requests Webmachine isn't Rack, so it extracts the W3C context straight from the request headers. Adds a generic `OpenTelemetry.if_started` gate that runs a block only in collector mode, so integrations keep their own carrier logic instead of each needing a bespoke helper on the module. --- lib/appsignal/integrations/webmachine.rb | 11 +++++++- lib/appsignal/opentelemetry.rb | 13 ++++++++++ .../appsignal/integrations/webmachine_spec.rb | 25 +++++++++++++++++++ spec/lib/appsignal/opentelemetry_spec.rb | 18 +++++++++++++ 4 files changed, 66 insertions(+), 1 deletion(-) 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/opentelemetry.rb b/lib/appsignal/opentelemetry.rb index ed29a3b0f..da4e269ca 100644 --- a/lib/appsignal/opentelemetry.rb +++ b/lib/appsignal/opentelemetry.rb @@ -162,6 +162,19 @@ def extract_job_context(item) ::OpenTelemetry.propagation.extract(carrier) 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 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/opentelemetry_spec.rb b/spec/lib/appsignal/opentelemetry_spec.rb index 25a9f6985..c35e212be 100644 --- a/spec/lib/appsignal/opentelemetry_spec.rb +++ b/spec/lib/appsignal/opentelemetry_spec.rb @@ -233,6 +233,24 @@ 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( From 78f60d8db4f3ff67d4d7e51d2c5ee602b091562a Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Thu, 25 Jun 2026 17:05:53 +0200 Subject: [PATCH 114/151] Link Que jobs back to the enqueuing trace On perform, read the W3C context Que carries as "key:value" strings in the job's tags and link the job's trace back to the enqueuer, matching OpenTelemetry's Que instrumentation. Collector mode only. --- lib/appsignal/integrations/que.rb | 32 +++++++++++++++++-- spec/lib/appsignal/integrations/que_spec.rb | 34 +++++++++++++++++++++ 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/lib/appsignal/integrations/que.rb b/lib/appsignal/integrations/que.rb index 5e5c54cb6..6e928924b 100644 --- a/lib/appsignal/integrations/que.rb +++ b/lib/appsignal/integrations/que.rb @@ -2,11 +2,40 @@ 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 + + # Read the incoming context off the job's tags. Splits each `"key:value"` + # tag on the first colon back into a carrier hash, then extracts. Returns + # an `OpenTelemetry::Context`, or `nil` outside collector mode. + def extract(tags) + Appsignal::OpenTelemetry.if_started do + carrier = Array(tags) + .map { |tag| tag.split(":", 2) } + .select { |pair| pair.size == 2 } + .to_h + ::OpenTelemetry.propagation.extract(carrier) + end + end + 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 +43,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 { diff --git a/spec/lib/appsignal/integrations/que_spec.rb b/spec/lib/appsignal/integrations/que_spec.rb index 45f6c38e5..f1127bd7c 100644 --- a/spec/lib/appsignal/integrations/que_spec.rb +++ b/spec/lib/appsignal/integrations/que_spec.rb @@ -310,6 +310,40 @@ def perform end end end + + context "with incoming trace context" do + let(:trace_id_hex) { "0af7651916cd43dd8448eb211c80319c" } + let(:span_id_hex) { "b7ad6b7169203331" } + let(:traceparent) { "00-#{trace_id_hex}-#{span_id_hex}-01" } + # OpenTelemetry's Que instrumentation carries the trace context as + # "key:value" tag strings under the job's `data` attribute. + let(:job_attrs) do + super().merge(:data => { :tags => ["traceparent:#{traceparent}"] }) + end + + def perform + perform_que_job(instance) + end + + it "in agent mode", :agent_mode do + start_agent + expect { perform }.to change { created_transactions.length }.by(1) + expect(last_transaction).to be_completed + end + + 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 From 01af8d6aa64d4531af3b6e74c69af6775f98ef48 Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Thu, 25 Jun 2026 17:06:52 +0200 Subject: [PATCH 115/151] Propagate trace context on Que enqueues Wrap Que enqueues with a producer event and, in collector mode, write the current trace context onto the job's tags (the only carrier Que exposes), matching OpenTelemetry's Que instrumentation. Unlike OTel, skip propagation when it would exceed Que's tag count/length limit rather than let the enqueue raise. The event only records with an active transaction. bulk_enqueue and Que 1 enqueue are not covered yet. --- lib/appsignal/hooks/que.rb | 5 - lib/appsignal/integrations/que.rb | 108 ++++++++---------- spec/lib/appsignal/integrations/que_spec.rb | 115 +++++++------------- 3 files changed, 89 insertions(+), 139 deletions(-) diff --git a/lib/appsignal/hooks/que.rb b/lib/appsignal/hooks/que.rb index 22b77e5a1..871fdca84 100644 --- a/lib/appsignal/hooks/que.rb +++ b/lib/appsignal/hooks/que.rb @@ -15,11 +15,6 @@ def install ::Que::Job.prepend Appsignal::Integrations::QuePlugin ::Que::Job.singleton_class.prepend Appsignal::Integrations::QueClientPlugin - # `bulk_enqueue` exists only on Que 2+; don't define one where it's absent. - if ::Que::Job.respond_to?(:bulk_enqueue) - ::Que::Job.singleton_class.prepend Appsignal::Integrations::QueBulkClientPlugin - end - ::Que.error_notifier = proc do |error, _job| Appsignal::Transaction.current.set_error(error) end diff --git a/lib/appsignal/integrations/que.rb b/lib/appsignal/integrations/que.rb index 6e928924b..babbe2773 100644 --- a/lib/appsignal/integrations/que.rb +++ b/lib/appsignal/integrations/que.rb @@ -10,6 +10,22 @@ module Integrations module QueTraceContext module_function + # Que has no header map, so context rides in the tags array. OTel writes + # each header as a `"key:value"` tag; mirror that exact format. + module TagSetter + def self.set(carrier, key, value) + carrier << "#{key}:#{value}" + end + end + + # Que rejects jobs with too many or too-long tags, so injected context + # must stay within these or the enqueue would raise. Read the limits from + # Que when available, with the documented defaults as a fallback. + MAX_TAGS_COUNT = + defined?(::Que::Job::MAXIMUM_TAGS_COUNT) ? ::Que::Job::MAXIMUM_TAGS_COUNT : 5 + MAX_TAG_LENGTH = + defined?(::Que::Job::MAXIMUM_TAG_LENGTH) ? ::Que::Job::MAXIMUM_TAG_LENGTH : 100 + # Read the incoming context off the job's tags. Splits each `"key:value"` # tag on the first colon back into a carrier hash, then extracts. Returns # an `OpenTelemetry::Context`, or `nil` outside collector mode. @@ -22,6 +38,27 @@ def extract(tags) ::OpenTelemetry.propagation.extract(carrier) end end + + # Returns the tags array to enqueue the job with. In collector mode injects + # the current context into a copy of the tags; keeps the result only if it + # still fits Que's limits, otherwise returns the original tags unchanged -- + # we skip propagation rather than break the user's enqueue. Outside + # collector mode returns the tags unchanged. + def inject(tags) + original = Array(tags) + injected = Appsignal::OpenTelemetry.if_started do + copy = original.dup + ::OpenTelemetry.propagation.inject(copy, :setter => TagSetter) + copy + end + return original if injected.nil? || !within_limits?(injected) + + injected + end + + def within_limits?(tags) + tags.length <= MAX_TAGS_COUNT && tags.all? { |tag| tag.length <= MAX_TAG_LENGTH } + end end # @!visibility private @@ -65,69 +102,20 @@ def _run(*args) # @!visibility private # - # Prepended to `Que::Job`'s singleton so it records each enqueue as an - # `enqueue.que` event under the active transaction. Like all AppSignal - # events, it only records when there's an active transaction (e.g. enqueuing - # from within a web request or another job); otherwise it's a transparent - # pass-through. + # Prepended to `Que::Job`'s singleton so it wraps enqueues. Records the + # enqueue as an AppSignal event (a producer span in collector mode), and in + # collector mode writes the current trace context onto the job's tags so the + # job that later performs links back to it. Like all AppSignal events, the + # enqueue only records when there's an active transaction; otherwise it's a + # transparent pass-through. module QueClientPlugin - def enqueue(*_args, job_options: {}, **_rest) - # Inside a `bulk_enqueue` block the batch is recorded once by the - # `bulk_enqueue` wrapper, so each inner enqueue is a pass-through to - # avoid recording an event per job. - return super if Thread.current[:appsignal_que_bulk_enqueue] - - # Under Active Job the enqueue is already recorded as an - # `enqueue.active_job` event, so skip recording it again here. - return super if Appsignal::Transaction.current? && - Appsignal::Transaction.current.job_enqueue_events_suppressed? - - # Resolve the job class the way Que does: an explicit `:job_class`, else - # the class `enqueue` was called on. - title = "enqueue #{job_options[:job_class] || name} job" - Appsignal.instrument("enqueue.que", title) { super } - end - end - - # @!visibility private - # - # `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. - 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 - # 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 - ensure - Thread.current[:appsignal_que_bulk_enqueue] = was_bulk - end + def enqueue(*args, job_options: {}, **rest) + Appsignal.instrument("enqueue_job.que", :opentelemetry_kind => :producer) do + tags = QueTraceContext.inject(job_options[:tags]) + merged = tags.empty? ? job_options : job_options.merge(:tags => tags) + super(*args, :job_options => merged, **rest) end end - - private - - # The batch's job class is known up front only from an explicit - # `:job_class` or when `bulk_enqueue` is called on a concrete subclass; - # called on `Que::Job` itself the class isn't known until the inner - # enqueues run, so the title is left class-less. - def bulk_enqueue_title(job_options) - job_class = job_options[:job_class] - job_class ||= name unless equal?(::Que::Job) - return "bulk enqueue jobs" unless job_class - - "bulk enqueue #{job_class} jobs" - end end end end diff --git a/spec/lib/appsignal/integrations/que_spec.rb b/spec/lib/appsignal/integrations/que_spec.rb index f1127bd7c..38406a82e 100644 --- a/spec/lib/appsignal/integrations/que_spec.rb +++ b/spec/lib/appsignal/integrations/que_spec.rb @@ -363,16 +363,12 @@ def self.name captured[:values] = values if command == :insert_job [{}] end - - start_agent end - around { |example| keep_transactions { example.run } } - # `data` is the last value Que passes to its `:insert_job` query on both Que - # 1 and Que 2 (Que 2 inserts `kwargs` before it, shifting its index); the - # tags live under it as a JSON string. + # `data` is the 7th value Que passes to its `:insert_job` query; the tags + # live under it as a JSON string. def enqueued_tags - data = captured[:values]&.last + data = captured[:values] && captured[:values][6] data ? JSON.parse(data)["tags"] : nil end @@ -381,92 +377,63 @@ def enqueue(tags: ["user:42"]) end context "with an active transaction" do - it "records an enqueue event and leaves the job's tags untouched" do + it "in agent mode", :agent_mode do + start_agent transaction = http_request_transaction set_current_transaction(transaction) enqueue - # Records an enqueue event on the transaction, titled after the job. - 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") + event_names = transaction.to_h["events"].map { |event| event["name"] } + expect(event_names).to include("enqueue_job.que") + # No wire context in agent mode; only the user's own tag persists. expect(enqueued_tags).to eq(["user:42"]) end - end - - context "without an active transaction" do - it "is a transparent pass-through" do - expect { enqueue }.to_not raise_error - expect(enqueued_tags).to eq(["user:42"]) - end - end - - context "when job enqueue events are suppressed" do - # As happens under Active Job, which records the enqueue itself. - it "passes through without recording the enqueue" do + it "in collector mode", :collector_mode do + start_collector_agent transaction = http_request_transaction set_current_transaction(transaction) - transaction.suppress_job_enqueue_events { enqueue } - - # The outer integration records the enqueue, so this one doesn't. - event_names = transaction.to_h["events"].map { |event| event["name"] } - expect(event_names).to_not include("enqueue.que") - end - end - - # `bulk_enqueue` is Que 2 only. The whole batch records a single - # `bulk_enqueue.que` event; 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 - # constant (the single-enqueue path uses `new` and doesn't). - stub_const("MyQueJob", job) - allow(Que).to receive(:transaction).and_yield - allow(Que).to receive(:execute) do |command, values| - captured[:values] = values if command == :bulk_insert_jobs - [{}] - end - end - - def bulk_enqueue(tags: ["user:42"]) - job.bulk_enqueue(:job_options => { :tags => tags }) do - job.enqueue("post_id_123") - job.enqueue("post_id_456") - end + enqueue + Appsignal::Transaction.complete_current! + + # The enqueue is a producer event span under the active transaction. + producer = event_spans.find { |s| s.name == "enqueue_job.que" } + expect(producer.kind).to eq(:producer) + expect(producer.parent_span_id).to eq(root_span.span_id) + + # The job carries the producer span's context as a traceparent tag, + # alongside the user's own tag. + expect(enqueued_tags).to include("user:42") + expect(enqueued_tags) + .to include("traceparent:00-#{producer.hex_trace_id}-#{producer.hex_span_id}-01") end - it "records one bulk_enqueue event for the whole batch" do - transaction = http_request_transaction - set_current_transaction(transaction) + it "skips propagation rather than break the enqueue when tags are full", + :collector_mode do + start_collector_agent + set_current_transaction(http_request_transaction) - bulk_enqueue + # 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! - # 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"]) + 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 - transaction = http_request_transaction - set_current_transaction(transaction) + context "without an active transaction" do + it "in collector mode", :collector_mode do + start_collector_agent - transaction.suppress_job_enqueue_events { bulk_enqueue } + enqueue - # 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 + # No transaction to attach to: nothing recorded, nothing injected. + expect(span_exporter.finished_spans.map(&:name)).to_not include("enqueue_job.que") + expect(enqueued_tags).to eq(["user:42"]) end end end From e39c7ce7aeb8e204ca71941dc7f1b286ae9c3121 Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Thu, 25 Jun 2026 17:18:58 +0200 Subject: [PATCH 116/151] Unify enqueue event names and OTel start gating Rename the Resque and Que enqueue events to enqueue.resque and enqueue.que so all four job integrations share the enqueue. shape (after the native enqueue.active_job event). Route the ActiveJob serialize patch through OpenTelemetry.if_started so every OTel start check goes through one helper. --- lib/appsignal/hooks/active_job.rb | 7 ++++--- lib/appsignal/integrations/que.rb | 2 +- lib/appsignal/integrations/resque.rb | 4 ++-- spec/lib/appsignal/integrations/que_spec.rb | 6 +++--- spec/lib/appsignal/integrations/resque_spec.rb | 6 +++--- 5 files changed, 13 insertions(+), 12 deletions(-) diff --git a/lib/appsignal/hooks/active_job.rb b/lib/appsignal/hooks/active_job.rb index 2e45a6690..3fea305ae 100644 --- a/lib/appsignal/hooks/active_job.rb +++ b/lib/appsignal/hooks/active_job.rb @@ -163,10 +163,11 @@ def enqueue(*) def serialize super.tap do |data| - next unless Appsignal::OpenTelemetry.started? - next if __otel_headers.empty? + Appsignal::OpenTelemetry.if_started do + next if __otel_headers.empty? - data["__otel_headers"] = ::ActiveJob::Arguments.serialize(__otel_headers) + data["__otel_headers"] = ::ActiveJob::Arguments.serialize(__otel_headers) + end end end diff --git a/lib/appsignal/integrations/que.rb b/lib/appsignal/integrations/que.rb index babbe2773..c02d90ce7 100644 --- a/lib/appsignal/integrations/que.rb +++ b/lib/appsignal/integrations/que.rb @@ -110,7 +110,7 @@ def _run(*args) # transparent pass-through. module QueClientPlugin def enqueue(*args, job_options: {}, **rest) - Appsignal.instrument("enqueue_job.que", :opentelemetry_kind => :producer) do + Appsignal.instrument("enqueue.que", :opentelemetry_kind => :producer) do tags = QueTraceContext.inject(job_options[:tags]) merged = tags.empty? ? job_options : job_options.merge(:tags => tags) super(*args, :job_options => merged, **rest) diff --git a/lib/appsignal/integrations/resque.rb b/lib/appsignal/integrations/resque.rb index 3da74cc1e..d28f56694 100644 --- a/lib/appsignal/integrations/resque.rb +++ b/lib/appsignal/integrations/resque.rb @@ -30,7 +30,7 @@ def perform end end - # Wraps `Resque.push` to record an `enqueue_job.resque` event so the + # 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. @@ -42,7 +42,7 @@ def perform # @!visibility private module ResquePushIntegration def push(queue, item) - Appsignal.instrument("enqueue_job.resque", :opentelemetry_kind => :producer) do + Appsignal.instrument("enqueue.resque", :opentelemetry_kind => :producer) do Appsignal::OpenTelemetry.inject_context(item) super end diff --git a/spec/lib/appsignal/integrations/que_spec.rb b/spec/lib/appsignal/integrations/que_spec.rb index 38406a82e..8db5284b5 100644 --- a/spec/lib/appsignal/integrations/que_spec.rb +++ b/spec/lib/appsignal/integrations/que_spec.rb @@ -385,7 +385,7 @@ def enqueue(tags: ["user:42"]) enqueue event_names = transaction.to_h["events"].map { |event| event["name"] } - expect(event_names).to include("enqueue_job.que") + expect(event_names).to include("enqueue.que") # No wire context in agent mode; only the user's own tag persists. expect(enqueued_tags).to eq(["user:42"]) end @@ -399,7 +399,7 @@ def enqueue(tags: ["user:42"]) Appsignal::Transaction.complete_current! # The enqueue is a producer event span under the active transaction. - producer = event_spans.find { |s| s.name == "enqueue_job.que" } + producer = event_spans.find { |s| s.name == "enqueue.que" } expect(producer.kind).to eq(:producer) expect(producer.parent_span_id).to eq(root_span.span_id) @@ -432,7 +432,7 @@ def enqueue(tags: ["user:42"]) enqueue # No transaction to attach to: nothing recorded, nothing injected. - expect(span_exporter.finished_spans.map(&:name)).to_not include("enqueue_job.que") + expect(span_exporter.finished_spans.map(&:name)).to_not include("enqueue.que") expect(enqueued_tags).to eq(["user:42"]) end end diff --git a/spec/lib/appsignal/integrations/resque_spec.rb b/spec/lib/appsignal/integrations/resque_spec.rb index e203cc6df..d7f49a834 100644 --- a/spec/lib/appsignal/integrations/resque_spec.rb +++ b/spec/lib/appsignal/integrations/resque_spec.rb @@ -246,7 +246,7 @@ def enqueue # Records an enqueue event on the transaction; no wire context in agent mode. event_names = transaction.to_h["events"].map { |event| event["name"] } - expect(event_names).to include("enqueue_job.resque") + expect(event_names).to include("enqueue.resque") expect(item).to_not have_key("traceparent") end @@ -259,7 +259,7 @@ def enqueue Appsignal::Transaction.complete_current! # The enqueue is a producer event span under the active transaction. - producer = event_spans.find { |s| s.name == "enqueue_job.resque" } + producer = event_spans.find { |s| s.name == "enqueue.resque" } expect(producer.kind).to eq(:producer) expect(producer.parent_span_id).to eq(root_span.span_id) @@ -285,7 +285,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_job.resque") + expect(span_exporter.finished_spans.map(&:name)).to_not include("enqueue.resque") expect(item).to_not have_key("traceparent") end end From 901c699f9bf9c1e45a3941ccaf96e740a0e269bd Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Thu, 25 Jun 2026 17:28:44 +0200 Subject: [PATCH 117/151] Route OTel context helpers through if_started inject_context, extract_rack_context and extract_job_context each did their own `return unless started?`. Funnel all three through the if_started gate so every OTel start check goes through one place. --- lib/appsignal/opentelemetry.rb | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/lib/appsignal/opentelemetry.rb b/lib/appsignal/opentelemetry.rb index da4e269ca..77527b27b 100644 --- a/lib/appsignal/opentelemetry.rb +++ b/lib/appsignal/opentelemetry.rb @@ -120,9 +120,9 @@ def started? # that is the AppSignal event span, so the written `traceparent` reflects # it. def inject_context(carrier) - return unless started? - - ::OpenTelemetry.propagation.inject(carrier) + if_started do + ::OpenTelemetry.propagation.inject(carrier) + end end # Read the trace context off an incoming Rack request env using the @@ -133,12 +133,12 @@ def inject_context(carrier) # nothing to continue. `rack_env_getter` reads the `HTTP_*`-mangled header # names Rack puts in the env. def extract_rack_context(env) - return unless started? - - ::OpenTelemetry.propagation.extract( - env, - :getter => ::OpenTelemetry::Common::Propagation.rack_env_getter - ) + if_started do + ::OpenTelemetry.propagation.extract( + env, + :getter => ::OpenTelemetry::Common::Propagation.rack_env_getter + ) + end end # Read the trace context off an incoming background job hash, so a @@ -153,13 +153,13 @@ def extract_rack_context(env) # accept both shapes. The nested keys win when present, since ActiveJob is # the outer, more specific layer. def extract_job_context(item) - return unless started? - - 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) + 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), From e65eb60130f32c9721c0ee3ea4ac5069c8f3fd43 Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Thu, 25 Jun 2026 17:56:01 +0200 Subject: [PATCH 118/151] Guard the Excon integration spec behind the gem The integration spec required excon at the top level, so every gemset without excon failed to load the suite. Wrap it in DependencyHelper. excon_present?, matching the other integration specs, and add the helper. --- spec/lib/appsignal/integrations/excon_spec.rb | 108 +++++++++--------- spec/support/helpers/dependency_helper.rb | 4 - 2 files changed, 55 insertions(+), 57 deletions(-) diff --git a/spec/lib/appsignal/integrations/excon_spec.rb b/spec/lib/appsignal/integrations/excon_spec.rb index 672e57ac6..3a50cf245 100644 --- a/spec/lib/appsignal/integrations/excon_spec.rb +++ b/spec/lib/appsignal/integrations/excon_spec.rb @@ -1,58 +1,60 @@ -require "excon" -require "appsignal/integrations/excon" -require "appsignal/integrations/excon/appsignal_middleware" - -# Integration test against the real Excon gem (the hooks/excon_spec.rb suite -# stubs Excon). Verifies, end to end, that the inject-only middleware writes -# trace context onto a live outgoing request while the instrumentor's client -# event span is current -- the ordering the stubbed suite can't prove. -describe "Excon integration" do - before { Appsignal::Hooks::ExconHook.new.install } - - describe "a GET request" do - def perform - stub_request(:get, "http://www.example.com/") - Excon.get("http://www.example.com/") +if DependencyHelper.excon_present? + require "excon" + require "appsignal/integrations/excon" + require "appsignal/integrations/excon/appsignal_middleware" + + # Integration test against the real Excon gem (the hooks/excon_spec.rb suite + # stubs Excon). Verifies, end to end, that the inject-only middleware writes + # trace context onto a live outgoing request while the instrumentor's client + # event span is current -- the ordering the stubbed suite can't prove. + describe "Excon integration" do + before { Appsignal::Hooks::ExconHook.new.install } + + describe "a GET request" do + def perform + stub_request(:get, "http://www.example.com/") + Excon.get("http://www.example.com/") + end + + it "in agent mode", :agent_mode do + start_agent + transaction = http_request_transaction + set_current_transaction(transaction) + perform + + expect(transaction).to include_event( + "name" => "request.excon", + "title" => "GET http://www.example.com" + ) + end + + it "in collector mode", :collector_mode do + start_collector_agent + transaction = http_request_transaction + set_current_transaction(transaction) + perform + Appsignal::Transaction.complete_current! + + span = event_spans.find { |s| s.attributes["appsignal.category"] == "request.excon" } + expect(span).not_to be_nil + expect(span.kind).to eq(:client) + expect(span.parent_span_id).to eq(root_span.span_id) + + # The injected traceparent must reflect the Excon CLIENT event span, not + # the root span -- proving the middleware runs inside the instrumentor's + # event span on a real request. + expect(injected_traceparent("http://www.example.com/")) + .to eq("00-#{span.hex_trace_id}-#{span.hex_span_id}-01") + end end - it "in agent mode", :agent_mode do - start_agent - transaction = http_request_transaction - set_current_transaction(transaction) - perform - - expect(transaction).to include_event( - "name" => "request.excon", - "title" => "GET http://www.example.com" - ) - end - - it "in collector mode", :collector_mode do - start_collector_agent - transaction = http_request_transaction - set_current_transaction(transaction) - perform - Appsignal::Transaction.complete_current! - - span = event_spans.find { |s| s.attributes["appsignal.category"] == "request.excon" } - expect(span).not_to be_nil - expect(span.kind).to eq(:client) - expect(span.parent_span_id).to eq(root_span.span_id) - - # The injected traceparent must reflect the Excon CLIENT event span, not - # the root span -- proving the middleware runs inside the instrumentor's - # event span on a real request. - expect(injected_traceparent("http://www.example.com/")) - .to eq("00-#{span.hex_trace_id}-#{span.hex_span_id}-01") + # 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 - - # Reads the `traceparent` header off the recorded outgoing request to `url`. - def injected_traceparent(url) - traceparent = nil - expect( - a_request(:get, url).with { |request| traceparent = request.headers["Traceparent"] } - ).to have_been_made - traceparent - end end diff --git a/spec/support/helpers/dependency_helper.rb b/spec/support/helpers/dependency_helper.rb index a30fb3ee4..2625f3917 100644 --- a/spec/support/helpers/dependency_helper.rb +++ b/spec/support/helpers/dependency_helper.rb @@ -153,10 +153,6 @@ def excon_present? dependency_present? "excon" end - def faraday_present? - dependency_present? "faraday" - end - def http_present? dependency_present? "http" end From 618260ba540af1035be0a1d919c12d7879702930 Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Thu, 25 Jun 2026 17:56:01 +0200 Subject: [PATCH 119/151] Pin rubocop-ast below 1.49 to keep RuboCop green rubocop-ast 1.49 removed EnsureNode#body, which the pinned RuboCop 1.64.1 still calls, so RuboCop errors on any file with an ensure block. Hold rubocop-ast back until RuboCop can be upgraded. --- Gemfile | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Gemfile b/Gemfile index 0faf63b60..feb64469a 100644 --- a/Gemfile +++ b/Gemfile @@ -5,6 +5,10 @@ source "https://rubygems.org" gemspec gem "benchmark-ips" +# rubocop-ast 1.49 removed EnsureNode#body, which the pinned RuboCop 1.64.1 +# still calls; it errors on any file with an ensure block. Hold rubocop-ast +# back until RuboCop is upgraded. +gem "rubocop-ast", "< 1.49" gem "rbs", "4.0.2" if RUBY_VERSION.start_with?("4.0") # Fix install issue for jruby on gem 3.1.8. # No java stub is published. From c73d09d049a64739917e47cd2bc907242d19cf7d Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Thu, 25 Jun 2026 18:31:40 +0200 Subject: [PATCH 120/151] Silence ParameterLists on record_event defs record_event gained an opentelemetry_kind keyword, pushing it to six parameters. Disable the cop inline, as the JRuby backend's record_event already does. --- lib/appsignal/transaction.rb | 2 +- lib/appsignal/transaction/base_backend.rb | 2 +- lib/appsignal/transaction/extension_backend.rb | 2 +- lib/appsignal/transaction/opentelemetry_backend.rb | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/appsignal/transaction.rb b/lib/appsignal/transaction.rb index 3a85e7922..7e9714f79 100644 --- a/lib/appsignal/transaction.rb +++ b/lib/appsignal/transaction.rb @@ -666,7 +666,7 @@ def finish_event(name, title, body, body_format = Appsignal::EventFormatter::DEF # @!visibility private # @see Helpers::Instrumentation#instrument - def record_event( + def record_event( # rubocop:disable Metrics/ParameterLists name, title, body, diff --git a/lib/appsignal/transaction/base_backend.rb b/lib/appsignal/transaction/base_backend.rb index 7d31ec024..c9d94cbc2 100644 --- a/lib/appsignal/transaction/base_backend.rb +++ b/lib/appsignal/transaction/base_backend.rb @@ -18,7 +18,7 @@ def finish_event(_name, _title, _body, _body_format) raise NotImplementedError end - def record_event(_name, _title, _body, _body_format, _duration, opentelemetry_kind: nil) + def record_event(_name, _title, _body, _body_format, _duration, opentelemetry_kind: nil) # rubocop:disable Metrics/ParameterLists raise NotImplementedError end diff --git a/lib/appsignal/transaction/extension_backend.rb b/lib/appsignal/transaction/extension_backend.rb index 57cac2b50..2e9889257 100644 --- a/lib/appsignal/transaction/extension_backend.rb +++ b/lib/appsignal/transaction/extension_backend.rb @@ -40,7 +40,7 @@ def finish_event(name, title, body, body_format) 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 + 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 diff --git a/lib/appsignal/transaction/opentelemetry_backend.rb b/lib/appsignal/transaction/opentelemetry_backend.rb index 1b6fadab2..717a188ab 100644 --- a/lib/appsignal/transaction/opentelemetry_backend.rb +++ b/lib/appsignal/transaction/opentelemetry_backend.rb @@ -98,7 +98,7 @@ 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) + 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, From 049a4c30d3f822fd551ef4a8a79645268657116f Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Thu, 25 Jun 2026 18:31:40 +0200 Subject: [PATCH 121/151] Fix Excon hook spec middleware instantiation Excon's Middleware::Base requires the next middleware as an argument, so the spec must pass one. It was calling new with no arguments, which fails against real Excon. --- spec/lib/appsignal/hooks/excon_spec.rb | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/spec/lib/appsignal/hooks/excon_spec.rb b/spec/lib/appsignal/hooks/excon_spec.rb index 5d4f26820..de8654ccc 100644 --- a/spec/lib/appsignal/hooks/excon_spec.rb +++ b/spec/lib/appsignal/hooks/excon_spec.rb @@ -72,7 +72,15 @@ def perform # Runs the AppSignal Excon middleware's `request_call` over an empty # datum, returning the datum so we can read the injected headers. def inject_with_middleware - middleware = Appsignal::Integrations::ExconMiddleware.new + # Excon middlewares wrap the next one in the stack and forward to it, + # so pass a tail that just returns the datum. Excon's Middleware::Base + # requires this argument. + tail = Class.new do + def request_call(datum) + datum + end + end.new + middleware = Appsignal::Integrations::ExconMiddleware.new(tail) middleware.request_call({}) end From aacc6aefde7a4274443bd6b975b77f53ffeb90e8 Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Thu, 25 Jun 2026 18:31:40 +0200 Subject: [PATCH 122/151] Skip Que enqueue specs on Que 1 The enqueue specs read trace context from the job tags using Que 2's storage layout, which Que 1 does not share. Que 1 enqueue propagation isn't supported. --- spec/lib/appsignal/integrations/que_spec.rb | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/spec/lib/appsignal/integrations/que_spec.rb b/spec/lib/appsignal/integrations/que_spec.rb index 8db5284b5..cc08fe67d 100644 --- a/spec/lib/appsignal/integrations/que_spec.rb +++ b/spec/lib/appsignal/integrations/que_spec.rb @@ -347,7 +347,9 @@ def perform end end - describe Appsignal::Integrations::QueClientPlugin do + # Enqueue-side propagation reads context from the job's tags, which only Que 2 + # persists in the layout these tests inspect; Que 1 enqueue is not covered. + describe Appsignal::Integrations::QueClientPlugin, :if => DependencyHelper.que2_present? do let(:job) do Class.new(::Que::Job) do def self.name From 2788f24898de2af9bedb0d9eb8c9695821d013ef Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Thu, 25 Jun 2026 18:33:55 +0200 Subject: [PATCH 123/151] Keep the Gemfile gems alphabetically sorted Bundler/OrderedGems sorts gems within each comment-separated group. Move the rubocop-ast pin into its own trailing group so it doesn't sit out of order before rbs. --- Gemfile | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Gemfile b/Gemfile index feb64469a..1c874b6cf 100644 --- a/Gemfile +++ b/Gemfile @@ -5,11 +5,11 @@ source "https://rubygems.org" gemspec gem "benchmark-ips" -# rubocop-ast 1.49 removed EnsureNode#body, which the pinned RuboCop 1.64.1 -# still calls; it errors on any file with an ensure block. Hold rubocop-ast -# back until RuboCop is upgraded. -gem "rubocop-ast", "< 1.49" gem "rbs", "4.0.2" if RUBY_VERSION.start_with?("4.0") # Fix install issue for jruby on gem 3.1.8. # No java stub is published. gem "bigdecimal", "3.1.7" if RUBY_PLATFORM == "java" +# rubocop-ast 1.49 removed EnsureNode#body, which the pinned RuboCop 1.64.1 +# still calls; it errors on any file with an ensure block. Hold rubocop-ast +# back until RuboCop is upgraded. +gem "rubocop-ast", "< 1.49" From 09df6219648903ec82aac17aa78cdd49fd223d99 Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Fri, 26 Jun 2026 12:21:47 +0200 Subject: [PATCH 124/151] Remove the rubocop-ast version pin The pin claimed rubocop-ast 1.49 removed EnsureNode#body, breaking RuboCop on any file with an ensure block. It doesn't: #body is only deprecated (it delegates to #branch) and is identical in 1.48 and 1.49, so RuboCop exits 0 either way. The pin silenced nothing -- the deprecation warnings come from RuboCop 1.64.1 itself calling the deprecated method, and 1.48 emits them too. Drop the dead pin. [skip changeset] --- Gemfile | 4 ---- 1 file changed, 4 deletions(-) diff --git a/Gemfile b/Gemfile index 1c874b6cf..0faf63b60 100644 --- a/Gemfile +++ b/Gemfile @@ -9,7 +9,3 @@ gem "rbs", "4.0.2" if RUBY_VERSION.start_with?("4.0") # Fix install issue for jruby on gem 3.1.8. # No java stub is published. gem "bigdecimal", "3.1.7" if RUBY_PLATFORM == "java" -# rubocop-ast 1.49 removed EnsureNode#body, which the pinned RuboCop 1.64.1 -# still calls; it errors on any file with an ensure block. Hold rubocop-ast -# back until RuboCop is upgraded. -gem "rubocop-ast", "< 1.49" From c0eda2810f6812ee6e0dd98b2d44c0163f506df0 Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Fri, 3 Jul 2026 14:08:54 +0200 Subject: [PATCH 125/151] Address review on Active Job enqueue refactor Forward all arguments through the enqueue wrapper so keyword args and blocks are preserved on Ruby 2.7+, and convert the event name to a string once in record_event? before indexing it. --- lib/appsignal/hooks/active_job.rb | 2 +- lib/appsignal/integrations/active_support_notifications.rb | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/appsignal/hooks/active_job.rb b/lib/appsignal/hooks/active_job.rb index 3fea305ae..26f7660e1 100644 --- a/lib/appsignal/hooks/active_job.rb +++ b/lib/appsignal/hooks/active_job.rb @@ -154,7 +154,7 @@ module ActiveJobTraceContext # 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(*) + def enqueue(*, **) Appsignal.instrument("enqueue.active_job", :opentelemetry_kind => :producer) do Appsignal::OpenTelemetry.inject_context(__otel_headers) super diff --git a/lib/appsignal/integrations/active_support_notifications.rb b/lib/appsignal/integrations/active_support_notifications.rb index 6d7ef1bb3..23a8e9233 100644 --- a/lib/appsignal/integrations/active_support_notifications.rb +++ b/lib/appsignal/integrations/active_support_notifications.rb @@ -46,7 +46,8 @@ def finish_event(name, payload = {}) # are recorded elsewhere. Both `start_event` and `finish_event` gate on # this so the event stack stays balanced. def record_event?(name) - name[0] != BANG && !SUPPRESSED_EVENT_NAMES.include?(name.to_s) + name = name.to_s + name[0] != BANG && !SUPPRESSED_EVENT_NAMES.include?(name) end end From c01619bcf265bae99313c1b6a91bfd12b5b31a57 Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Thu, 25 Jun 2026 21:20:00 +0200 Subject: [PATCH 126/151] Add a Faraday integration Auto-install, onto every Faraday connection, Faraday's instrumentation middleware (so the request.faraday event fires without the user adding it) and an inject-only middleware that writes W3C trace context onto outgoing requests in collector mode. Tag the request.faraday event as a client span. Faraday has no global default middleware stack, so the build path is patched to reach every connection. --- .changesets/add-faraday-integration.md | 4 +- .../active_support_notifications.rb | 11 ++-- lib/appsignal/integrations/faraday.rb | 53 ++++++++++--------- 3 files changed, 36 insertions(+), 32 deletions(-) diff --git a/.changesets/add-faraday-integration.md b/.changesets/add-faraday-integration.md index a4e537141..6f6441d41 100644 --- a/.changesets/add-faraday-integration.md +++ b/.changesets/add-faraday-integration.md @@ -3,4 +3,6 @@ bump: minor type: add --- -Improve Faraday support. AppSignal now instruments Faraday requests automatically, without double-instrumenting the underlying HTTP client. Turn it off with the `instrument_faraday` option. +Add a Faraday integration. AppSignal now automatically instruments Faraday requests, recording a `request.faraday` event without you having to add Faraday's instrumentation middleware yourself. In collector mode, outgoing Faraday requests carry W3C trace context so the called service joins the same distributed trace. + +The integration is enabled by default and can be turned off with the `instrument_faraday` configuration option (`APPSIGNAL_INSTRUMENT_FARADAY` environment variable). diff --git a/lib/appsignal/integrations/active_support_notifications.rb b/lib/appsignal/integrations/active_support_notifications.rb index 23a8e9233..b8b133c38 100644 --- a/lib/appsignal/integrations/active_support_notifications.rb +++ b/lib/appsignal/integrations/active_support_notifications.rb @@ -8,12 +8,13 @@ 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 + # call (to a datastore or another service), so they carry CLIENT kind in + # collector mode (to match the dedicated 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 + # `request.faraday` is Faraday's outgoing HTTP request event. + CLIENT_EVENT_NAMES = ["sql.active_record", "request.faraday"].freeze # Events a dedicated AppSignal integration already records with richer # semantics, so the generic notifications path must not record them a diff --git a/lib/appsignal/integrations/faraday.rb b/lib/appsignal/integrations/faraday.rb index af5d253df..ad6eb9606 100644 --- a/lib/appsignal/integrations/faraday.rb +++ b/lib/appsignal/integrations/faraday.rb @@ -2,32 +2,20 @@ 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 writes trace context onto the outgoing request, so + # the called service joins this trace. The `request.faraday` event recorded + # by Faraday's own instrumentation middleware provides the span; this + # middleware only injects. # # @!visibility private class FaradayMiddleware < ::Faraday::Middleware - def call(env) - http_method = env[:method].to_s.upcase - uri = env[:url] - # Title only, no body: the path is left out so the event matches - # Net::HTTP's (scheme and host only), keeping paths out of event titles. - Appsignal.instrument( - "request.faraday", - "#{http_method} #{uri.scheme}://#{uri.host}" - ) do - # Faraday's default adapter is Net::HTTP, which AppSignal also - # instruments. Suppress the adapter's own instrumentation so the - # request appears once (as the Faraday event) rather than as nested - # Faraday + Net::HTTP client events. - if Appsignal::Transaction.current? - Appsignal::Transaction.current.suppress_http_client_events { @app.call(env) } - else - @app.call(env) - end - end + def on_request(env) + # Inject from whatever span is current. Faraday's instrumentation + # middleware wraps this call in the `request.faraday` event, so its event + # span is current and the written `traceparent` reflects the Faraday + # client event. 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) end end @@ -37,13 +25,26 @@ 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: + # + # - `Faraday::Request::Instrumentation`, so the `request.faraday` event fires + # without the user adding it themselves -- but only when + # ActiveSupport::Notifications is loaded, since that middleware references it + # at build time. Skipped if the user already added it. + # - `FaradayMiddleware`, which injects trace context. Added after + # Instrumentation so it runs inside that event's span. # # @!visibility private module FaradayRackBuilderPatch def adapter(*) - use(FaradayMiddleware) unless handlers.any? { |handler| handler.klass == FaradayMiddleware } + unless handlers.any? { |handler| handler.klass == FaradayMiddleware } + if defined?(::ActiveSupport::Notifications) && + defined?(::Faraday::Request::Instrumentation) && + handlers.none? { |handler| handler.klass == ::Faraday::Request::Instrumentation } + use(::Faraday::Request::Instrumentation) + end + use(FaradayMiddleware) + end super end end From 01a6d47275fcb365493a8f8ecb7966067b106c9c Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Thu, 25 Jun 2026 21:20:08 +0200 Subject: [PATCH 127/151] Test the Faraday integration on Faraday 1 and 2 Add gemfiles and CI matrix entries for both Faraday majors, plus a dual-mode spec. The collector-mode examples drive real requests to prove the middleware ordering: the default Net::HTTP adapter nests the spans correctly, and a non-Net::HTTP adapter shows our middleware injecting the Faraday client context. ActiveSupport is added to the gemfiles so the request.faraday event fires. --- build_matrix.yml | 10 ++ gemfiles/faraday-1-collector.gemfile | 6 + gemfiles/faraday-1.gemfile | 1 + gemfiles/faraday-2-collector.gemfile | 6 + gemfiles/faraday-2.gemfile | 3 +- .../appsignal/integrations/faraday_spec.rb | 104 ++++++++++++------ spec/support/helpers/dependency_helper.rb | 4 + 7 files changed, 96 insertions(+), 38 deletions(-) create mode 100644 gemfiles/faraday-1-collector.gemfile create mode 100644 gemfiles/faraday-2-collector.gemfile diff --git a/build_matrix.yml b/build_matrix.yml index c29ad3d4b..dc383a8e0 100644 --- a/build_matrix.yml +++ b/build_matrix.yml @@ -163,6 +163,16 @@ matrix: - "3.1.6" - "3.0.7" - gem: "excon" + - gem: "faraday-1" + - gem: "faraday-2" + only: + ruby: + - "4.0.0" + - "3.4.1" + - "3.3.4" + - "3.2.5" + - "3.1.6" + - "3.0.7" - gem: "grape" - gem: "hanami-2.0" only: 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-1.gemfile b/gemfiles/faraday-1.gemfile index 39c2d41c1..a1b2b5354 100644 --- a/gemfiles/faraday-1.gemfile +++ b/gemfiles/faraday-1.gemfile @@ -1,5 +1,6 @@ source "https://rubygems.org" +gem "activesupport", :require => "active_support" gem "faraday", "~> 1.0" gemspec :path => "../" diff --git a/gemfiles/faraday-2-collector.gemfile b/gemfiles/faraday-2-collector.gemfile new file mode 100644 index 000000000..4af7281ed --- /dev/null +++ b/gemfiles/faraday-2-collector.gemfile @@ -0,0 +1,6 @@ +# DO NOT EDIT +# This is a generated file by the `rake build_matrix:gemfiles:generate` task. +# It layers the optional OpenTelemetry gems (gemfiles/collector.rb) on top of faraday-2.gemfile. + +eval_gemfile File.expand_path("faraday-2.gemfile", __dir__) +eval_gemfile File.expand_path("collector.rb", __dir__) diff --git a/gemfiles/faraday-2.gemfile b/gemfiles/faraday-2.gemfile index 00a571da9..56af0241f 100644 --- a/gemfiles/faraday-2.gemfile +++ b/gemfiles/faraday-2.gemfile @@ -1,7 +1,6 @@ source "https://rubygems.org" -gem "excon" +gem "activesupport", :require => "active_support" 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 46d3658dd..92b1bf955 100644 --- a/spec/lib/appsignal/integrations/faraday_spec.rb +++ b/spec/lib/appsignal/integrations/faraday_spec.rb @@ -1,69 +1,101 @@ 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). + # Integration test against the real Faraday gem. The hook auto-installs, onto + # every connection, Faraday's instrumentation middleware (so the + # `request.faraday` event fires without the user adding it) and an inject-only + # middleware (so outgoing requests carry trace context). 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 common case: the default adapter is Net::HTTP, which AppSignal already + # instruments. So a Faraday request nests two client spans -- the + # `request.faraday` event around the `request.net_http` event -- and Net::HTTP + # (the innermost) writes the final `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) perform - # Title only, no body -- the path is left out, matching Net::HTTP. expect(transaction).to include_event( "name" => "request.faraday", "title" => "GET http://www.example.com", - "body" => "" + "body" => "GET http://www.example.com/" ) - # 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") + net_http_span = event_span("request.net_http") + + 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 runs inside the Faraday event, so its span nests under it. + expect(net_http_span).not_to be_nil + expect(net_http_span.parent_span_id).to eq(faraday_span.span_id) + + # Net::HTTP injects last (innermost), so the wire traceparent reflects it. + expect(injected_traceparent("http://www.example.com/")) + .to eq("00-#{net_http_span.hex_trace_id}-#{net_http_span.hex_span_id}-01") end end - # Excon is also a Faraday adapter, and AppSignal instruments it through - # Excon's instrumentor. Faraday suppresses it too, so the request is recorded - # once -- as the `request.faraday` event. - describe "a request over the Excon adapter", :if => DependencyHelper.excon_present? do - before { Appsignal::Hooks::ExconHook.new.install } + # With a non-Net::HTTP adapter (here Faraday's test adapter), our inject + # middleware is the only thing writing context, so the request carries the + # `request.faraday` client span's traceparent -- proving the middleware runs + # and injects inside that event's span. This is the path that gives Faraday + # propagation for adapters AppSignal doesn't instrument directly. + it "injects the Faraday client context on a non-Net::HTTP adapter", :collector_mode do + start_collector_agent + transaction = http_request_transaction + set_current_transaction(transaction) - def perform - stub_request(:get, "http://www.example.com/") - connection = Faraday.new("http://www.example.com") do |faraday| - faraday.adapter :excon + captured_env = nil + connection = Faraday.new("http://www.example.com") do |faraday| + faraday.adapter :test do |stub| + stub.get("/") do |env| + captured_env = env + [200, {}, ""] + end end - connection.get("/") end + connection.get("/") + Appsignal::Transaction.complete_current! - it "records the request once, as the Faraday event" do - start_agent - transaction = http_request_transaction - set_current_transaction(transaction) - perform + faraday_span = event_span("request.faraday") + expect(faraday_span).not_to be_nil + expect(captured_env.request_headers["traceparent"]) + .to eq("00-#{faraday_span.hex_trace_id}-#{faraday_span.hex_span_id}-01") + end - expect(transaction).to include_event( - "name" => "request.faraday", - "title" => "GET http://www.example.com", - "body" => "" - ) - # Excon is suppressed under Faraday, so it isn't recorded again. - expect(transaction).to_not include_event("name" => "request.excon") - end + # Finds the recorded event span for an `appsignal.category` (AS::N name). + def event_span(category) + event_spans.find { |span| span.attributes["appsignal.category"] == category } + end + + # Reads the `traceparent` header off the recorded outgoing request to `url`. + def injected_traceparent(url) + traceparent = nil + expect( + a_request(:get, url).with { |request| traceparent = request.headers["Traceparent"] } + ).to have_been_made + traceparent end end end diff --git a/spec/support/helpers/dependency_helper.rb b/spec/support/helpers/dependency_helper.rb index 2625f3917..a30fb3ee4 100644 --- a/spec/support/helpers/dependency_helper.rb +++ b/spec/support/helpers/dependency_helper.rb @@ -153,6 +153,10 @@ def excon_present? dependency_present? "excon" end + def faraday_present? + dependency_present? "faraday" + end + def http_present? dependency_present? "http" end From b13bcdbdcfabbaa174ac2ed11a7e013fbbd5bb32 Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Thu, 25 Jun 2026 22:48:50 +0200 Subject: [PATCH 128/151] Suppress downstream HTTP under Faraday Faraday's default adapter is Net::HTTP, which AppSignal also instruments, so a single request was recorded as nested Faraday and Net::HTTP client events. When Faraday records its own event it now flags the transaction, via the store, so the downstream client skips its event. The request is recorded once, as the Faraday event, which also writes the trace context. --- .changesets/add-faraday-integration.md | 4 +-- lib/appsignal/integrations/faraday.rb | 35 +++++++++++++++---- lib/appsignal/transaction.rb | 5 +-- .../appsignal/integrations/faraday_spec.rb | 20 +++++------ spec/lib/appsignal/transaction_spec.rb | 13 ------- 5 files changed, 40 insertions(+), 37 deletions(-) diff --git a/.changesets/add-faraday-integration.md b/.changesets/add-faraday-integration.md index 6f6441d41..f1af593ff 100644 --- a/.changesets/add-faraday-integration.md +++ b/.changesets/add-faraday-integration.md @@ -3,6 +3,4 @@ bump: minor type: add --- -Add a Faraday integration. AppSignal now automatically instruments Faraday requests, recording a `request.faraday` event without you having to add Faraday's instrumentation middleware yourself. In collector mode, outgoing Faraday requests carry W3C trace context so the called service joins the same distributed trace. - -The integration is enabled by default and can be turned off with the `instrument_faraday` configuration option (`APPSIGNAL_INSTRUMENT_FARADAY` environment variable). +Improve Faraday support. AppSignal now instruments Faraday requests automatically and, in collector mode, propagates trace context to the called service so it joins the same distributed trace. Turn it off with the `instrument_faraday` option. diff --git a/lib/appsignal/integrations/faraday.rb b/lib/appsignal/integrations/faraday.rb index ad6eb9606..3e5e792eb 100644 --- a/lib/appsignal/integrations/faraday.rb +++ b/lib/appsignal/integrations/faraday.rb @@ -5,17 +5,35 @@ module Integrations # Faraday middleware that writes trace context onto the outgoing request, so # the called service joins this trace. The `request.faraday` event recorded # by Faraday's own instrumentation middleware provides the span; this - # middleware only injects. + # middleware injects and, when that event is recorded, suppresses the + # downstream HTTP client's own instrumentation. # # @!visibility private class FaradayMiddleware < ::Faraday::Middleware - def on_request(env) + # `super(app)` passes only the app: Faraday 1's `Middleware#initialize` + # takes the app alone, so forwarding our options hash to it would raise. + def initialize(app, options = {}) + super(app) + @suppress_downstream = options[:suppress_downstream] + end + + def call(env) # Inject from whatever span is current. Faraday's instrumentation # middleware wraps this call in the `request.faraday` event, so its event # span is current and the written `traceparent` reflects the Faraday # client event. 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. When the `request.faraday` event is recorded, suppress the + # adapter's instrumentation so the request appears once (as the Faraday + # event) rather than as nested Faraday + Net::HTTP client events. + if @suppress_downstream && Appsignal::Transaction.current? + Appsignal::Transaction.current.suppress_http_client_events { @app.call(env) } + else + @app.call(env) + end end end @@ -32,18 +50,23 @@ def on_request(env) # ActiveSupport::Notifications is loaded, since that middleware references it # at build time. Skipped if the user already added it. # - `FaradayMiddleware`, which injects trace context. Added after - # Instrumentation so it runs inside that event's span. + # Instrumentation so it runs inside that event's span. It also suppresses + # the downstream client when the Faraday event is recorded -- decided here, + # at build time, so it stays in sync with whether Instrumentation is added. # # @!visibility private module FaradayRackBuilderPatch def adapter(*) unless handlers.any? { |handler| handler.klass == FaradayMiddleware } - if defined?(::ActiveSupport::Notifications) && - defined?(::Faraday::Request::Instrumentation) && + # The `request.faraday` event needs ActiveSupport::Notifications, which + # Faraday's instrumentation middleware references at build time. + records_event = defined?(::ActiveSupport::Notifications) && + defined?(::Faraday::Request::Instrumentation) + if records_event && handlers.none? { |handler| handler.klass == ::Faraday::Request::Instrumentation } use(::Faraday::Request::Instrumentation) end - use(FaradayMiddleware) + use(FaradayMiddleware, { :suppress_downstream => records_event }) end super end diff --git a/lib/appsignal/transaction.rb b/lib/appsignal/transaction.rb index 7e9714f79..d8f97e398 100644 --- a/lib/appsignal/transaction.rb +++ b/lib/appsignal/transaction.rb @@ -292,13 +292,10 @@ def store(key) # integration (Faraday) already records the request, so the same request is # not instrumented twice as nested client events. def suppress_http_client_events - # Restore the previous value rather than forcing `false`, so nested calls - # don't unsuppress while an outer block is still active. - previously_suppressed = store("http_client")[:suppressed] store("http_client")[:suppressed] = true yield ensure - store("http_client")[:suppressed] = previously_suppressed + store("http_client")[:suppressed] = false end # @!visibility private diff --git a/spec/lib/appsignal/integrations/faraday_spec.rb b/spec/lib/appsignal/integrations/faraday_spec.rb index 92b1bf955..fb9e66f7b 100644 --- a/spec/lib/appsignal/integrations/faraday_spec.rb +++ b/spec/lib/appsignal/integrations/faraday_spec.rb @@ -9,10 +9,9 @@ describe "Faraday integration" do before { Appsignal::Hooks::FaradayHook.new.install } - # The common case: the default adapter is Net::HTTP, which AppSignal already - # instruments. So a Faraday request nests two client spans -- the - # `request.faraday` event around the `request.net_http` event -- and Net::HTTP - # (the innermost) writes the final `traceparent`. + # 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`. describe "a request over the default Net::HTTP adapter" do def perform stub_request(:get, "http://www.example.com/") @@ -30,6 +29,8 @@ def perform "title" => "GET http://www.example.com", "body" => "GET http://www.example.com/" ) + # 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 @@ -40,19 +41,16 @@ def perform Appsignal::Transaction.complete_current! faraday_span = event_span("request.faraday") - net_http_span = event_span("request.net_http") - 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 runs inside the Faraday event, so its span nests under it. - expect(net_http_span).not_to be_nil - expect(net_http_span.parent_span_id).to eq(faraday_span.span_id) + # Net::HTTP is suppressed, so there's no nested net_http span. + expect(event_span("request.net_http")).to be_nil - # Net::HTTP injects last (innermost), so the wire traceparent reflects it. + # Faraday writes the wire traceparent (Net::HTTP doesn't run its inject). expect(injected_traceparent("http://www.example.com/")) - .to eq("00-#{net_http_span.hex_trace_id}-#{net_http_span.hex_span_id}-01") + .to eq("00-#{faraday_span.hex_trace_id}-#{faraday_span.hex_span_id}-01") end end diff --git a/spec/lib/appsignal/transaction_spec.rb b/spec/lib/appsignal/transaction_spec.rb index 31dc2efbc..f64bf472e 100644 --- a/spec/lib/appsignal/transaction_spec.rb +++ b/spec/lib/appsignal/transaction_spec.rb @@ -847,19 +847,6 @@ def perform expect(transaction.http_client_events_suppressed?).to be(false) end - - it "stays suppressed in an outer block when a nested block returns" do - transaction.suppress_http_client_events do - transaction.suppress_http_client_events do - expect(transaction.http_client_events_suppressed?).to be(true) - end - - # The nested block must not unsuppress while the outer block is active. - expect(transaction.http_client_events_suppressed?).to be(true) - end - - expect(transaction.http_client_events_suppressed?).to be(false) - end end describe "#suppress_job_enqueue_events" do From 14e7e750290f83494890ba0d68eab144d6676889 Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Thu, 25 Jun 2026 22:53:40 +0200 Subject: [PATCH 129/151] Leave the path out of Faraday event titles Match Net::HTTP, which records only the method, scheme, and host. The formatter no longer returns the path as the event body. --- spec/lib/appsignal/integrations/faraday_spec.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/spec/lib/appsignal/integrations/faraday_spec.rb b/spec/lib/appsignal/integrations/faraday_spec.rb index fb9e66f7b..9642441a6 100644 --- a/spec/lib/appsignal/integrations/faraday_spec.rb +++ b/spec/lib/appsignal/integrations/faraday_spec.rb @@ -24,10 +24,11 @@ def perform set_current_transaction(transaction) perform + # Title only, no body -- the path is left out, matching Net::HTTP. expect(transaction).to include_event( "name" => "request.faraday", "title" => "GET http://www.example.com", - "body" => "GET http://www.example.com/" + "body" => "" ) # Net::HTTP is suppressed under Faraday, so it isn't recorded again. expect(transaction).to_not include_event("name" => "request.net_http") From 06e410d4d93e4481653551b85e029a26ab181a5a Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Fri, 3 Jul 2026 14:20:16 +0200 Subject: [PATCH 130/151] Drop ActiveSupport from the Faraday integration Record the request.faraday event from our own Faraday middleware with Appsignal.instrument, as a client event, instead of relying on Faraday's ActiveSupport notifications middleware. This matches the Net::HTTP integration and removes ActiveSupport as a dependency of Faraday support. Trace context is now injected from within that event, so the outgoing request still carries the Faraday client span's traceparent. The generic ActiveSupport::Notifications path now skips request.faraday through SUPPRESSED_EVENT_NAMES instead of marking it a client event, so a manually added notifications middleware doesn't record the request a second time. --- gemfiles/faraday-1.gemfile | 1 - gemfiles/faraday-2.gemfile | 1 - .../active_support_notifications.rb | 18 +++-- lib/appsignal/integrations/faraday.rb | 80 ++++++++----------- .../instrument_shared_examples.rb | 28 +++++++ .../appsignal/integrations/faraday_spec.rb | 9 ++- 6 files changed, 76 insertions(+), 61 deletions(-) diff --git a/gemfiles/faraday-1.gemfile b/gemfiles/faraday-1.gemfile index a1b2b5354..39c2d41c1 100644 --- a/gemfiles/faraday-1.gemfile +++ b/gemfiles/faraday-1.gemfile @@ -1,6 +1,5 @@ source "https://rubygems.org" -gem "activesupport", :require => "active_support" gem "faraday", "~> 1.0" gemspec :path => "../" diff --git a/gemfiles/faraday-2.gemfile b/gemfiles/faraday-2.gemfile index 56af0241f..27a42cbd5 100644 --- a/gemfiles/faraday-2.gemfile +++ b/gemfiles/faraday-2.gemfile @@ -1,6 +1,5 @@ source "https://rubygems.org" -gem "activesupport", :require => "active_support" gem "faraday", "~> 2.0" gemspec :path => "../" diff --git a/lib/appsignal/integrations/active_support_notifications.rb b/lib/appsignal/integrations/active_support_notifications.rb index b8b133c38..2b4cf84ba 100644 --- a/lib/appsignal/integrations/active_support_notifications.rb +++ b/lib/appsignal/integrations/active_support_notifications.rb @@ -8,20 +8,22 @@ class << self BANG = "!" # ActiveSupport::Notifications events whose span represents an outgoing - # call (to a datastore or another service), so they carry CLIENT kind in - # collector mode (to match the dedicated 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 + # 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. - # `request.faraday` is Faraday's outgoing HTTP request event. - CLIENT_EVENT_NAMES = ["sql.active_record", "request.faraday"].freeze + 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. - SUPPRESSED_EVENT_NAMES = ["enqueue.active_job"].freeze + # 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) diff --git a/lib/appsignal/integrations/faraday.rb b/lib/appsignal/integrations/faraday.rb index 3e5e792eb..38cd4336d 100644 --- a/lib/appsignal/integrations/faraday.rb +++ b/lib/appsignal/integrations/faraday.rb @@ -2,37 +2,40 @@ module Appsignal module Integrations - # Faraday middleware that writes trace context onto the outgoing request, so - # the called service joins this trace. The `request.faraday` event recorded - # by Faraday's own instrumentation middleware provides the span; this - # middleware injects and, when that event is recorded, suppresses the - # downstream HTTP client's own instrumentation. + # 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 - # `super(app)` passes only the app: Faraday 1's `Middleware#initialize` - # takes the app alone, so forwarding our options hash to it would raise. - def initialize(app, options = {}) - super(app) - @suppress_downstream = options[:suppress_downstream] - end - def call(env) - # Inject from whatever span is current. Faraday's instrumentation - # middleware wraps this call in the `request.faraday` event, so its event - # span is current and the written `traceparent` reflects the Faraday - # client event. 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) + http_method = env[:method].to_s.upcase + uri = env[:url] + # Title only, no body: the path is left out so the event matches + # Net::HTTP's (scheme and host only), keeping paths out of event titles. + Appsignal.instrument( + "request.faraday", + "#{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. When the `request.faraday` event is recorded, suppress the - # adapter's instrumentation so the request appears once (as the Faraday - # event) rather than as nested Faraday + Net::HTTP client events. - if @suppress_downstream && Appsignal::Transaction.current? - Appsignal::Transaction.current.suppress_http_client_events { @app.call(env) } - else - @app.call(env) + # Faraday's default adapter is Net::HTTP, which AppSignal also + # instruments. Suppress the adapter's own instrumentation so the + # request appears once (as the Faraday event) rather than as nested + # Faraday + Net::HTTP client events. + if Appsignal::Transaction.current? + Appsignal::Transaction.current.suppress_http_client_events { @app.call(env) } + else + @app.call(env) + end end end end @@ -43,31 +46,14 @@ 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: - # - # - `Faraday::Request::Instrumentation`, so the `request.faraday` event fires - # without the user adding it themselves -- but only when - # ActiveSupport::Notifications is loaded, since that middleware references it - # at build time. Skipped if the user already added it. - # - `FaradayMiddleware`, which injects trace context. Added after - # Instrumentation so it runs inside that event's span. It also suppresses - # the downstream client when the Faraday event is recorded -- decided here, - # at build time, so it stays in sync with whether Instrumentation is added. + # 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 def adapter(*) - unless handlers.any? { |handler| handler.klass == FaradayMiddleware } - # The `request.faraday` event needs ActiveSupport::Notifications, which - # Faraday's instrumentation middleware references at build time. - records_event = defined?(::ActiveSupport::Notifications) && - defined?(::Faraday::Request::Instrumentation) - if records_event && - handlers.none? { |handler| handler.klass == ::Faraday::Request::Instrumentation } - use(::Faraday::Request::Instrumentation) - end - use(FaradayMiddleware, { :suppress_downstream => records_event }) - end + use(FaradayMiddleware) unless handlers.any? { |handler| handler.klass == FaradayMiddleware } super 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 6335728bb..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 @@ -151,6 +151,34 @@ def perform end end + describe "a suppressed event, recorded by a dedicated integration" do + def perform + as.instrument("request.faraday", :method => :get) { "value" } + end + + it "in agent mode", :agent_mode do + start_agent + transaction = http_request_transaction + set_current_transaction(transaction) + as.notifier = notifier + + expect(perform).to eq "value" + expect(transaction).to_not include_events + end + + it "in collector mode", :collector_mode do + start_collector_agent + transaction = http_request_transaction + set_current_transaction(transaction) + as.notifier = notifier + + expect(perform).to eq "value" + Appsignal::Transaction.complete_current! + + expect(event_spans).to be_empty + end + end + describe "when an error is raised in an instrumented block" do def perform expect do diff --git a/spec/lib/appsignal/integrations/faraday_spec.rb b/spec/lib/appsignal/integrations/faraday_spec.rb index 9642441a6..45affbb9c 100644 --- a/spec/lib/appsignal/integrations/faraday_spec.rb +++ b/spec/lib/appsignal/integrations/faraday_spec.rb @@ -2,10 +2,11 @@ require "faraday" require "appsignal/integrations/faraday" - # Integration test against the real Faraday gem. The hook auto-installs, onto - # every connection, Faraday's instrumentation middleware (so the - # `request.faraday` event fires without the user adding it) and an inject-only - # middleware (so outgoing requests carry trace context). + # Integration test against the real Faraday gem. The hook auto-installs + # AppSignal's middleware onto every connection, so the `request.faraday` event + # 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 } From 2a2ac91b18438bc8274dc23070efc207439dffdd Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Fri, 3 Jul 2026 16:27:28 +0200 Subject: [PATCH 131/151] Drop cop disables made redundant by the merge Merging main brought RuboCop 1.87 and its config, which now disables Naming/AccessorMethodName and Metrics/AbcSize project-wide. Remove the inline disable directives those cops no longer need. --- lib/appsignal/transaction/base_backend.rb | 6 +++--- lib/appsignal/transaction/extension_backend.rb | 6 +++--- lib/appsignal/transaction/opentelemetry_backend.rb | 6 +++--- spec/integration/collector_mode_spec.rb | 2 +- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/lib/appsignal/transaction/base_backend.rb b/lib/appsignal/transaction/base_backend.rb index c9d94cbc2..9b4c23b53 100644 --- a/lib/appsignal/transaction/base_backend.rb +++ b/lib/appsignal/transaction/base_backend.rb @@ -23,11 +23,11 @@ def record_event(_name, _title, _body, _body_format, _duration, opentelemetry_ki end # Transaction metadata. - def set_action(_action) # rubocop:disable Naming/AccessorMethodName + def set_action(_action) raise NotImplementedError end - def set_namespace(_namespace) # rubocop:disable Naming/AccessorMethodName + def set_namespace(_namespace) raise NotImplementedError end @@ -35,7 +35,7 @@ def set_metadata(_key, _value) raise NotImplementedError end - def set_queue_start(_start) # rubocop:disable Naming/AccessorMethodName + def set_queue_start(_start) raise NotImplementedError end diff --git a/lib/appsignal/transaction/extension_backend.rb b/lib/appsignal/transaction/extension_backend.rb index 2e9889257..934defbd3 100644 --- a/lib/appsignal/transaction/extension_backend.rb +++ b/lib/appsignal/transaction/extension_backend.rb @@ -44,15 +44,15 @@ def record_event(name, title, body, body_format, duration, opentelemetry_kind: n @handle.record_event(name, title, body, body_format, duration, 0) end - def set_action(action) # rubocop:disable Naming/AccessorMethodName + def set_action(action) @handle.set_action(action) end - def set_namespace(namespace) # rubocop:disable Naming/AccessorMethodName + def set_namespace(namespace) @handle.set_namespace(namespace) end - def set_queue_start(start) # rubocop:disable Naming/AccessorMethodName + def set_queue_start(start) @handle.set_queue_start(start) end diff --git a/lib/appsignal/transaction/opentelemetry_backend.rb b/lib/appsignal/transaction/opentelemetry_backend.rb index 717a188ab..5fd3bc8c2 100644 --- a/lib/appsignal/transaction/opentelemetry_backend.rb +++ b/lib/appsignal/transaction/opentelemetry_backend.rb @@ -110,7 +110,7 @@ def record_event(name, title, body, body_format, duration, opentelemetry_kind: n span.finish end - def set_action(action) # rubocop:disable Naming/AccessorMethodName + 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. @@ -118,7 +118,7 @@ def set_action(action) # rubocop:disable Naming/AccessorMethodName @span.set_attribute("appsignal.action_name", action) end - def set_namespace(namespace) # rubocop:disable Naming/AccessorMethodName + 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 -- @@ -132,7 +132,7 @@ def set_namespace(namespace) # rubocop:disable Naming/AccessorMethodName # `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) # rubocop:disable Naming/AccessorMethodName + def set_queue_start(start) return unless start && start > QUEUE_START_MIN @queue_start = start diff --git a/spec/integration/collector_mode_spec.rb b/spec/integration/collector_mode_spec.rb index ab326261b..03f42e127 100644 --- a/spec/integration/collector_mode_spec.rb +++ b/spec/integration/collector_mode_spec.rb @@ -18,7 +18,7 @@ # 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) # rubocop:disable Metrics/AbcSize + def expect_appsignal_resource(resource) attrs = resource.attributes.to_h { |kv| [kv.key, kv.value] } defaults = Runner::DEFAULT_ENV From ca1d06ae714b5208ed41eee049f4e7e2e104dd31 Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Fri, 3 Jul 2026 17:18:48 +0200 Subject: [PATCH 132/151] Harden Sidekiq enqueue instrumentation specs Address review feedback on the enqueue instrumentation tests. The hook spec now asserts the client middleware ends up registered exactly once, mirroring Sidekiq's own chain that dedupes by class, so the double registration across the server and client config blocks can't produce duplicate enqueue events unnoticed. The agent-mode pass-through spec now asserts that enqueuing without an active transaction records nothing, matching the collector-mode spec that already checks no span is emitted. --- spec/lib/appsignal/hooks/sidekiq_spec.rb | 25 +++++++++++++++---- .../appsignal/integrations/sidekiq_spec.rb | 4 ++- 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/spec/lib/appsignal/hooks/sidekiq_spec.rb b/spec/lib/appsignal/hooks/sidekiq_spec.rb index 8160cab51..7a2ab9c27 100644 --- a/spec/lib/appsignal/hooks/sidekiq_spec.rb +++ b/spec/lib/appsignal/hooks/sidekiq_spec.rb @@ -16,8 +16,14 @@ end describe "#install" do + # Mirror Sidekiq's own middleware chain, which removes any existing entry + # for a class before adding it, so a class is only ever present once even + # if it's registered from more than one config block. class SidekiqMiddlewareMockWithPrepend < Array - alias add << + def add(middleware) + delete(middleware) + push(middleware) + end alias exists? include? unless method_defined? :prepend @@ -29,7 +35,10 @@ def prepend(middleware) end class SidekiqMiddlewareMockWithoutPrepend < Array - alias add << + def add(middleware) + delete(middleware) + push(middleware) + end alias exists? include? undef_method :prepend if method_defined? :prepend # For Ruby >= 2.5 @@ -88,11 +97,17 @@ def add_middleware(middleware) expect(Sidekiq.error_handlers).to include(Appsignal::Integrations::SidekiqErrorHandler) end - it "adds the AppSignal client middleware to the client middleware chain" do + it "adds the AppSignal client middleware to the client middleware chain exactly once" do Sidekiq.middleware_mock = SidekiqMiddlewareMockWithPrepend described_class.new.install - expect(Sidekiq.client_middleware) - .to include(Appsignal::Integrations::SidekiqClientMiddleware) + + # The client middleware is registered from both the server and client + # config blocks. Sidekiq only runs one of them per process and dedupes + # by class, so it must end up registered exactly once. + registrations = Sidekiq.client_middleware.count do |middleware| + middleware == Appsignal::Integrations::SidekiqClientMiddleware + end + expect(registrations).to eq(1) end context "when Sidekiq middleware responds to prepend method" do # Sidekiq 3.3.0 and newer diff --git a/spec/lib/appsignal/integrations/sidekiq_spec.rb b/spec/lib/appsignal/integrations/sidekiq_spec.rb index 961a79ed2..d885a3c38 100644 --- a/spec/lib/appsignal/integrations/sidekiq_spec.rb +++ b/spec/lib/appsignal/integrations/sidekiq_spec.rb @@ -370,8 +370,10 @@ def enqueue it "in agent mode", :agent_mode do start_agent - # A transparent pass-through: the job hash is untouched. + # 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 From f23561a7762988488bbe193f52c584f581295881 Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Fri, 3 Jul 2026 18:03:27 +0200 Subject: [PATCH 133/151] Instrument HTTP.rb via request and Session Rebasing onto main reverted the HTTP.rb integration to the older perform-based hook. Restore main's request/Session instrumentation (one event per request, spanning redirects, and http6 chained requests through Session) and keep per-hop trace-context injection on a separate perform prepend. --- lib/appsignal/hooks/http.rb | 28 +++++++- lib/appsignal/integrations/http.rb | 42 +++++++++--- spec/lib/appsignal/hooks/http_spec.rb | 21 +++++- spec/lib/appsignal/integrations/http_spec.rb | 72 +++++++------------- 4 files changed, 98 insertions(+), 65 deletions(-) diff --git a/lib/appsignal/hooks/http.rb b/lib/appsignal/hooks/http.rb index 36a344e68..f3f8901ef 100644 --- a/lib/appsignal/hooks/http.rb +++ b/lib/appsignal/hooks/http.rb @@ -6,15 +6,37 @@ class Hooks class HttpHook < Appsignal::Hooks::Hook register :http_rb + def self.http6_or_higher? + Gem::Version.new(HTTP::VERSION) >= Gem::Version.new("6.0.0") + end + def dependencies_present? defined?(HTTP::Client) && Appsignal.config && Appsignal.config[:instrument_http_rb] end def install require "appsignal/integrations/http" - # `perform` has the same signature in http5 and http6, so a single - # prepend module works for both. - HTTP::Client.prepend Appsignal::Integrations::HttpIntegration + # `Client#request` takes positional options in http5 and keyword options + # in http6. + integration = + if self.class.http6_or_higher? + Appsignal::Integrations::HttpIntegration::KeywordOptions + else + Appsignal::Integrations::HttpIntegration::HashOptions + end + HTTP::Client.prepend integration + # In http6 a chained request (`.follow`, `.headers`, ...) goes through + # `HTTP::Session#request` instead of `HTTP::Client#request`, so + # instrument it too (keyword options). http5 has no Session; chained + # requests run through `Client#request` there. + 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/http.rb b/lib/appsignal/integrations/http.rb index 8f8fd966d..80227d928 100644 --- a/lib/appsignal/integrations/http.rb +++ b/lib/appsignal/integrations/http.rb @@ -17,17 +17,37 @@ def self.instrument(verb, uri, &block) ) end - # `perform` is the single send chokepoint in both http5 and http6 (its - # signature is identical), called once per request and once per redirect - # hop. Instrumenting here keeps a single prepend module across versions and - # gives each hop its own event with trace context injected into that hop's - # outgoing request. - def perform(req, options) - HttpIntegration.instrument(req.verb, req.uri) do - # Write trace context onto the outgoing request headers so the called - # service joins this trace. No-op outside collector mode. `req.headers` - # is the live outgoing header set and a valid carrier (it responds to - # `[]=`). + # The event is recorded at the request boundary, so a redirected request + # stays a single `request.http_rb` event spanning every hop. That boundary + # lives in more than one place: a bare request runs through + # `HTTP::Client#request`, but in http6 a chained request (`.follow`, + # `.headers`, `.timeout`, ...) runs through `HTTP::Session#request` + # instead, which never touches `Client#request`. The hook prepends one of + # these onto each. `Client#request` takes positional options in http5 and + # keyword options in http6; `Session#request` (http6 only) takes keyword + # options. + module HashOptions + def request(verb, uri, opts = {}) + HttpIntegration.instrument(verb, uri) { super } + end + end + + module KeywordOptions + 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 diff --git a/spec/lib/appsignal/hooks/http_spec.rb b/spec/lib/appsignal/hooks/http_spec.rb index f7c6384dc..2f29940a8 100644 --- a/spec/lib/appsignal/hooks/http_spec.rb +++ b/spec/lib/appsignal/hooks/http_spec.rb @@ -12,9 +12,26 @@ it { is_expected.to be_truthy } end - it "installs the HTTP plugin" do + if DependencyHelper.http6_present? + it "installs the HTTP plugin with keyword options" do + expect(HTTP::Client.included_modules) + .to include(Appsignal::Integrations::HttpIntegration::KeywordOptions) + end + + it "instruments chained requests through HTTP::Session" do + expect(HTTP::Session.included_modules) + .to include(Appsignal::Integrations::HttpIntegration::KeywordOptions) + end + else + it "installs the HTTP plugin with hash options" do + expect(HTTP::Client.included_modules) + .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) + .to include(Appsignal::Integrations::HttpIntegration::ContextInjection) end end diff --git a/spec/lib/appsignal/integrations/http_spec.rb b/spec/lib/appsignal/integrations/http_spec.rb index 6389b12d0..b48478ecd 100644 --- a/spec/lib/appsignal/integrations/http_spec.rb +++ b/spec/lib/appsignal/integrations/http_spec.rb @@ -162,14 +162,21 @@ def perform 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" } @@ -177,6 +184,19 @@ def perform ["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 @@ -342,52 +362,6 @@ def perform end end - describe "following redirects" do - # `perform` is the single send chokepoint, called once per request and - # once per redirect hop, so each hop becomes its own `request.http_rb` - # event. (Before this moved to `perform`, the `request`-level event - # spanned all hops.) - 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" } - expect(events.map { |event| event["title"] }).to eq( - [ - "GET http://www.google.com", - "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(2) - expect(event_spans.map(&:name)).to contain_exactly( - "GET http://www.google.com", - "GET http://www.example.com" - ) - event_spans.each do |span| - expect(span.kind).to eq(:client) - 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 From 0fc6b3b30c93cbcc827f955340abad9c03e3de66 Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Fri, 3 Jul 2026 18:03:27 +0200 Subject: [PATCH 134/151] Adopt main's integration refinements after rebase Restore refinements that live on main but were reverted by the rebase: the nested-safe HTTP client suppression, the MongoDB body via Appsignal::Utils::JSON, and the matching spec updates for Sidekiq and the ActiveSupport notifications suppression. --- .changesets/add-faraday-integration.md | 2 +- .../integrations/active_support_notifications.rb | 4 ++-- lib/appsignal/integrations/mongo_ruby_driver.rb | 2 +- lib/appsignal/transaction.rb | 5 ++++- .../hooks/active_support_notifications_spec.rb | 12 ++++++++++++ spec/lib/appsignal/integrations/sidekiq_spec.rb | 8 ++++---- spec/lib/appsignal/transaction_spec.rb | 13 +++++++++++++ 7 files changed, 37 insertions(+), 9 deletions(-) diff --git a/.changesets/add-faraday-integration.md b/.changesets/add-faraday-integration.md index f1af593ff..0ac3fe85f 100644 --- a/.changesets/add-faraday-integration.md +++ b/.changesets/add-faraday-integration.md @@ -3,4 +3,4 @@ bump: minor type: add --- -Improve Faraday support. AppSignal now instruments Faraday requests automatically and, in collector mode, propagates trace context to the called service so it joins the same distributed trace. Turn it off with the `instrument_faraday` option. +Improve Faraday support. AppSignal now instruments Faraday requests automatically, without double-instrumenting the underlying HTTP client, and, in collector mode, propagates trace context to the called service so it joins the same distributed trace. Turn it off with the `instrument_faraday` option. diff --git a/lib/appsignal/integrations/active_support_notifications.rb b/lib/appsignal/integrations/active_support_notifications.rb index 2b4cf84ba..99f4faab2 100644 --- a/lib/appsignal/integrations/active_support_notifications.rb +++ b/lib/appsignal/integrations/active_support_notifications.rb @@ -46,8 +46,8 @@ def finish_event(name, payload = {}) end # Events starting with a bang are internal to Rails; suppressed events - # are recorded elsewhere. Both `start_event` and `finish_event` gate on - # this so the event stack stays balanced. + # are recorded by a dedicated integration instead. 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) diff --git a/lib/appsignal/integrations/mongo_ruby_driver.rb b/lib/appsignal/integrations/mongo_ruby_driver.rb index 97a336c7a..c3e9841de 100644 --- a/lib/appsignal/integrations/mongo_ruby_driver.rb +++ b/lib/appsignal/integrations/mongo_ruby_driver.rb @@ -55,7 +55,7 @@ def finish(result, event) transaction.finish_event( "query.mongodb", "#{event.command_name} | #{event.database_name} | #{result}", - JSON.generate(command), + Appsignal::Utils::JSON.generate(command), Appsignal::EventFormatter::DEFAULT ) diff --git a/lib/appsignal/transaction.rb b/lib/appsignal/transaction.rb index d8f97e398..7e9714f79 100644 --- a/lib/appsignal/transaction.rb +++ b/lib/appsignal/transaction.rb @@ -292,10 +292,13 @@ def store(key) # integration (Faraday) already records the request, so the same request is # not instrumented twice as nested client events. def suppress_http_client_events + # Restore the previous value rather than forcing `false`, so nested calls + # don't unsuppress while an outer block is still active. + previously_suppressed = store("http_client")[:suppressed] store("http_client")[:suppressed] = true yield ensure - store("http_client")[:suppressed] = false + store("http_client")[:suppressed] = previously_suppressed end # @!visibility private diff --git a/spec/lib/appsignal/hooks/active_support_notifications_spec.rb b/spec/lib/appsignal/hooks/active_support_notifications_spec.rb index 957eec3f1..505ff55ba 100644 --- a/spec/lib/appsignal/hooks/active_support_notifications_spec.rb +++ b/spec/lib/appsignal/hooks/active_support_notifications_spec.rb @@ -17,6 +17,18 @@ ActiveSupport::Notifications.notifier = original_notifier end + # The before hook swaps 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 + describe "#dependencies_present?" do subject { described_class.new.dependencies_present? } diff --git a/spec/lib/appsignal/integrations/sidekiq_spec.rb b/spec/lib/appsignal/integrations/sidekiq_spec.rb index d885a3c38..4b18bc9d2 100644 --- a/spec/lib/appsignal/integrations/sidekiq_spec.rb +++ b/spec/lib/appsignal/integrations/sidekiq_spec.rb @@ -620,7 +620,7 @@ def perform perform expect(transaction).to have_action("DelayedTestClass.foo_method") - expect(transaction).to include_params(["bar" => "baz"]) + expect(transaction).to include_params([{ "bar" => "baz" }]) end it "in collector mode", :collector_mode do @@ -630,7 +630,7 @@ def 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"]) + expect(params).to eq([{ "bar" => "baz" }]) end end @@ -691,7 +691,7 @@ def perform perform expect(transaction).to have_action("DelayedTestClass#foo_method") - expect(transaction).to include_params(["bar" => "baz"]) + expect(transaction).to include_params([{ "bar" => "baz" }]) end it "in collector mode", :collector_mode do @@ -701,7 +701,7 @@ def 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"]) + expect(params).to eq([{ "bar" => "baz" }]) end end diff --git a/spec/lib/appsignal/transaction_spec.rb b/spec/lib/appsignal/transaction_spec.rb index f64bf472e..31dc2efbc 100644 --- a/spec/lib/appsignal/transaction_spec.rb +++ b/spec/lib/appsignal/transaction_spec.rb @@ -847,6 +847,19 @@ def perform expect(transaction.http_client_events_suppressed?).to be(false) end + + it "stays suppressed in an outer block when a nested block returns" do + transaction.suppress_http_client_events do + transaction.suppress_http_client_events do + expect(transaction.http_client_events_suppressed?).to be(true) + end + + # The nested block must not unsuppress while the outer block is active. + expect(transaction.http_client_events_suppressed?).to be(true) + end + + expect(transaction.http_client_events_suppressed?).to be(false) + end end describe "#suppress_job_enqueue_events" do From 8f0d13312c085971bb99ff44858798bfb625f52b Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Fri, 3 Jul 2026 18:03:27 +0200 Subject: [PATCH 135/151] Regenerate the CI workflow Regenerate .github/workflows/ci.yml from build_matrix.yml so it reflects the current build matrix after the rebase. --- .github/workflows/ci.yml | 6394 ++++++++++++++++++++++++++++++++------ 1 file changed, 5384 insertions(+), 1010 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 70baeb3b5..950691559 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,7 @@ # This is a generated file by the `rake build_matrix:github:generate` task. # See `build_matrix.yml` for the build matrix. # Generate this file with `rake build_matrix:github:generate`. -# Generated job count: 234 +# Generated job count: 396 --- name: Ruby gem CI 'on': @@ -123,7 +123,8 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &1 + name: Run tests run: "./script/bundler_wrapper exec rake test" - name: Run tests without extension run: "./script/bundler_wrapper exec rake test:failure" @@ -132,8 +133,8 @@ jobs: JRUBY_OPTS: '' COV: '1' BUNDLE_GEMFILE: gemfiles/no_dependencies.gemfile - ruby_4-0-0__capistrano2_ubuntu-latest: - name: Ruby 4.0.0 - capistrano2 + ruby_4-0-0__no_dependencies-collector_ubuntu-latest: + name: Ruby 4.0.0 - no_dependencies-collector needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: @@ -152,15 +153,14 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *1 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/capistrano2.gemfile - ruby_4-0-0__capistrano3_ubuntu-latest: - name: Ruby 4.0.0 - capistrano3 + BUNDLE_GEMFILE: gemfiles/no_dependencies-collector.gemfile + ruby_4-0-0__capistrano2_ubuntu-latest: + name: Ruby 4.0.0 - capistrano2 needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: @@ -179,15 +179,16 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &2 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/capistrano3.gemfile - ruby_4-0-0__code_ownership_ubuntu-latest: - name: Ruby 4.0.0 - code_ownership + BUNDLE_GEMFILE: gemfiles/capistrano2.gemfile + ruby_4-0-0__capistrano2-collector_ubuntu-latest: + name: Ruby 4.0.0 - capistrano2-collector needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: @@ -206,15 +207,14 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *2 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/code_ownership.gemfile - ruby_4-0-0__dry-monitor_ubuntu-latest: - name: Ruby 4.0.0 - dry-monitor + BUNDLE_GEMFILE: gemfiles/capistrano2-collector.gemfile + ruby_4-0-0__capistrano3_ubuntu-latest: + name: Ruby 4.0.0 - capistrano3 needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: @@ -233,15 +233,16 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &3 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/dry-monitor.gemfile - ruby_4-0-0__faraday-1_ubuntu-latest: - name: Ruby 4.0.0 - faraday-1 + BUNDLE_GEMFILE: gemfiles/capistrano3.gemfile + ruby_4-0-0__capistrano3-collector_ubuntu-latest: + name: Ruby 4.0.0 - capistrano3-collector needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: @@ -260,15 +261,14 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *3 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/faraday-1.gemfile - ruby_4-0-0__faraday-2_ubuntu-latest: - name: Ruby 4.0.0 - faraday-2 + BUNDLE_GEMFILE: gemfiles/capistrano3-collector.gemfile + ruby_4-0-0__code_ownership_ubuntu-latest: + name: Ruby 4.0.0 - code_ownership needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: @@ -287,15 +287,16 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &4 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/faraday-2.gemfile - ruby_4-0-0__grape_ubuntu-latest: - name: Ruby 4.0.0 - grape + BUNDLE_GEMFILE: gemfiles/code_ownership.gemfile + ruby_4-0-0__code_ownership-collector_ubuntu-latest: + name: Ruby 4.0.0 - code_ownership-collector needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: @@ -314,15 +315,14 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *4 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/grape.gemfile - ruby_4-0-0__http5_ubuntu-latest: - name: Ruby 4.0.0 - http5 + BUNDLE_GEMFILE: gemfiles/code_ownership-collector.gemfile + ruby_4-0-0__dry-monitor_ubuntu-latest: + name: Ruby 4.0.0 - dry-monitor needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: @@ -341,15 +341,16 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &5 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/http5.gemfile - ruby_4-0-0__http6_ubuntu-latest: - name: Ruby 4.0.0 - http6 + BUNDLE_GEMFILE: gemfiles/dry-monitor.gemfile + ruby_4-0-0__dry-monitor-collector_ubuntu-latest: + name: Ruby 4.0.0 - dry-monitor-collector needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: @@ -368,15 +369,14 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *5 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/http6.gemfile - ruby_4-0-0__mongo_ubuntu-latest: - name: Ruby 4.0.0 - mongo + BUNDLE_GEMFILE: gemfiles/dry-monitor-collector.gemfile + ruby_4-0-0__excon_ubuntu-latest: + name: Ruby 4.0.0 - excon needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: @@ -395,15 +395,16 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &6 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/mongo.gemfile - ruby_4-0-0__ownership_ubuntu-latest: - name: Ruby 4.0.0 - ownership + BUNDLE_GEMFILE: gemfiles/excon.gemfile + ruby_4-0-0__excon-collector_ubuntu-latest: + name: Ruby 4.0.0 - excon-collector needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: @@ -422,15 +423,14 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *6 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/ownership.gemfile - ruby_4-0-0__psych-3_ubuntu-latest: - name: Ruby 4.0.0 - psych-3 + BUNDLE_GEMFILE: gemfiles/excon-collector.gemfile + ruby_4-0-0__faraday-1_ubuntu-latest: + name: Ruby 4.0.0 - faraday-1 needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: @@ -449,15 +449,16 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &7 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/psych-3.gemfile - ruby_4-0-0__psych-4_ubuntu-latest: - name: Ruby 4.0.0 - psych-4 + BUNDLE_GEMFILE: gemfiles/faraday-1.gemfile + ruby_4-0-0__faraday-1-collector_ubuntu-latest: + name: Ruby 4.0.0 - faraday-1-collector needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: @@ -476,15 +477,14 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *7 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/psych-4.gemfile - ruby_4-0-0__que-1_ubuntu-latest: - name: Ruby 4.0.0 - que-1 + BUNDLE_GEMFILE: gemfiles/faraday-1-collector.gemfile + ruby_4-0-0__faraday-2_ubuntu-latest: + name: Ruby 4.0.0 - faraday-2 needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: @@ -503,15 +503,16 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &8 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/que-1.gemfile - ruby_4-0-0__que-2_ubuntu-latest: - name: Ruby 4.0.0 - que-2 + BUNDLE_GEMFILE: gemfiles/faraday-2.gemfile + ruby_4-0-0__faraday-2-collector_ubuntu-latest: + name: Ruby 4.0.0 - faraday-2-collector needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: @@ -530,15 +531,14 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *8 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/que-2.gemfile - ruby_4-0-0__rails-7-0_ubuntu-latest: - name: Ruby 4.0.0 - rails-7.0 + BUNDLE_GEMFILE: gemfiles/faraday-2-collector.gemfile + ruby_4-0-0__grape_ubuntu-latest: + name: Ruby 4.0.0 - grape needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: @@ -557,15 +557,16 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &9 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/rails-7.0.gemfile - ruby_4-0-0__rails-7-1_ubuntu-latest: - name: Ruby 4.0.0 - rails-7.1 + BUNDLE_GEMFILE: gemfiles/grape.gemfile + ruby_4-0-0__grape-collector_ubuntu-latest: + name: Ruby 4.0.0 - grape-collector needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: @@ -584,15 +585,14 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *9 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/rails-7.1.gemfile - ruby_4-0-0__rails-7-2_ubuntu-latest: - name: Ruby 4.0.0 - rails-7.2 + BUNDLE_GEMFILE: gemfiles/grape-collector.gemfile + ruby_4-0-0__http5_ubuntu-latest: + name: Ruby 4.0.0 - http5 needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: @@ -611,15 +611,16 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &10 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/rails-7.2.gemfile - ruby_4-0-0__rails-8-0_ubuntu-latest: - name: Ruby 4.0.0 - rails-8.0 + BUNDLE_GEMFILE: gemfiles/http5.gemfile + ruby_4-0-0__http5-collector_ubuntu-latest: + name: Ruby 4.0.0 - http5-collector needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: @@ -638,15 +639,14 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *10 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/rails-8.0.gemfile - ruby_4-0-0__resque-2_ubuntu-latest: - name: Ruby 4.0.0 - resque-2 + BUNDLE_GEMFILE: gemfiles/http5-collector.gemfile + ruby_4-0-0__http6_ubuntu-latest: + name: Ruby 4.0.0 - http6 needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: @@ -665,15 +665,16 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &11 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/resque-2.gemfile - ruby_4-0-0__resque-3_ubuntu-latest: - name: Ruby 4.0.0 - resque-3 + BUNDLE_GEMFILE: gemfiles/http6.gemfile + ruby_4-0-0__http6-collector_ubuntu-latest: + name: Ruby 4.0.0 - http6-collector needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: @@ -692,15 +693,14 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *11 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/resque-3.gemfile - ruby_4-0-0__sequel_ubuntu-latest: - name: Ruby 4.0.0 - sequel + BUNDLE_GEMFILE: gemfiles/http6-collector.gemfile + ruby_4-0-0__mongo_ubuntu-latest: + name: Ruby 4.0.0 - mongo needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: @@ -719,15 +719,16 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &12 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/sequel.gemfile - ruby_4-0-0__shoryuken-7_ubuntu-latest: - name: Ruby 4.0.0 - shoryuken-7 + BUNDLE_GEMFILE: gemfiles/mongo.gemfile + ruby_4-0-0__mongo-collector_ubuntu-latest: + name: Ruby 4.0.0 - mongo-collector needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: @@ -746,15 +747,14 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *12 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/shoryuken-7.gemfile - ruby_4-0-0__sinatra_ubuntu-latest: - name: Ruby 4.0.0 - sinatra + BUNDLE_GEMFILE: gemfiles/mongo-collector.gemfile + ruby_4-0-0__ownership_ubuntu-latest: + name: Ruby 4.0.0 - ownership needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: @@ -773,15 +773,16 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &13 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/sinatra.gemfile - ruby_4-0-0__webmachine2_ubuntu-latest: - name: Ruby 4.0.0 - webmachine2 + BUNDLE_GEMFILE: gemfiles/ownership.gemfile + ruby_4-0-0__ownership-collector_ubuntu-latest: + name: Ruby 4.0.0 - ownership-collector needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: @@ -800,15 +801,14 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *13 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/webmachine2.gemfile - ruby_4-0-0__redis-4_ubuntu-latest: - name: Ruby 4.0.0 - redis-4 + BUNDLE_GEMFILE: gemfiles/ownership-collector.gemfile + ruby_4-0-0__psych-3_ubuntu-latest: + name: Ruby 4.0.0 - psych-3 needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: @@ -827,15 +827,16 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &14 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/redis-4.gemfile - ruby_4-0-0__redis-5_ubuntu-latest: - name: Ruby 4.0.0 - redis-5 + BUNDLE_GEMFILE: gemfiles/psych-3.gemfile + ruby_4-0-0__psych-3-collector_ubuntu-latest: + name: Ruby 4.0.0 - psych-3-collector needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: @@ -854,15 +855,14 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *14 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/redis-5.gemfile - ruby_4-0-0__sidekiq-7_ubuntu-latest: - name: Ruby 4.0.0 - sidekiq-7 + BUNDLE_GEMFILE: gemfiles/psych-3-collector.gemfile + ruby_4-0-0__psych-4_ubuntu-latest: + name: Ruby 4.0.0 - psych-4 needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: @@ -881,15 +881,16 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &15 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/sidekiq-7.gemfile - ruby_4-0-0__sidekiq-8_ubuntu-latest: - name: Ruby 4.0.0 - sidekiq-8 + BUNDLE_GEMFILE: gemfiles/psych-4.gemfile + ruby_4-0-0__psych-4-collector_ubuntu-latest: + name: Ruby 4.0.0 - psych-4-collector needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: @@ -908,17 +909,16 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *15 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/sidekiq-8.gemfile - ruby_4-0-0_macos-14: - name: Ruby 4.0.0 (macos-14) - needs: validation - runs-on: macos-14 + BUNDLE_GEMFILE: gemfiles/psych-4-collector.gemfile + ruby_4-0-0__que-1_ubuntu-latest: + name: Ruby 4.0.0 - que-1 + needs: ruby_4-0-0_ubuntu-latest + runs-on: ubuntu-latest steps: - name: Check out repository uses: actions/checkout@v4 @@ -935,18 +935,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &16 + name: Run tests run: "./script/bundler_wrapper exec rake test" - - name: Run tests without extension - run: "./script/bundler_wrapper exec rake test:failure" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/no_dependencies.gemfile - ruby_3-4-1_ubuntu-latest: - name: Ruby 3.4.1 - needs: validation + BUNDLE_GEMFILE: gemfiles/que-1.gemfile + ruby_4-0-0__que-1-collector_ubuntu-latest: + name: Ruby 4.0.0 - que-1-collector + needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -954,7 +953,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.4.1 + ruby-version: 4.0.0 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -964,18 +963,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" - - name: Run tests without extension - run: "./script/bundler_wrapper exec rake test:failure" + - *16 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/no_dependencies.gemfile - ruby_3-4-1__capistrano2_ubuntu-latest: - name: Ruby 3.4.1 - capistrano2 - needs: ruby_3-4-1_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/que-1-collector.gemfile + ruby_4-0-0__que-2_ubuntu-latest: + name: Ruby 4.0.0 - que-2 + needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -983,7 +979,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.4.1 + ruby-version: 4.0.0 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -993,16 +989,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &17 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/capistrano2.gemfile - ruby_3-4-1__capistrano3_ubuntu-latest: - name: Ruby 3.4.1 - capistrano3 - needs: ruby_3-4-1_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/que-2.gemfile + ruby_4-0-0__que-2-collector_ubuntu-latest: + name: Ruby 4.0.0 - que-2-collector + needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -1010,7 +1007,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.4.1 + ruby-version: 4.0.0 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -1020,16 +1017,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *17 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/capistrano3.gemfile - ruby_3-4-1__code_ownership_ubuntu-latest: - name: Ruby 3.4.1 - code_ownership - needs: ruby_3-4-1_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/que-2-collector.gemfile + ruby_4-0-0__rails-7-0_ubuntu-latest: + name: Ruby 4.0.0 - rails-7.0 + needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -1037,7 +1033,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.4.1 + ruby-version: 4.0.0 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -1047,16 +1043,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &18 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/code_ownership.gemfile - ruby_3-4-1__dry-monitor_ubuntu-latest: - name: Ruby 3.4.1 - dry-monitor - needs: ruby_3-4-1_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/rails-7.0.gemfile + ruby_4-0-0__rails-7-0-collector_ubuntu-latest: + name: Ruby 4.0.0 - rails-7.0-collector + needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -1064,7 +1061,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.4.1 + ruby-version: 4.0.0 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -1074,16 +1071,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *18 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/dry-monitor.gemfile - ruby_3-4-1__faraday-1_ubuntu-latest: - name: Ruby 3.4.1 - faraday-1 - needs: ruby_3-4-1_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/rails-7.0-collector.gemfile + ruby_4-0-0__rails-7-1_ubuntu-latest: + name: Ruby 4.0.0 - rails-7.1 + needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -1091,7 +1087,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.4.1 + ruby-version: 4.0.0 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -1101,16 +1097,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &19 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/faraday-1.gemfile - ruby_3-4-1__faraday-2_ubuntu-latest: - name: Ruby 3.4.1 - faraday-2 - needs: ruby_3-4-1_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/rails-7.1.gemfile + ruby_4-0-0__rails-7-1-collector_ubuntu-latest: + name: Ruby 4.0.0 - rails-7.1-collector + needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -1118,7 +1115,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.4.1 + ruby-version: 4.0.0 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -1128,24 +1125,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" + - *19 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/faraday-2.gemfile - ruby_3-4-1__grape_ubuntu-latest: - name: Ruby 3.4.1 - grape - needs: ruby_3-4-1_ubuntu-latest - runs-on: 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 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" @@ -1155,16 +1151,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &20 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/grape.gemfile - ruby_3-4-1__hanami-2-0_ubuntu-latest: - name: Ruby 3.4.1 - hanami-2.0 - needs: ruby_3-4-1_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/rails-7.2.gemfile + ruby_4-0-0__rails-7-2-collector_ubuntu-latest: + name: Ruby 4.0.0 - rails-7.2-collector + needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -1172,7 +1169,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.4.1 + ruby-version: 4.0.0 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -1182,16 +1179,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *20 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/hanami-2.0.gemfile - ruby_3-4-1__hanami-2-1_ubuntu-latest: - name: Ruby 3.4.1 - hanami-2.1 - needs: ruby_3-4-1_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/rails-7.2-collector.gemfile + ruby_4-0-0__rails-8-0_ubuntu-latest: + name: Ruby 4.0.0 - rails-8.0 + needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -1199,7 +1195,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.4.1 + ruby-version: 4.0.0 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -1209,16 +1205,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &21 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/hanami-2.1.gemfile - ruby_3-4-1__hanami-2-2_ubuntu-latest: - name: Ruby 3.4.1 - hanami-2.2 - needs: ruby_3-4-1_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/rails-8.0.gemfile + ruby_4-0-0__rails-8-0-collector_ubuntu-latest: + name: Ruby 4.0.0 - rails-8.0-collector + needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -1226,7 +1223,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.4.1 + ruby-version: 4.0.0 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -1236,16 +1233,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *21 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/hanami-2.2.gemfile - ruby_3-4-1__http5_ubuntu-latest: - name: Ruby 3.4.1 - http5 - needs: ruby_3-4-1_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/rails-8.0-collector.gemfile + ruby_4-0-0__resque-2_ubuntu-latest: + name: Ruby 4.0.0 - resque-2 + needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -1253,7 +1249,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.4.1 + ruby-version: 4.0.0 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -1263,16 +1259,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &22 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/http5.gemfile - ruby_3-4-1__http6_ubuntu-latest: - name: Ruby 3.4.1 - http6 - needs: ruby_3-4-1_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/resque-2.gemfile + ruby_4-0-0__resque-2-collector_ubuntu-latest: + name: Ruby 4.0.0 - resque-2-collector + needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -1280,7 +1277,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.4.1 + ruby-version: 4.0.0 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -1290,16 +1287,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *22 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/http6.gemfile - ruby_3-4-1__mongo_ubuntu-latest: - name: Ruby 3.4.1 - mongo - needs: ruby_3-4-1_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/resque-2-collector.gemfile + ruby_4-0-0__resque-3_ubuntu-latest: + name: Ruby 4.0.0 - resque-3 + needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -1307,7 +1303,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.4.1 + ruby-version: 4.0.0 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -1317,16 +1313,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &23 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/mongo.gemfile - ruby_3-4-1__ownership_ubuntu-latest: - name: Ruby 3.4.1 - ownership - needs: ruby_3-4-1_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/resque-3.gemfile + ruby_4-0-0__resque-3-collector_ubuntu-latest: + name: Ruby 4.0.0 - resque-3-collector + needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -1334,7 +1331,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.4.1 + ruby-version: 4.0.0 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -1344,16 +1341,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *23 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/ownership.gemfile - ruby_3-4-1__padrino_ubuntu-latest: - name: Ruby 3.4.1 - padrino - needs: ruby_3-4-1_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/resque-3-collector.gemfile + ruby_4-0-0__sequel_ubuntu-latest: + name: Ruby 4.0.0 - sequel + needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -1361,7 +1357,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.4.1 + ruby-version: 4.0.0 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -1371,16 +1367,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &24 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/padrino.gemfile - ruby_3-4-1__psych-3_ubuntu-latest: - name: Ruby 3.4.1 - psych-3 - needs: ruby_3-4-1_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/sequel.gemfile + ruby_4-0-0__sequel-collector_ubuntu-latest: + name: Ruby 4.0.0 - sequel-collector + needs: ruby_4-0-0_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -1388,7 +1385,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.4.1 + ruby-version: 4.0.0 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -1398,16 +1395,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *24 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/psych-3.gemfile - ruby_3-4-1__psych-4_ubuntu-latest: - name: Ruby 3.4.1 - psych-4 - needs: ruby_3-4-1_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/sequel-collector.gemfile + ruby_4-0-0__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 @@ -1415,7 +1411,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.4.1 + ruby-version: 4.0.0 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -1425,16 +1421,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &25 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/psych-4.gemfile - ruby_3-4-1__que-1_ubuntu-latest: - name: Ruby 3.4.1 - que-1 - needs: ruby_3-4-1_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/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 @@ -1442,7 +1439,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.4.1 + ruby-version: 4.0.0 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -1452,16 +1449,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *25 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/que-1.gemfile - ruby_3-4-1__que-2_ubuntu-latest: - name: Ruby 3.4.1 - que-2 - needs: ruby_3-4-1_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/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 @@ -1469,7 +1465,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.4.1 + ruby-version: 4.0.0 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -1479,16 +1475,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &26 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/que-2.gemfile - ruby_3-4-1__rails-7-0_ubuntu-latest: - name: Ruby 3.4.1 - rails-7.0 - needs: ruby_3-4-1_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/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 @@ -1496,7 +1493,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.4.1 + ruby-version: 4.0.0 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -1506,16 +1503,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *26 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/rails-7.0.gemfile - ruby_3-4-1__rails-7-1_ubuntu-latest: - name: Ruby 3.4.1 - rails-7.1 - needs: ruby_3-4-1_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/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 @@ -1523,7 +1519,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.4.1 + ruby-version: 4.0.0 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -1533,16 +1529,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &27 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/rails-7.1.gemfile - ruby_3-4-1__rails-7-2_ubuntu-latest: - name: Ruby 3.4.1 - rails-7.2 - needs: ruby_3-4-1_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/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 @@ -1550,7 +1547,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.4.1 + ruby-version: 4.0.0 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -1560,16 +1557,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *27 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/rails-7.2.gemfile - ruby_3-4-1__rails-8-0_ubuntu-latest: - name: Ruby 3.4.1 - rails-8.0 - needs: ruby_3-4-1_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/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 @@ -1577,7 +1573,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.4.1 + ruby-version: 4.0.0 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -1587,16 +1583,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &28 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/rails-8.0.gemfile - ruby_3-4-1__rails-8-1_ubuntu-latest: - name: Ruby 3.4.1 - rails-8.1 - needs: ruby_3-4-1_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/redis-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 @@ -1604,7 +1601,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.4.1 + ruby-version: 4.0.0 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -1614,16 +1611,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *28 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/rails-8.1.gemfile - ruby_3-4-1__resque-2_ubuntu-latest: - name: Ruby 3.4.1 - resque-2 - needs: ruby_3-4-1_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/redis-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 @@ -1631,7 +1627,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.4.1 + ruby-version: 4.0.0 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -1641,16 +1637,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &29 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/resque-2.gemfile - ruby_3-4-1__resque-3_ubuntu-latest: - name: Ruby 3.4.1 - resque-3 - needs: ruby_3-4-1_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/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 @@ -1658,7 +1655,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.4.1 + ruby-version: 4.0.0 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -1668,16 +1665,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *29 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/resque-3.gemfile - ruby_3-4-1__sequel_ubuntu-latest: - name: Ruby 3.4.1 - sequel - needs: ruby_3-4-1_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/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 @@ -1685,7 +1681,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.4.1 + ruby-version: 4.0.0 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -1695,16 +1691,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &30 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/sequel.gemfile - ruby_3-4-1__shoryuken-7_ubuntu-latest: - name: Ruby 3.4.1 - shoryuken-7 - needs: ruby_3-4-1_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/sidekiq-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 @@ -1712,7 +1709,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.4.1 + ruby-version: 4.0.0 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -1722,24 +1719,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" + - *30 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/shoryuken-7.gemfile - ruby_3-4-1__sinatra_ubuntu-latest: - name: Ruby 3.4.1 - sinatra - needs: ruby_3-4-1_ubuntu-latest - 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" @@ -1751,14 +1747,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 @@ -1776,15 +1774,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 + - &31 + 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: @@ -1803,15 +1804,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" + - *31 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: @@ -1830,15 +1830,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 + - &32 + 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: @@ -1857,15 +1858,14 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *32 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/sidekiq-7.gemfile - ruby_3-4-1__sidekiq-8_ubuntu-latest: - name: Ruby 3.4.1 - sidekiq-8 + BUNDLE_GEMFILE: gemfiles/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: @@ -1884,17 +1884,18 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &33 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/sidekiq-8.gemfile - ruby_3-4-1_macos-14: - name: Ruby 3.4.1 (macos-14) - needs: validation - runs-on: macos-14 + BUNDLE_GEMFILE: gemfiles/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 @@ -1911,18 +1912,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" - - name: Run tests without extension - run: "./script/bundler_wrapper exec rake test:failure" + - *33 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/no_dependencies.gemfile - ruby_3-3-4_ubuntu-latest: - name: Ruby 3.3.4 - needs: validation + BUNDLE_GEMFILE: gemfiles/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 @@ -1930,7 +1928,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.3.4 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -1940,18 +1938,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &34 + name: Run tests run: "./script/bundler_wrapper exec rake test" - - name: Run tests without extension - run: "./script/bundler_wrapper exec rake test:failure" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/no_dependencies.gemfile - ruby_3-3-4__capistrano2_ubuntu-latest: - name: Ruby 3.3.4 - capistrano2 - needs: ruby_3-3-4_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/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 @@ -1959,7 +1956,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.3.4 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -1969,16 +1966,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *34 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/capistrano2.gemfile - ruby_3-3-4__capistrano3_ubuntu-latest: - name: Ruby 3.3.4 - capistrano3 - needs: ruby_3-3-4_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/code_ownership-collector.gemfile + ruby_3-4-1__dry-monitor_ubuntu-latest: + name: Ruby 3.4.1 - dry-monitor + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -1986,7 +1982,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.3.4 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -1996,16 +1992,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &35 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/capistrano3.gemfile - ruby_3-3-4__code_ownership_ubuntu-latest: - name: Ruby 3.3.4 - code_ownership - needs: ruby_3-3-4_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/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 @@ -2013,7 +2010,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.3.4 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -2023,16 +2020,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *35 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/code_ownership.gemfile - ruby_3-3-4__dry-monitor_ubuntu-latest: - name: Ruby 3.3.4 - dry-monitor - needs: ruby_3-3-4_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/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 @@ -2040,7 +2036,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.3.4 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -2050,16 +2046,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &36 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/dry-monitor.gemfile - ruby_3-3-4__faraday-1_ubuntu-latest: - name: Ruby 3.3.4 - faraday-1 - needs: ruby_3-3-4_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/excon.gemfile + ruby_3-4-1__excon-collector_ubuntu-latest: + name: Ruby 3.4.1 - excon-collector + needs: ruby_3-4-1_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -2067,7 +2064,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.3.4 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -2077,16 +2074,43 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - *36 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + 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 + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.4.1 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &37 + 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-2_ubuntu-latest: - name: Ruby 3.3.4 - faraday-2 - needs: ruby_3-3-4_ubuntu-latest + 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 @@ -2094,7 +2118,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.3.4 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -2104,16 +2128,43 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - *37 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + 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 + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.4.1 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &38 + 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 + 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 @@ -2121,7 +2172,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.3.4 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -2131,16 +2182,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *38 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/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-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 @@ -2148,7 +2198,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.3.4 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -2158,16 +2208,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &39 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/hanami-2.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/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 @@ -2175,7 +2226,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.3.4 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -2185,16 +2236,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *39 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/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/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 @@ -2202,7 +2252,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.3.4 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -2212,16 +2262,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &40 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/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/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 @@ -2229,7 +2280,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.3.4 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -2239,16 +2290,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *40 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/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/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 @@ -2256,7 +2306,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.3.4 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -2266,16 +2316,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &41 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/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.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 @@ -2283,7 +2334,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.3.4 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -2293,16 +2344,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/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.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 @@ -2310,7 +2360,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.3.4 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -2320,16 +2370,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/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.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 @@ -2337,7 +2388,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.3.4 + ruby-version: 3.4.1 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -2347,16 +2398,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/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.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 @@ -2364,7 +2414,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" @@ -2374,16 +2424,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/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/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 @@ -2391,7 +2442,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" @@ -2401,16 +2452,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/psych-4.gemfile - ruby_3-3-4__que-1_ubuntu-latest: - name: Ruby 3.3.4 - que-1 - needs: ruby_3-3-4_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/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 @@ -2418,7 +2468,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" @@ -2428,16 +2478,4345 @@ jobs: - 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/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/http6.gemfile + ruby_3-4-1__http6-collector_ubuntu-latest: + name: Ruby 3.4.1 - http6-collector + needs: ruby_3-4-1_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.4.1 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *44 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/http6-collector.gemfile + ruby_3-4-1__mongo_ubuntu-latest: + name: Ruby 3.4.1 - mongo + needs: ruby_3-4-1_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.4.1 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &45 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/mongo.gemfile + ruby_3-4-1__mongo-collector_ubuntu-latest: + name: Ruby 3.4.1 - mongo-collector + needs: ruby_3-4-1_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.4.1 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *45 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/mongo-collector.gemfile + ruby_3-4-1__ownership_ubuntu-latest: + name: Ruby 3.4.1 - ownership + needs: ruby_3-4-1_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.4.1 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &46 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/ownership.gemfile + ruby_3-4-1__ownership-collector_ubuntu-latest: + name: Ruby 3.4.1 - ownership-collector + needs: ruby_3-4-1_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.4.1 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *46 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/ownership-collector.gemfile + ruby_3-4-1__padrino_ubuntu-latest: + name: Ruby 3.4.1 - padrino + needs: ruby_3-4-1_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.4.1 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &47 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/padrino.gemfile + ruby_3-4-1__padrino-collector_ubuntu-latest: + name: Ruby 3.4.1 - padrino-collector + needs: ruby_3-4-1_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.4.1 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *47 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/padrino-collector.gemfile + ruby_3-4-1__psych-3_ubuntu-latest: + name: Ruby 3.4.1 - psych-3 + needs: ruby_3-4-1_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.4.1 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &48 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/psych-3.gemfile + ruby_3-4-1__psych-3-collector_ubuntu-latest: + name: Ruby 3.4.1 - psych-3-collector + needs: ruby_3-4-1_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.4.1 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *48 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/psych-3-collector.gemfile + ruby_3-4-1__psych-4_ubuntu-latest: + name: Ruby 3.4.1 - psych-4 + needs: ruby_3-4-1_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.4.1 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &49 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/psych-4.gemfile + ruby_3-4-1__psych-4-collector_ubuntu-latest: + name: Ruby 3.4.1 - psych-4-collector + needs: ruby_3-4-1_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.4.1 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *49 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/psych-4-collector.gemfile + ruby_3-4-1__que-1_ubuntu-latest: + name: Ruby 3.4.1 - que-1 + needs: ruby_3-4-1_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.4.1 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &50 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/que-1.gemfile + ruby_3-4-1__que-1-collector_ubuntu-latest: + name: Ruby 3.4.1 - que-1-collector + needs: ruby_3-4-1_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.4.1 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *50 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/que-1-collector.gemfile + ruby_3-4-1__que-2_ubuntu-latest: + name: Ruby 3.4.1 - que-2 + needs: ruby_3-4-1_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.4.1 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &51 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/que-2.gemfile + ruby_3-4-1__que-2-collector_ubuntu-latest: + name: Ruby 3.4.1 - que-2-collector + needs: ruby_3-4-1_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.4.1 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *51 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/que-2-collector.gemfile + ruby_3-4-1__rails-7-0_ubuntu-latest: + name: Ruby 3.4.1 - rails-7.0 + needs: ruby_3-4-1_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.4.1 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &52 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/rails-7.0.gemfile + ruby_3-4-1__rails-7-0-collector_ubuntu-latest: + name: Ruby 3.4.1 - rails-7.0-collector + needs: ruby_3-4-1_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.4.1 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *52 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/rails-7.0-collector.gemfile + ruby_3-4-1__rails-7-1_ubuntu-latest: + name: Ruby 3.4.1 - rails-7.1 + needs: ruby_3-4-1_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.4.1 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &53 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/rails-7.1.gemfile + ruby_3-4-1__rails-7-1-collector_ubuntu-latest: + name: Ruby 3.4.1 - rails-7.1-collector + needs: ruby_3-4-1_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.4.1 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *53 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/rails-7.1-collector.gemfile + ruby_3-4-1__rails-7-2_ubuntu-latest: + name: Ruby 3.4.1 - rails-7.2 + needs: ruby_3-4-1_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.4.1 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &54 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/rails-7.2.gemfile + ruby_3-4-1__rails-7-2-collector_ubuntu-latest: + name: Ruby 3.4.1 - rails-7.2-collector + needs: ruby_3-4-1_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.4.1 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *54 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/rails-7.2-collector.gemfile + ruby_3-4-1__rails-8-0_ubuntu-latest: + name: Ruby 3.4.1 - rails-8.0 + needs: ruby_3-4-1_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.4.1 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &55 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/rails-8.0.gemfile + ruby_3-4-1__rails-8-0-collector_ubuntu-latest: + name: Ruby 3.4.1 - rails-8.0-collector + needs: ruby_3-4-1_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.4.1 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *55 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/rails-8.0-collector.gemfile + ruby_3-4-1__rails-8-1_ubuntu-latest: + name: Ruby 3.4.1 - rails-8.1 + needs: ruby_3-4-1_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.4.1 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &56 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/rails-8.1.gemfile + ruby_3-4-1__rails-8-1-collector_ubuntu-latest: + name: Ruby 3.4.1 - rails-8.1-collector + needs: ruby_3-4-1_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.4.1 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *56 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/rails-8.1-collector.gemfile + ruby_3-4-1__resque-2_ubuntu-latest: + name: Ruby 3.4.1 - resque-2 + needs: ruby_3-4-1_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.4.1 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &57 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/resque-2.gemfile + ruby_3-4-1__resque-2-collector_ubuntu-latest: + name: Ruby 3.4.1 - resque-2-collector + needs: ruby_3-4-1_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.4.1 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *57 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/resque-2-collector.gemfile + ruby_3-4-1__resque-3_ubuntu-latest: + name: Ruby 3.4.1 - resque-3 + needs: ruby_3-4-1_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.4.1 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &58 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/resque-3.gemfile + ruby_3-4-1__resque-3-collector_ubuntu-latest: + name: Ruby 3.4.1 - resque-3-collector + needs: ruby_3-4-1_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.4.1 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *58 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/resque-3-collector.gemfile + ruby_3-4-1__sequel_ubuntu-latest: + name: Ruby 3.4.1 - sequel + needs: ruby_3-4-1_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.4.1 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &59 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/sequel.gemfile + ruby_3-4-1__sequel-collector_ubuntu-latest: + name: Ruby 3.4.1 - sequel-collector + needs: ruby_3-4-1_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.4.1 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *59 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/sequel-collector.gemfile + ruby_3-4-1__sinatra_ubuntu-latest: + name: Ruby 3.4.1 - sinatra + needs: ruby_3-4-1_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.4.1 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &60 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/sinatra.gemfile + ruby_3-4-1__sinatra-collector_ubuntu-latest: + name: Ruby 3.4.1 - sinatra-collector + needs: ruby_3-4-1_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.4.1 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *60 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/sinatra-collector.gemfile + ruby_3-4-1__webmachine2_ubuntu-latest: + name: Ruby 3.4.1 - webmachine2 + needs: ruby_3-4-1_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.4.1 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &61 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/webmachine2.gemfile + ruby_3-4-1__webmachine2-collector_ubuntu-latest: + name: Ruby 3.4.1 - webmachine2-collector + needs: ruby_3-4-1_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.4.1 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *61 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/webmachine2-collector.gemfile + ruby_3-4-1__redis-4_ubuntu-latest: + name: Ruby 3.4.1 - redis-4 + needs: ruby_3-4-1_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.4.1 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &62 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/redis-4.gemfile + ruby_3-4-1__redis-4-collector_ubuntu-latest: + name: Ruby 3.4.1 - redis-4-collector + needs: ruby_3-4-1_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.4.1 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *62 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/redis-4-collector.gemfile + ruby_3-4-1__redis-5_ubuntu-latest: + name: Ruby 3.4.1 - redis-5 + needs: ruby_3-4-1_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.4.1 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &63 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/redis-5.gemfile + ruby_3-4-1__redis-5-collector_ubuntu-latest: + name: Ruby 3.4.1 - redis-5-collector + needs: ruby_3-4-1_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.4.1 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *63 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/redis-5-collector.gemfile + ruby_3-4-1__sidekiq-7_ubuntu-latest: + name: Ruby 3.4.1 - sidekiq-7 + needs: ruby_3-4-1_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.4.1 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &64 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/sidekiq-7.gemfile + ruby_3-4-1__sidekiq-7-collector_ubuntu-latest: + name: Ruby 3.4.1 - sidekiq-7-collector + needs: ruby_3-4-1_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.4.1 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *64 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/sidekiq-7-collector.gemfile + ruby_3-4-1__sidekiq-8_ubuntu-latest: + name: Ruby 3.4.1 - sidekiq-8 + needs: ruby_3-4-1_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.4.1 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &65 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/sidekiq-8.gemfile + ruby_3-4-1__sidekiq-8-collector_ubuntu-latest: + name: Ruby 3.4.1 - sidekiq-8-collector + needs: ruby_3-4-1_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.4.1 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *65 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/sidekiq-8-collector.gemfile + ruby_3-4-1_macos-14: + name: Ruby 3.4.1 (macos-14) + needs: validation + runs-on: macos-14 + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.4.1 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - name: Run tests + run: "./script/bundler_wrapper exec rake test" + - name: Run tests without extension + run: "./script/bundler_wrapper exec rake test:failure" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/no_dependencies.gemfile + ruby_3-3-4_ubuntu-latest: + name: Ruby 3.3.4 + needs: validation + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &66 + 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'" + - *66 + 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'" + - &67 + 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'" + - *67 + 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'" + - &68 + 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'" + - *68 + 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'" + - &69 + 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'" + - *69 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/code_ownership-collector.gemfile + ruby_3-3-4__dry-monitor_ubuntu-latest: + name: Ruby 3.3.4 - dry-monitor + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &70 + 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'" + - *70 + 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'" + - &71 + 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'" + - *71 + 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'" + - &72 + 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'" + - *72 + 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'" + - &73 + 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'" + - *73 + 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'" + - &74 + 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'" + - *74 + 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'" + - &75 + 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'" + - *75 + 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'" + - &76 + 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'" + - *76 + 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'" + - &77 + 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'" + - *77 + 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'" + - &78 + 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'" + - *78 + 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'" + - &79 + 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'" + - *79 + 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'" + - &80 + 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'" + - *80 + 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'" + - &81 + 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'" + - *81 + 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'" + - &82 + 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'" + - *82 + 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'" + - &83 + 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'" + - *83 + 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'" + - &84 + 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'" + - *84 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/psych-4-collector.gemfile + ruby_3-3-4__que-1_ubuntu-latest: + name: Ruby 3.3.4 - que-1 + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &85 + 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'" + - *85 + 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'" + - &86 + 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'" + - *86 + 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'" + - &87 + 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'" + - *87 + 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'" + - &88 + 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'" + - *88 + 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'" + - &89 + 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'" + - *89 + 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'" + - &90 + 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'" + - *90 + 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'" + - &91 + 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'" + - *91 + 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'" + - &92 + 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'" + - *92 + 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'" + - &93 + 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'" + - *93 + 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'" + - &94 + 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'" + - *94 + 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'" + - &95 + 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'" + - *95 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/sequel-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'" + - &96 + 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'" + - *96 + 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'" + - &97 + 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'" + - *97 + 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'" + - &98 + 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'" + - *98 + 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'" + - &99 + 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'" + - *99 + 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'" + - &100 + 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'" + - *100 + 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'" + - &101 + 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'" + - *101 + 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'" + - &102 + 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'" + - *102 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/capistrano3-collector.gemfile + ruby_3-2-5__dry-monitor_ubuntu-latest: + name: Ruby 3.2.5 - dry-monitor + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &103 + 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'" + - *103 + 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'" + - &104 + 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'" + - *104 + 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'" + - &105 + 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'" + - *105 + 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'" + - &106 + 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'" + - *106 + 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'" + - &107 + 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'" + - *107 + 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'" + - &108 + 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'" + - *108 + 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'" + - &109 + 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'" + - *109 + 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'" + - &110 + 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'" + - *110 + 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'" + - &111 + 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'" + - *111 + 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'" + - &112 + 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'" + - *112 + 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'" + - &113 + 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'" + - *113 + 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'" + - &114 + 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'" + - *114 + 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'" + - &115 + 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'" + - *115 + 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'" + - &116 + 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'" + - *116 + 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'" + - &117 + 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'" + - *117 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/psych-4-collector.gemfile + ruby_3-2-5__que-1_ubuntu-latest: + name: Ruby 3.2.5 - que-1 + needs: ruby_3-2-5_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.5 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &118 + 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'" + - *118 + 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'" + - &119 + 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'" + - *119 + 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'" + - &120 + 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'" + - *120 + 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'" + - &121 + 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'" + - *121 + 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'" + - &122 + 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'" + - *122 + 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'" + - &123 + 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 @@ -2445,7 +6824,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.3.4 + ruby-version: 3.2.5 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -2455,16 +6834,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" + - *123 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/que-2.gemfile - ruby_3-3-4__rails-6-1_ubuntu-latest: - name: Ruby 3.3.4 - rails-6.1 - needs: ruby_3-3-4_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/rails-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 @@ -2472,7 +6850,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.3.4 + ruby-version: 3.2.5 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -2482,16 +6860,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 + - &124 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/rails-6.1.gemfile - ruby_3-3-4__rails-7-0_ubuntu-latest: - name: Ruby 3.3.4 - rails-7.0 - needs: ruby_3-3-4_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/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 @@ -2499,7 +6878,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.3.4 + ruby-version: 3.2.5 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -2509,16 +6888,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" + - *124 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/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 @@ -2526,7 +6904,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.3.4 + ruby-version: 3.2.5 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -2536,16 +6914,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 + - &125 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/rails-7.1.gemfile - ruby_3-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/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 @@ -2553,7 +6932,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.3.4 + ruby-version: 3.2.5 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -2563,16 +6942,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" + - *125 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/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 @@ -2580,7 +6958,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.3.4 + ruby-version: 3.2.5 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -2590,16 +6968,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 + - &126 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/rails-8.0.gemfile - ruby_3-3-4__rails-8-1_ubuntu-latest: - name: Ruby 3.3.4 - rails-8.1 - needs: ruby_3-3-4_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/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 @@ -2607,7 +6986,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.3.4 + ruby-version: 3.2.5 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -2617,16 +6996,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" + - *126 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/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 @@ -2634,7 +7012,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.3.4 + ruby-version: 3.2.5 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -2644,16 +7022,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &127 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/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/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 @@ -2661,7 +7040,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.3.4 + ruby-version: 3.2.5 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -2671,16 +7050,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *127 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/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/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 @@ -2688,7 +7066,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.3.4 + ruby-version: 3.2.5 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -2698,16 +7076,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &128 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' BUNDLE_GEMFILE: gemfiles/sequel.gemfile - ruby_3-3-4__shoryuken-7_ubuntu-latest: - name: Ruby 3.3.4 - shoryuken-7 - needs: ruby_3-3-4_ubuntu-latest + 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 @@ -2715,7 +7094,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.3.4 + ruby-version: 3.2.5 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -2725,16 +7104,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *128 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/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/sequel-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 @@ -2742,7 +7120,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.3.4 + ruby-version: 3.2.5 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -2752,16 +7130,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &129 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' BUNDLE_GEMFILE: gemfiles/sinatra.gemfile - ruby_3-3-4__webmachine2_ubuntu-latest: - name: Ruby 3.3.4 - webmachine2 - needs: ruby_3-3-4_ubuntu-latest + 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 @@ -2769,7 +7148,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.3.4 + ruby-version: 3.2.5 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -2779,16 +7158,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *129 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/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/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 @@ -2796,7 +7174,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.3.4 + ruby-version: 3.2.5 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -2806,16 +7184,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &130 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/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/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 @@ -2823,7 +7202,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.3.4 + ruby-version: 3.2.5 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -2833,24 +7212,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" + - *130 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/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.3.4 + ruby-version: 3.2.5 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -2860,18 +7238,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &131 + name: Run tests run: "./script/bundler_wrapper exec rake test" - - name: Run tests without extension - run: "./script/bundler_wrapper exec rake test:failure" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/no_dependencies.gemfile - ruby_3-2-5_ubuntu-latest: - name: Ruby 3.2.5 - needs: validation + BUNDLE_GEMFILE: gemfiles/redis-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 @@ -2889,17 +7266,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" + - *131 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 + 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: @@ -2918,15 +7292,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 + - &132 + 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__capistrano3_ubuntu-latest: - name: Ruby 3.2.5 - capistrano3 + 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: @@ -2945,17 +7320,16 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *132 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/capistrano3.gemfile - ruby_3-2-5__dry-monitor_ubuntu-latest: - name: Ruby 3.2.5 - dry-monitor - needs: ruby_3-2-5_ubuntu-latest - runs-on: ubuntu-latest + BUNDLE_GEMFILE: gemfiles/redis-5-collector.gemfile + ruby_3-2-5_macos-14: + name: Ruby 3.2.5 (macos-14) + needs: validation + runs-on: macos-14 steps: - name: Check out repository uses: actions/checkout@v4 @@ -2974,14 +7348,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/dry-monitor.gemfile - ruby_3-2-5__faraday-1_ubuntu-latest: - name: Ruby 3.2.5 - faraday-1 - needs: ruby_3-2-5_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/no_dependencies.gemfile + ruby_3-1-6_ubuntu-latest: + name: Ruby 3.1.6 + needs: validation runs-on: ubuntu-latest steps: - name: Check out repository @@ -2989,7 +7365,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.2.5 + ruby-version: 3.1.6 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -2999,16 +7375,19 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &133 + 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/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/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 @@ -3016,7 +7395,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.2.5 + ruby-version: 3.1.6 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -3026,16 +7405,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *133 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/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/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 @@ -3043,7 +7421,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.2.5 + ruby-version: 3.1.6 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -3053,16 +7431,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &134 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/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/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 @@ -3070,7 +7449,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.2.5 + ruby-version: 3.1.6 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -3080,16 +7459,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *134 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/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/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 @@ -3097,7 +7475,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.2.5 + ruby-version: 3.1.6 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -3107,16 +7485,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &135 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/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/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 @@ -3124,7 +7503,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.2.5 + ruby-version: 3.1.6 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -3134,16 +7513,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" + - *135 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/capistrano3-collector.gemfile + ruby_3-1-6__dry-monitor_ubuntu-latest: + name: Ruby 3.1.6 - dry-monitor + needs: ruby_3-1-6_ubuntu-latest runs-on: ubuntu-latest steps: - name: Check out repository @@ -3151,7 +7529,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.2.5 + ruby-version: 3.1.6 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -3161,16 +7539,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &136 + name: Run tests run: "./script/bundler_wrapper exec rake test" 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/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 @@ -3178,7 +7557,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.2.5 + ruby-version: 3.1.6 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -3188,16 +7567,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" + - *136 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/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 @@ -3205,7 +7583,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.2.5 + ruby-version: 3.1.6 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -3215,16 +7593,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 + - &137 + 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/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 @@ -3232,7 +7611,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.2.5 + ruby-version: 3.1.6 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -3242,16 +7621,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *137 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/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/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 @@ -3259,7 +7637,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.2.5 + ruby-version: 3.1.6 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -3269,16 +7647,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &138 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/padrino.gemfile - ruby_3-2-5__psych-3_ubuntu-latest: - name: Ruby 3.2.5 - psych-3 - needs: ruby_3-2-5_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/faraday-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 @@ -3286,7 +7665,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.2.5 + ruby-version: 3.1.6 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -3296,16 +7675,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *138 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/psych-3.gemfile - ruby_3-2-5__psych-4_ubuntu-latest: - name: Ruby 3.2.5 - psych-4 - needs: ruby_3-2-5_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/faraday-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 @@ -3313,7 +7691,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.2.5 + ruby-version: 3.1.6 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -3323,16 +7701,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &139 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/psych-4.gemfile - ruby_3-2-5__que-1_ubuntu-latest: - name: Ruby 3.2.5 - que-1 - needs: ruby_3-2-5_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/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 @@ -3340,7 +7719,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.2.5 + ruby-version: 3.1.6 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -3350,16 +7729,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *139 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/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/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 @@ -3367,7 +7745,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.2.5 + ruby-version: 3.1.6 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -3377,16 +7755,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &140 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/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/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 @@ -3394,7 +7773,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.2.5 + ruby-version: 3.1.6 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -3404,16 +7783,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *140 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/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/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 @@ -3421,7 +7799,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.2.5 + ruby-version: 3.1.6 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -3431,16 +7809,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &141 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/rails-7.0.gemfile - ruby_3-2-5__rails-7-1_ubuntu-latest: - name: Ruby 3.2.5 - rails-7.1 - needs: ruby_3-2-5_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/hanami-2.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 @@ -3448,7 +7827,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.2.5 + ruby-version: 3.1.6 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -3458,16 +7837,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *141 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/rails-7.1.gemfile - ruby_3-2-5__rails-7-2_ubuntu-latest: - name: Ruby 3.2.5 - rails-7.2 - needs: ruby_3-2-5_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/hanami-2.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 @@ -3475,7 +7853,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.2.5 + ruby-version: 3.1.6 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -3485,16 +7863,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &142 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/rails-7.2.gemfile - ruby_3-2-5__rails-8-0_ubuntu-latest: - name: Ruby 3.2.5 - rails-8.0 - needs: ruby_3-2-5_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/hanami-2.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 @@ -3502,7 +7881,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.2.5 + ruby-version: 3.1.6 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -3512,16 +7891,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *142 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/rails-8.0.gemfile - ruby_3-2-5__rails-8-1_ubuntu-latest: - name: Ruby 3.2.5 - rails-8.1 - needs: ruby_3-2-5_ubuntu-latest + BUNDLE_GEMFILE: gemfiles/hanami-2.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 @@ -3529,7 +7907,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.2.5 + ruby-version: 3.1.6 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -3539,16 +7917,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &143 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/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/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 @@ -3556,7 +7935,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.2.5 + ruby-version: 3.1.6 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -3566,16 +7945,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *143 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/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/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 @@ -3583,7 +7961,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.2.5 + ruby-version: 3.1.6 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -3593,16 +7971,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &144 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/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/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 @@ -3610,7 +7989,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.2.5 + ruby-version: 3.1.6 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -3620,16 +7999,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *144 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/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/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 @@ -3637,7 +8015,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.2.5 + ruby-version: 3.1.6 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -3647,16 +8025,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &145 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/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/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 @@ -3664,7 +8043,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.2.5 + ruby-version: 3.1.6 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -3674,16 +8053,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *145 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/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/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 @@ -3691,7 +8069,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.2.5 + ruby-version: 3.1.6 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -3701,16 +8079,17 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &146 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/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.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 @@ -3718,7 +8097,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.2.5 + ruby-version: 3.1.6 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -3728,16 +8107,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *146 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/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/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 @@ -3745,7 +8123,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.2.5 + ruby-version: 3.1.6 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -3755,24 +8133,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 + - &147 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/redis-5.gemfile - ruby_3-2-5_macos-14: - name: Ruby 3.2.5 (macos-14) - needs: validation - runs-on: macos-14 + BUNDLE_GEMFILE: gemfiles/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 uses: actions/checkout@v4 - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.2.5 + ruby-version: 3.1.6 bundler-cache: true - name: Install gem extension run: "./script/bundler_wrapper exec rake extension:install" @@ -3782,18 +8161,15 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" - - name: Run tests without extension - run: "./script/bundler_wrapper exec rake test:failure" + - *147 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/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 @@ -3811,17 +8187,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 + - &148 + name: Run tests run: "./script/bundler_wrapper exec rake test" - - name: Run tests without extension - run: "./script/bundler_wrapper exec rake test:failure" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/no_dependencies.gemfile - ruby_3-1-6__capistrano2_ubuntu-latest: - name: Ruby 3.1.6 - capistrano2 + BUNDLE_GEMFILE: gemfiles/psych-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: @@ -3840,15 +8215,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" + - *148 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-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: @@ -3867,15 +8241,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 + - &149 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/capistrano3.gemfile - ruby_3-1-6__dry-monitor_ubuntu-latest: - name: Ruby 3.1.6 - dry-monitor + BUNDLE_GEMFILE: gemfiles/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: @@ -3894,15 +8269,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" + - *149 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/dry-monitor.gemfile - ruby_3-1-6__faraday-1_ubuntu-latest: - name: Ruby 3.1.6 - faraday-1 + BUNDLE_GEMFILE: gemfiles/psych-4-collector.gemfile + ruby_3-1-6__que-1_ubuntu-latest: + name: Ruby 3.1.6 - que-1 needs: ruby_3-1-6_ubuntu-latest runs-on: ubuntu-latest steps: @@ -3921,15 +8295,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 + - &150 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/faraday-1.gemfile - ruby_3-1-6__faraday-2_ubuntu-latest: - name: Ruby 3.1.6 - faraday-2 + BUNDLE_GEMFILE: gemfiles/que-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: @@ -3948,15 +8323,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" + - *150 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-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: @@ -3975,15 +8349,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 + - &151 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/grape.gemfile - ruby_3-1-6__hanami-2-0_ubuntu-latest: - name: Ruby 3.1.6 - hanami-2.0 + BUNDLE_GEMFILE: gemfiles/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: @@ -4002,15 +8377,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" + - *151 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/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: @@ -4029,15 +8403,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 + - &152 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/hanami-2.1.gemfile - ruby_3-1-6__hanami-2-2_ubuntu-latest: - name: Ruby 3.1.6 - hanami-2.2 + BUNDLE_GEMFILE: gemfiles/rails-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: @@ -4056,15 +8431,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" + - *152 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-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: @@ -4083,15 +8457,16 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &153 + name: Run tests run: "./script/bundler_wrapper exec rake test" 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.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: @@ -4110,15 +8485,14 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *153 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/mongo.gemfile - ruby_3-1-6__ownership_ubuntu-latest: - name: Ruby 3.1.6 - ownership + 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: @@ -4137,15 +8511,16 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &154 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/ownership.gemfile - ruby_3-1-6__padrino_ubuntu-latest: - name: Ruby 3.1.6 - padrino + 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: @@ -4164,15 +8539,14 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *154 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/padrino.gemfile - ruby_3-1-6__psych-3_ubuntu-latest: - name: Ruby 3.1.6 - psych-3 + 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: @@ -4191,15 +8565,16 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &155 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/psych-3.gemfile - ruby_3-1-6__psych-4_ubuntu-latest: - name: Ruby 3.1.6 - psych-4 + 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: @@ -4218,15 +8593,14 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *155 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/psych-4.gemfile - ruby_3-1-6__que-1_ubuntu-latest: - name: Ruby 3.1.6 - que-1 + 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: @@ -4245,15 +8619,16 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &156 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/que-1.gemfile - ruby_3-1-6__que-2_ubuntu-latest: - name: Ruby 3.1.6 - que-2 + 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: @@ -4272,15 +8647,14 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *156 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/que-2.gemfile - ruby_3-1-6__rails-6-1_ubuntu-latest: - name: Ruby 3.1.6 - rails-6.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: @@ -4299,15 +8673,16 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &157 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/rails-6.1.gemfile - ruby_3-1-6__rails-7-0_ubuntu-latest: - name: Ruby 3.1.6 - rails-7.0 + 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: @@ -4326,15 +8701,14 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *157 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/rails-7.0.gemfile - ruby_3-1-6__rails-7-1_ubuntu-latest: - name: Ruby 3.1.6 - rails-7.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: @@ -4353,15 +8727,16 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &158 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/rails-7.1.gemfile - ruby_3-1-6__rails-7-2_ubuntu-latest: - name: Ruby 3.1.6 - rails-7.2 + 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: @@ -4380,15 +8755,14 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *158 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/rails-7.2.gemfile - ruby_3-1-6__resque-2_ubuntu-latest: - name: Ruby 3.1.6 - resque-2 + BUNDLE_GEMFILE: gemfiles/sequel-collector.gemfile + ruby_3-1-6__sinatra_ubuntu-latest: + name: Ruby 3.1.6 - sinatra needs: ruby_3-1-6_ubuntu-latest runs-on: ubuntu-latest steps: @@ -4407,15 +8781,16 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &159 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/resque-2.gemfile - ruby_3-1-6__resque-3_ubuntu-latest: - name: Ruby 3.1.6 - resque-3 + BUNDLE_GEMFILE: gemfiles/sinatra.gemfile + ruby_3-1-6__sinatra-collector_ubuntu-latest: + name: Ruby 3.1.6 - sinatra-collector needs: ruby_3-1-6_ubuntu-latest runs-on: ubuntu-latest steps: @@ -4434,15 +8809,14 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *159 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/resque-3.gemfile - ruby_3-1-6__sequel_ubuntu-latest: - name: Ruby 3.1.6 - sequel + BUNDLE_GEMFILE: gemfiles/sinatra-collector.gemfile + ruby_3-1-6__webmachine2_ubuntu-latest: + name: Ruby 3.1.6 - webmachine2 needs: ruby_3-1-6_ubuntu-latest runs-on: ubuntu-latest steps: @@ -4461,15 +8835,16 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &160 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/sequel.gemfile - ruby_3-1-6__shoryuken-6_ubuntu-latest: - name: Ruby 3.1.6 - shoryuken-6 + BUNDLE_GEMFILE: gemfiles/webmachine2.gemfile + ruby_3-1-6__webmachine2-collector_ubuntu-latest: + name: Ruby 3.1.6 - webmachine2-collector needs: ruby_3-1-6_ubuntu-latest runs-on: ubuntu-latest steps: @@ -4488,15 +8863,14 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *160 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/shoryuken-6.gemfile - ruby_3-1-6__sinatra_ubuntu-latest: - name: Ruby 3.1.6 - sinatra + BUNDLE_GEMFILE: gemfiles/webmachine2-collector.gemfile + ruby_3-1-6__redis-4_ubuntu-latest: + name: Ruby 3.1.6 - redis-4 needs: ruby_3-1-6_ubuntu-latest runs-on: ubuntu-latest steps: @@ -4515,15 +8889,16 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &161 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/sinatra.gemfile - ruby_3-1-6__webmachine2_ubuntu-latest: - name: Ruby 3.1.6 - webmachine2 + BUNDLE_GEMFILE: gemfiles/redis-4.gemfile + ruby_3-1-6__redis-4-collector_ubuntu-latest: + name: Ruby 3.1.6 - redis-4-collector needs: ruby_3-1-6_ubuntu-latest runs-on: ubuntu-latest steps: @@ -4542,15 +8917,14 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" + - *161 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/webmachine2.gemfile - ruby_3-1-6__redis-4_ubuntu-latest: - name: Ruby 3.1.6 - redis-4 + BUNDLE_GEMFILE: gemfiles/redis-4-collector.gemfile + ruby_3-1-6__redis-5_ubuntu-latest: + name: Ruby 3.1.6 - redis-5 needs: ruby_3-1-6_ubuntu-latest runs-on: ubuntu-latest steps: @@ -4569,15 +8943,16 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - name: Run tests + - &162 + name: Run tests run: "./script/bundler_wrapper exec rake test" env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/redis-4.gemfile - ruby_3-1-6__redis-5_ubuntu-latest: - name: Ruby 3.1.6 - redis-5 + BUNDLE_GEMFILE: gemfiles/redis-5.gemfile + ruby_3-1-6__redis-5-collector_ubuntu-latest: + name: Ruby 3.1.6 - redis-5-collector needs: ruby_3-1-6_ubuntu-latest runs-on: ubuntu-latest steps: @@ -4596,13 +8971,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" + - *162 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/redis-5.gemfile + BUNDLE_GEMFILE: gemfiles/redis-5-collector.gemfile ruby_3-1-6_macos-14: name: Ruby 3.1.6 (macos-14) needs: validation @@ -4742,6 +9116,33 @@ jobs: JRUBY_OPTS: '' COV: '1' BUNDLE_GEMFILE: gemfiles/dry-monitor.gemfile + ruby_3-0-7__excon_ubuntu-latest: + name: Ruby 3.0.7 - excon + needs: ruby_3-0-7_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.0.7 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/excon.gemfile ruby_3-0-7__faraday-1_ubuntu-latest: name: Ruby 3.0.7 - faraday-1 needs: ruby_3-0-7_ubuntu-latest @@ -5282,33 +9683,6 @@ jobs: JRUBY_OPTS: '' COV: '1' BUNDLE_GEMFILE: gemfiles/sequel.gemfile - ruby_3-0-7__shoryuken-6_ubuntu-latest: - name: Ruby 3.0.7 - shoryuken-6 - needs: ruby_3-0-7_ubuntu-latest - runs-on: ubuntu-latest - steps: - - name: Check out repository - uses: actions/checkout@v4 - - name: Install Ruby - uses: ruby/setup-ruby@v1 - with: - ruby-version: 3.0.7 - bundler-cache: true - - name: Install gem extension - run: "./script/bundler_wrapper exec rake extension:install" - - name: Print extension install report - run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report - file found'" - - name: Print Makefile log file - run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file - found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" - env: - RAILS_ENV: test - JRUBY_OPTS: '' - COV: '1' - BUNDLE_GEMFILE: gemfiles/shoryuken-6.gemfile ruby_3-0-7__sinatra_ubuntu-latest: name: Ruby 3.0.7 - sinatra needs: ruby_3-0-7_ubuntu-latest @@ -5529,6 +9903,33 @@ jobs: JRUBY_OPTS: '' COV: '1' BUNDLE_GEMFILE: gemfiles/capistrano3.gemfile + ruby_2-7-8__excon_ubuntu-latest: + name: Ruby 2.7.8 - excon + needs: ruby_2-7-8_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 2.7.8 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/excon.gemfile ruby_2-7-8__faraday-1_ubuntu-latest: name: Ruby 2.7.8 - faraday-1 needs: ruby_2-7-8_ubuntu-latest @@ -5934,33 +10335,6 @@ jobs: JRUBY_OPTS: '' COV: '1' BUNDLE_GEMFILE: gemfiles/sequel.gemfile - ruby_2-7-8__shoryuken-6_ubuntu-latest: - name: Ruby 2.7.8 - shoryuken-6 - needs: ruby_2-7-8_ubuntu-latest - runs-on: ubuntu-latest - steps: - - name: Check out repository - uses: actions/checkout@v4 - - name: Install Ruby - uses: ruby/setup-ruby@v1 - with: - ruby-version: 2.7.8 - bundler-cache: true - - name: Install gem extension - run: "./script/bundler_wrapper exec rake extension:install" - - name: Print extension install report - run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report - file found'" - - name: Print Makefile log file - run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file - found'" - - name: Run tests - run: "./script/bundler_wrapper exec rake test" - env: - RAILS_ENV: test - JRUBY_OPTS: '' - COV: '1' - BUNDLE_GEMFILE: gemfiles/shoryuken-6.gemfile ruby_2-7-8__sinatra_ubuntu-latest: name: Ruby 2.7.8 - sinatra needs: ruby_2-7-8_ubuntu-latest From a6b6ff10a77e419c5f9ad4dab19628251e402ab7 Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Fri, 3 Jul 2026 18:32:59 +0200 Subject: [PATCH 136/151] Restore Que bulk enqueue instrumentation Prepend QueBulkClientPlugin on Que 2 so a bulk_enqueue records one bulk_enqueue.que producer event for the whole batch and injects the trace context once; the inner enqueues pass through. Single enqueue already recorded enqueue.que, so extract the shared recording into record_enqueue and cover both single and bulk on Que 1 and Que 2. This work was present before the wip-opentelemetry rebase against main but dropped during it; the accompanying changeset was dropped too, so restore it here. See #1522 for the main-based backport. --- .changesets/add-que-distributed-tracing.md | 6 ++ lib/appsignal/hooks/que.rb | 5 ++ lib/appsignal/integrations/que.rb | 41 +++++++++- spec/lib/appsignal/integrations/que_spec.rb | 84 +++++++++++++++++++-- 4 files changed, 128 insertions(+), 8 deletions(-) create mode 100644 .changesets/add-que-distributed-tracing.md diff --git a/.changesets/add-que-distributed-tracing.md b/.changesets/add-que-distributed-tracing.md new file mode 100644 index 000000000..db8ced36d --- /dev/null +++ b/.changesets/add-que-distributed-tracing.md @@ -0,0 +1,6 @@ +--- +bump: minor +type: add +--- + +Improve Que support. In collector mode, AppSignal now propagates trace context when enqueuing Que jobs, so each job links back to the trace that enqueued it. This covers single and bulk enqueues, on both Que 1 and Que 2. diff --git a/lib/appsignal/hooks/que.rb b/lib/appsignal/hooks/que.rb index 871fdca84..22b77e5a1 100644 --- a/lib/appsignal/hooks/que.rb +++ b/lib/appsignal/hooks/que.rb @@ -15,6 +15,11 @@ def install ::Que::Job.prepend Appsignal::Integrations::QuePlugin ::Que::Job.singleton_class.prepend Appsignal::Integrations::QueClientPlugin + # `bulk_enqueue` exists only on Que 2+; don't define one where it's absent. + if ::Que::Job.respond_to?(:bulk_enqueue) + ::Que::Job.singleton_class.prepend Appsignal::Integrations::QueBulkClientPlugin + end + ::Que.error_notifier = proc do |error, _job| Appsignal::Transaction.current.set_error(error) end diff --git a/lib/appsignal/integrations/que.rb b/lib/appsignal/integrations/que.rb index c02d90ce7..d217a4242 100644 --- a/lib/appsignal/integrations/que.rb +++ b/lib/appsignal/integrations/que.rb @@ -110,10 +110,47 @@ def _run(*args) # transparent pass-through. module QueClientPlugin def enqueue(*args, job_options: {}, **rest) - Appsignal.instrument("enqueue.que", :opentelemetry_kind => :producer) do + # Inside a Que 2 `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 bulk_insert_in_progress? + + record_enqueue(job_options) do |merged| + super(*args, :job_options => merged, **rest) + end + end + + private + + # Records the enqueue as a producer event and, in collector mode, injects + # the current trace context into the job's tags so the job that later + # performs links back. Yields the (possibly tag-augmented) `job_options` to + # do the actual enqueue. + def record_enqueue(job_options, event_name = "enqueue.que") + Appsignal.instrument(event_name, :opentelemetry_kind => :producer) do tags = QueTraceContext.inject(job_options[:tags]) merged = tags.empty? ? job_options : job_options.merge(:tags => tags) - super(*args, :job_options => merged, **rest) + yield merged + end + end + + def bulk_insert_in_progress? + !Thread.current[:que_jobs_to_bulk_insert].nil? + end + end + + # @!visibility private + # + # `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 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(job_options: {}, **rest, &block) + record_enqueue(job_options, "bulk_enqueue.que") do |merged| + super(:job_options => merged, **rest, &block) end end end diff --git a/spec/lib/appsignal/integrations/que_spec.rb b/spec/lib/appsignal/integrations/que_spec.rb index cc08fe67d..e29a8abc5 100644 --- a/spec/lib/appsignal/integrations/que_spec.rb +++ b/spec/lib/appsignal/integrations/que_spec.rb @@ -347,9 +347,10 @@ def perform end end - # Enqueue-side propagation reads context from the job's tags, which only Que 2 - # persists in the layout these tests inspect; Que 1 enqueue is not covered. - describe Appsignal::Integrations::QueClientPlugin, :if => DependencyHelper.que2_present? do + # Enqueue-side propagation reads context from the job's tags. The carrier + # (tags serialized into the job's `data`) is identical on Que 1 and Que 2, so + # this is covered on both versions. + describe Appsignal::Integrations::QueClientPlugin do let(:job) do Class.new(::Que::Job) do def self.name @@ -367,10 +368,11 @@ def self.name end end - # `data` is the 7th value Que passes to its `:insert_job` query; the tags - # live under it as a JSON string. + # `data` is the last value Que passes to its `:insert_job` query on both Que + # 1 and Que 2 (Que 2 inserts `kwargs` before it, shifting its index); the + # tags live under it as a JSON string. def enqueued_tags - data = captured[:values] && captured[:values][6] + data = captured[:values]&.last data ? JSON.parse(data)["tags"] : nil end @@ -438,5 +440,75 @@ def enqueue(tags: ["user:42"]) expect(enqueued_tags).to eq(["user:42"]) 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 + before do + # Que's bulk path constantizes the job class by name, so it needs a real + # constant (the single-enqueue path uses `new` and doesn't). + stub_const("MyQueJob", job) + allow(Que).to receive(:transaction).and_yield + allow(Que).to receive(:execute) do |command, values| + captured[:values] = values if command == :bulk_insert_jobs + [{}] + end + end + + def bulk_enqueue(tags: ["user:42"]) + job.bulk_enqueue(:job_options => { :tags => tags }) do + job.enqueue("post_id_123") + job.enqueue("post_id_456") + end + end + + 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 + + event_names = transaction.to_h["events"].map { |event| event["name"] } + # One event for the whole batch -- the inner enqueues don't add their own. + expect(event_names.count { |name| name == "bulk_enqueue.que" }).to eq(1) + 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.que" } + expect(producers.size).to eq(1) + producer = producers.first + 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 + end end end From b3952674d23b3573c04583415dbbb3556f59ee6b Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Fri, 3 Jul 2026 18:37:07 +0200 Subject: [PATCH 137/151] Restore Shoryuken enqueue instrumentation Add a Shoryuken client middleware that records an enqueue.shoryuken event on the active transaction (both modes) and, in collector mode, writes the trace context onto the outgoing message as SQS message attributes so the job links back to the enqueuer. The server middleware reads that context to link the job's trace. Registered on both the client and server configs, since workers enqueue jobs too. Adds the shoryuken gemfiles, build matrix entry and CI jobs, and the shoryuken_present? dependency helper. This work was present before the wip-opentelemetry rebase against main but dropped during it; restore it here. See #1521 for the main-based backport. --- .github/workflows/ci.yml | 904 ++++++++++++------ build_matrix.yml | 14 +- gemfiles/shoryuken-collector.gemfile | 6 + gemfiles/shoryuken.gemfile | 5 + lib/appsignal/hooks/shoryuken.rb | 6 +- lib/appsignal/integrations/shoryuken.rb | 107 ++- .../integrations/shoryuken_client_spec.rb | 52 +- .../appsignal/integrations/shoryuken_spec.rb | 125 ++- 8 files changed, 877 insertions(+), 342 deletions(-) create mode 100644 gemfiles/shoryuken-collector.gemfile create mode 100644 gemfiles/shoryuken.gemfile diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 950691559..d1f6b6e29 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: 396 +# Generated job count: 408 --- name: Ruby gem CI 'on': @@ -1401,6 +1401,60 @@ jobs: JRUBY_OPTS: '' COV: '1' BUNDLE_GEMFILE: gemfiles/sequel-collector.gemfile + ruby_4-0-0__shoryuken_ubuntu-latest: + name: Ruby 4.0.0 - shoryuken + needs: ruby_4-0-0_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 4.0.0 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &25 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/shoryuken.gemfile + ruby_4-0-0__shoryuken-collector_ubuntu-latest: + name: Ruby 4.0.0 - shoryuken-collector + needs: ruby_4-0-0_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 4.0.0 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *25 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/shoryuken-collector.gemfile ruby_4-0-0__sinatra_ubuntu-latest: name: Ruby 4.0.0 - sinatra needs: ruby_4-0-0_ubuntu-latest @@ -1421,7 +1475,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &25 + - &26 name: Run tests run: "./script/bundler_wrapper exec rake test" env: @@ -1449,7 +1503,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *25 + - *26 env: RAILS_ENV: test JRUBY_OPTS: '' @@ -1475,7 +1529,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &26 + - &27 name: Run tests run: "./script/bundler_wrapper exec rake test" env: @@ -1503,7 +1557,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *26 + - *27 env: RAILS_ENV: test JRUBY_OPTS: '' @@ -1529,7 +1583,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &27 + - &28 name: Run tests run: "./script/bundler_wrapper exec rake test" env: @@ -1557,7 +1611,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *27 + - *28 env: RAILS_ENV: test JRUBY_OPTS: '' @@ -1583,7 +1637,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &28 + - &29 name: Run tests run: "./script/bundler_wrapper exec rake test" env: @@ -1611,7 +1665,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *28 + - *29 env: RAILS_ENV: test JRUBY_OPTS: '' @@ -1637,7 +1691,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &29 + - &30 name: Run tests run: "./script/bundler_wrapper exec rake test" env: @@ -1665,7 +1719,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *29 + - *30 env: RAILS_ENV: test JRUBY_OPTS: '' @@ -1691,7 +1745,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &30 + - &31 name: Run tests run: "./script/bundler_wrapper exec rake test" env: @@ -1719,7 +1773,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *30 + - *31 env: RAILS_ENV: test JRUBY_OPTS: '' @@ -1774,7 +1828,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &31 + - &32 name: Run tests run: "./script/bundler_wrapper exec rake test" - name: Run tests without extension @@ -1804,7 +1858,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *31 + - *32 env: RAILS_ENV: test JRUBY_OPTS: '' @@ -1830,7 +1884,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &32 + - &33 name: Run tests run: "./script/bundler_wrapper exec rake test" env: @@ -1858,7 +1912,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *32 + - *33 env: RAILS_ENV: test JRUBY_OPTS: '' @@ -1884,7 +1938,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &33 + - &34 name: Run tests run: "./script/bundler_wrapper exec rake test" env: @@ -1912,7 +1966,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *33 + - *34 env: RAILS_ENV: test JRUBY_OPTS: '' @@ -1938,7 +1992,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &34 + - &35 name: Run tests run: "./script/bundler_wrapper exec rake test" env: @@ -1966,7 +2020,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *34 + - *35 env: RAILS_ENV: test JRUBY_OPTS: '' @@ -1992,7 +2046,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &35 + - &36 name: Run tests run: "./script/bundler_wrapper exec rake test" env: @@ -2020,7 +2074,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *35 + - *36 env: RAILS_ENV: test JRUBY_OPTS: '' @@ -2046,7 +2100,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &36 + - &37 name: Run tests run: "./script/bundler_wrapper exec rake test" env: @@ -2074,7 +2128,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *36 + - *37 env: RAILS_ENV: test JRUBY_OPTS: '' @@ -2100,7 +2154,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &37 + - &38 name: Run tests run: "./script/bundler_wrapper exec rake test" env: @@ -2128,7 +2182,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *37 + - *38 env: RAILS_ENV: test JRUBY_OPTS: '' @@ -2154,7 +2208,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &38 + - &39 name: Run tests run: "./script/bundler_wrapper exec rake test" env: @@ -2182,7 +2236,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *38 + - *39 env: RAILS_ENV: test JRUBY_OPTS: '' @@ -2208,7 +2262,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &39 + - &40 name: Run tests run: "./script/bundler_wrapper exec rake test" env: @@ -2236,7 +2290,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *39 + - *40 env: RAILS_ENV: test JRUBY_OPTS: '' @@ -2262,7 +2316,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &40 + - &41 name: Run tests run: "./script/bundler_wrapper exec rake test" env: @@ -2290,7 +2344,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *40 + - *41 env: RAILS_ENV: test JRUBY_OPTS: '' @@ -2316,7 +2370,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &41 + - &42 name: Run tests run: "./script/bundler_wrapper exec rake test" env: @@ -2344,7 +2398,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *41 + - *42 env: RAILS_ENV: test JRUBY_OPTS: '' @@ -2370,7 +2424,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &42 + - &43 name: Run tests run: "./script/bundler_wrapper exec rake test" env: @@ -2398,7 +2452,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *42 + - *43 env: RAILS_ENV: test JRUBY_OPTS: '' @@ -2424,7 +2478,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &43 + - &44 name: Run tests run: "./script/bundler_wrapper exec rake test" env: @@ -2452,7 +2506,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *43 + - *44 env: RAILS_ENV: test JRUBY_OPTS: '' @@ -2478,7 +2532,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &44 + - &45 name: Run tests run: "./script/bundler_wrapper exec rake test" env: @@ -2506,7 +2560,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *44 + - *45 env: RAILS_ENV: test JRUBY_OPTS: '' @@ -2532,7 +2586,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &45 + - &46 name: Run tests run: "./script/bundler_wrapper exec rake test" env: @@ -2560,7 +2614,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *45 + - *46 env: RAILS_ENV: test JRUBY_OPTS: '' @@ -2586,7 +2640,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &46 + - &47 name: Run tests run: "./script/bundler_wrapper exec rake test" env: @@ -2614,7 +2668,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *46 + - *47 env: RAILS_ENV: test JRUBY_OPTS: '' @@ -2640,7 +2694,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &47 + - &48 name: Run tests run: "./script/bundler_wrapper exec rake test" env: @@ -2668,7 +2722,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *47 + - *48 env: RAILS_ENV: test JRUBY_OPTS: '' @@ -2694,7 +2748,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &48 + - &49 name: Run tests run: "./script/bundler_wrapper exec rake test" env: @@ -2722,7 +2776,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *48 + - *49 env: RAILS_ENV: test JRUBY_OPTS: '' @@ -2748,7 +2802,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &49 + - &50 name: Run tests run: "./script/bundler_wrapper exec rake test" env: @@ -2776,7 +2830,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *49 + - *50 env: RAILS_ENV: test JRUBY_OPTS: '' @@ -2802,7 +2856,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &50 + - &51 name: Run tests run: "./script/bundler_wrapper exec rake test" env: @@ -2830,7 +2884,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *50 + - *51 env: RAILS_ENV: test JRUBY_OPTS: '' @@ -2856,7 +2910,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &51 + - &52 name: Run tests run: "./script/bundler_wrapper exec rake test" env: @@ -2884,7 +2938,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *51 + - *52 env: RAILS_ENV: test JRUBY_OPTS: '' @@ -2910,7 +2964,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &52 + - &53 name: Run tests run: "./script/bundler_wrapper exec rake test" env: @@ -2938,7 +2992,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *52 + - *53 env: RAILS_ENV: test JRUBY_OPTS: '' @@ -2964,7 +3018,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &53 + - &54 name: Run tests run: "./script/bundler_wrapper exec rake test" env: @@ -2992,7 +3046,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *53 + - *54 env: RAILS_ENV: test JRUBY_OPTS: '' @@ -3018,7 +3072,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &54 + - &55 name: Run tests run: "./script/bundler_wrapper exec rake test" env: @@ -3046,7 +3100,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *54 + - *55 env: RAILS_ENV: test JRUBY_OPTS: '' @@ -3072,7 +3126,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &55 + - &56 name: Run tests run: "./script/bundler_wrapper exec rake test" env: @@ -3100,7 +3154,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *55 + - *56 env: RAILS_ENV: test JRUBY_OPTS: '' @@ -3126,7 +3180,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &56 + - &57 name: Run tests run: "./script/bundler_wrapper exec rake test" env: @@ -3154,7 +3208,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *56 + - *57 env: RAILS_ENV: test JRUBY_OPTS: '' @@ -3180,7 +3234,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &57 + - &58 name: Run tests run: "./script/bundler_wrapper exec rake test" env: @@ -3208,7 +3262,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *57 + - *58 env: RAILS_ENV: test JRUBY_OPTS: '' @@ -3234,7 +3288,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &58 + - &59 name: Run tests run: "./script/bundler_wrapper exec rake test" env: @@ -3262,7 +3316,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *58 + - *59 env: RAILS_ENV: test JRUBY_OPTS: '' @@ -3288,7 +3342,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &59 + - &60 name: Run tests run: "./script/bundler_wrapper exec rake test" env: @@ -3316,12 +3370,66 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *59 + - *60 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' BUNDLE_GEMFILE: gemfiles/sequel-collector.gemfile + ruby_3-4-1__shoryuken_ubuntu-latest: + name: Ruby 3.4.1 - shoryuken + needs: ruby_3-4-1_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.4.1 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &61 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/shoryuken.gemfile + ruby_3-4-1__shoryuken-collector_ubuntu-latest: + name: Ruby 3.4.1 - shoryuken-collector + needs: ruby_3-4-1_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.4.1 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *61 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/shoryuken-collector.gemfile ruby_3-4-1__sinatra_ubuntu-latest: name: Ruby 3.4.1 - sinatra needs: ruby_3-4-1_ubuntu-latest @@ -3342,7 +3450,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &60 + - &62 name: Run tests run: "./script/bundler_wrapper exec rake test" env: @@ -3370,7 +3478,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *60 + - *62 env: RAILS_ENV: test JRUBY_OPTS: '' @@ -3396,7 +3504,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &61 + - &63 name: Run tests run: "./script/bundler_wrapper exec rake test" env: @@ -3424,7 +3532,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *61 + - *63 env: RAILS_ENV: test JRUBY_OPTS: '' @@ -3450,7 +3558,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &62 + - &64 name: Run tests run: "./script/bundler_wrapper exec rake test" env: @@ -3478,7 +3586,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *62 + - *64 env: RAILS_ENV: test JRUBY_OPTS: '' @@ -3504,7 +3612,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &63 + - &65 name: Run tests run: "./script/bundler_wrapper exec rake test" env: @@ -3532,7 +3640,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *63 + - *65 env: RAILS_ENV: test JRUBY_OPTS: '' @@ -3558,7 +3666,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &64 + - &66 name: Run tests run: "./script/bundler_wrapper exec rake test" env: @@ -3586,7 +3694,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *64 + - *66 env: RAILS_ENV: test JRUBY_OPTS: '' @@ -3612,7 +3720,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &65 + - &67 name: Run tests run: "./script/bundler_wrapper exec rake test" env: @@ -3640,7 +3748,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *65 + - *67 env: RAILS_ENV: test JRUBY_OPTS: '' @@ -3695,7 +3803,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &66 + - &68 name: Run tests run: "./script/bundler_wrapper exec rake test" - name: Run tests without extension @@ -3725,7 +3833,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *66 + - *68 env: RAILS_ENV: test JRUBY_OPTS: '' @@ -3751,7 +3859,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &67 + - &69 name: Run tests run: "./script/bundler_wrapper exec rake test" env: @@ -3779,7 +3887,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *67 + - *69 env: RAILS_ENV: test JRUBY_OPTS: '' @@ -3805,7 +3913,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &68 + - &70 name: Run tests run: "./script/bundler_wrapper exec rake test" env: @@ -3833,7 +3941,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *68 + - *70 env: RAILS_ENV: test JRUBY_OPTS: '' @@ -3859,7 +3967,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &69 + - &71 name: Run tests run: "./script/bundler_wrapper exec rake test" env: @@ -3887,7 +3995,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *69 + - *71 env: RAILS_ENV: test JRUBY_OPTS: '' @@ -3913,7 +4021,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &70 + - &72 name: Run tests run: "./script/bundler_wrapper exec rake test" env: @@ -3941,7 +4049,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *70 + - *72 env: RAILS_ENV: test JRUBY_OPTS: '' @@ -3967,7 +4075,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &71 + - &73 name: Run tests run: "./script/bundler_wrapper exec rake test" env: @@ -3995,7 +4103,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *71 + - *73 env: RAILS_ENV: test JRUBY_OPTS: '' @@ -4021,7 +4129,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &72 + - &74 name: Run tests run: "./script/bundler_wrapper exec rake test" env: @@ -4049,7 +4157,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *72 + - *74 env: RAILS_ENV: test JRUBY_OPTS: '' @@ -4075,7 +4183,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &73 + - &75 name: Run tests run: "./script/bundler_wrapper exec rake test" env: @@ -4103,7 +4211,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *73 + - *75 env: RAILS_ENV: test JRUBY_OPTS: '' @@ -4129,7 +4237,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &74 + - &76 name: Run tests run: "./script/bundler_wrapper exec rake test" env: @@ -4157,7 +4265,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *74 + - *76 env: RAILS_ENV: test JRUBY_OPTS: '' @@ -4183,7 +4291,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &75 + - &77 name: Run tests run: "./script/bundler_wrapper exec rake test" env: @@ -4211,7 +4319,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *75 + - *77 env: RAILS_ENV: test JRUBY_OPTS: '' @@ -4237,7 +4345,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &76 + - &78 name: Run tests run: "./script/bundler_wrapper exec rake test" env: @@ -4265,7 +4373,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *76 + - *78 env: RAILS_ENV: test JRUBY_OPTS: '' @@ -4291,7 +4399,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &77 + - &79 name: Run tests run: "./script/bundler_wrapper exec rake test" env: @@ -4319,7 +4427,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *77 + - *79 env: RAILS_ENV: test JRUBY_OPTS: '' @@ -4345,7 +4453,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &78 + - &80 name: Run tests run: "./script/bundler_wrapper exec rake test" env: @@ -4373,7 +4481,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *78 + - *80 env: RAILS_ENV: test JRUBY_OPTS: '' @@ -4399,7 +4507,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &79 + - &81 name: Run tests run: "./script/bundler_wrapper exec rake test" env: @@ -4427,7 +4535,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *79 + - *81 env: RAILS_ENV: test JRUBY_OPTS: '' @@ -4453,7 +4561,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &80 + - &82 name: Run tests run: "./script/bundler_wrapper exec rake test" env: @@ -4481,7 +4589,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *80 + - *82 env: RAILS_ENV: test JRUBY_OPTS: '' @@ -4507,7 +4615,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &81 + - &83 name: Run tests run: "./script/bundler_wrapper exec rake test" env: @@ -4535,7 +4643,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *81 + - *83 env: RAILS_ENV: test JRUBY_OPTS: '' @@ -4561,7 +4669,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &82 + - &84 name: Run tests run: "./script/bundler_wrapper exec rake test" env: @@ -4589,7 +4697,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *82 + - *84 env: RAILS_ENV: test JRUBY_OPTS: '' @@ -4615,7 +4723,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &83 + - &85 name: Run tests run: "./script/bundler_wrapper exec rake test" env: @@ -4643,7 +4751,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *83 + - *85 env: RAILS_ENV: test JRUBY_OPTS: '' @@ -4669,7 +4777,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &84 + - &86 name: Run tests run: "./script/bundler_wrapper exec rake test" env: @@ -4697,7 +4805,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *84 + - *86 env: RAILS_ENV: test JRUBY_OPTS: '' @@ -4723,7 +4831,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &85 + - &87 name: Run tests run: "./script/bundler_wrapper exec rake test" env: @@ -4751,7 +4859,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *85 + - *87 env: RAILS_ENV: test JRUBY_OPTS: '' @@ -4777,7 +4885,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &86 + - &88 name: Run tests run: "./script/bundler_wrapper exec rake test" env: @@ -4805,7 +4913,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *86 + - *88 env: RAILS_ENV: test JRUBY_OPTS: '' @@ -4831,7 +4939,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &87 + - &89 name: Run tests run: "./script/bundler_wrapper exec rake test" env: @@ -4859,7 +4967,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *87 + - *89 env: RAILS_ENV: test JRUBY_OPTS: '' @@ -4885,7 +4993,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &88 + - &90 name: Run tests run: "./script/bundler_wrapper exec rake test" env: @@ -4913,7 +5021,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *88 + - *90 env: RAILS_ENV: test JRUBY_OPTS: '' @@ -4939,7 +5047,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &89 + - &91 name: Run tests run: "./script/bundler_wrapper exec rake test" env: @@ -4967,7 +5075,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *89 + - *91 env: RAILS_ENV: test JRUBY_OPTS: '' @@ -4993,7 +5101,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &90 + - &92 name: Run tests run: "./script/bundler_wrapper exec rake test" env: @@ -5021,7 +5129,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *90 + - *92 env: RAILS_ENV: test JRUBY_OPTS: '' @@ -5047,7 +5155,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &91 + - &93 name: Run tests run: "./script/bundler_wrapper exec rake test" env: @@ -5075,7 +5183,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *91 + - *93 env: RAILS_ENV: test JRUBY_OPTS: '' @@ -5101,7 +5209,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &92 + - &94 name: Run tests run: "./script/bundler_wrapper exec rake test" env: @@ -5129,7 +5237,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *92 + - *94 env: RAILS_ENV: test JRUBY_OPTS: '' @@ -5155,7 +5263,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &93 + - &95 name: Run tests run: "./script/bundler_wrapper exec rake test" env: @@ -5183,7 +5291,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *93 + - *95 env: RAILS_ENV: test JRUBY_OPTS: '' @@ -5209,7 +5317,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &94 + - &96 name: Run tests run: "./script/bundler_wrapper exec rake test" env: @@ -5237,7 +5345,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *94 + - *96 env: RAILS_ENV: test JRUBY_OPTS: '' @@ -5263,7 +5371,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &95 + - &97 name: Run tests run: "./script/bundler_wrapper exec rake test" env: @@ -5291,12 +5399,66 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *95 + - *97 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' BUNDLE_GEMFILE: gemfiles/sequel-collector.gemfile + ruby_3-3-4__shoryuken_ubuntu-latest: + name: Ruby 3.3.4 - shoryuken + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - &98 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/shoryuken.gemfile + ruby_3-3-4__shoryuken-collector_ubuntu-latest: + name: Ruby 3.3.4 - shoryuken-collector + needs: ruby_3-3-4_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - *98 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/shoryuken-collector.gemfile ruby_3-3-4__sinatra_ubuntu-latest: name: Ruby 3.3.4 - sinatra needs: ruby_3-3-4_ubuntu-latest @@ -5317,7 +5479,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &96 + - &99 name: Run tests run: "./script/bundler_wrapper exec rake test" env: @@ -5345,7 +5507,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *96 + - *99 env: RAILS_ENV: test JRUBY_OPTS: '' @@ -5371,7 +5533,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &97 + - &100 name: Run tests run: "./script/bundler_wrapper exec rake test" env: @@ -5399,7 +5561,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *97 + - *100 env: RAILS_ENV: test JRUBY_OPTS: '' @@ -5425,7 +5587,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &98 + - &101 name: Run tests run: "./script/bundler_wrapper exec rake test" env: @@ -5453,7 +5615,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *98 + - *101 env: RAILS_ENV: test JRUBY_OPTS: '' @@ -5479,7 +5641,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &99 + - &102 name: Run tests run: "./script/bundler_wrapper exec rake test" env: @@ -5507,7 +5669,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *99 + - *102 env: RAILS_ENV: test JRUBY_OPTS: '' @@ -5562,7 +5724,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &100 + - &103 name: Run tests run: "./script/bundler_wrapper exec rake test" - name: Run tests without extension @@ -5592,7 +5754,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *100 + - *103 env: RAILS_ENV: test JRUBY_OPTS: '' @@ -5618,7 +5780,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &101 + - &104 name: Run tests run: "./script/bundler_wrapper exec rake test" env: @@ -5646,7 +5808,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *101 + - *104 env: RAILS_ENV: test JRUBY_OPTS: '' @@ -5672,7 +5834,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &102 + - &105 name: Run tests run: "./script/bundler_wrapper exec rake test" env: @@ -5700,7 +5862,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *102 + - *105 env: RAILS_ENV: test JRUBY_OPTS: '' @@ -5726,7 +5888,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &103 + - &106 name: Run tests run: "./script/bundler_wrapper exec rake test" env: @@ -5754,7 +5916,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *103 + - *106 env: RAILS_ENV: test JRUBY_OPTS: '' @@ -5780,7 +5942,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &104 + - &107 name: Run tests run: "./script/bundler_wrapper exec rake test" env: @@ -5808,7 +5970,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *104 + - *107 env: RAILS_ENV: test JRUBY_OPTS: '' @@ -5834,7 +5996,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &105 + - &108 name: Run tests run: "./script/bundler_wrapper exec rake test" env: @@ -5862,7 +6024,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *105 + - *108 env: RAILS_ENV: test JRUBY_OPTS: '' @@ -5888,7 +6050,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &106 + - &109 name: Run tests run: "./script/bundler_wrapper exec rake test" env: @@ -5916,7 +6078,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *106 + - *109 env: RAILS_ENV: test JRUBY_OPTS: '' @@ -5942,7 +6104,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &107 + - &110 name: Run tests run: "./script/bundler_wrapper exec rake test" env: @@ -5970,7 +6132,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *107 + - *110 env: RAILS_ENV: test JRUBY_OPTS: '' @@ -5996,7 +6158,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &108 + - &111 name: Run tests run: "./script/bundler_wrapper exec rake test" env: @@ -6024,7 +6186,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *108 + - *111 env: RAILS_ENV: test JRUBY_OPTS: '' @@ -6050,7 +6212,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &109 + - &112 name: Run tests run: "./script/bundler_wrapper exec rake test" env: @@ -6078,7 +6240,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *109 + - *112 env: RAILS_ENV: test JRUBY_OPTS: '' @@ -6104,7 +6266,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &110 + - &113 name: Run tests run: "./script/bundler_wrapper exec rake test" env: @@ -6132,7 +6294,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *110 + - *113 env: RAILS_ENV: test JRUBY_OPTS: '' @@ -6158,7 +6320,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &111 + - &114 name: Run tests run: "./script/bundler_wrapper exec rake test" env: @@ -6186,7 +6348,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *111 + - *114 env: RAILS_ENV: test JRUBY_OPTS: '' @@ -6212,7 +6374,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &112 + - &115 name: Run tests run: "./script/bundler_wrapper exec rake test" env: @@ -6240,7 +6402,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *112 + - *115 env: RAILS_ENV: test JRUBY_OPTS: '' @@ -6266,7 +6428,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &113 + - &116 name: Run tests run: "./script/bundler_wrapper exec rake test" env: @@ -6294,7 +6456,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *113 + - *116 env: RAILS_ENV: test JRUBY_OPTS: '' @@ -6320,7 +6482,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &114 + - &117 name: Run tests run: "./script/bundler_wrapper exec rake test" env: @@ -6348,7 +6510,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *114 + - *117 env: RAILS_ENV: test JRUBY_OPTS: '' @@ -6374,7 +6536,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &115 + - &118 name: Run tests run: "./script/bundler_wrapper exec rake test" env: @@ -6402,7 +6564,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *115 + - *118 env: RAILS_ENV: test JRUBY_OPTS: '' @@ -6428,7 +6590,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &116 + - &119 name: Run tests run: "./script/bundler_wrapper exec rake test" env: @@ -6456,7 +6618,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *116 + - *119 env: RAILS_ENV: test JRUBY_OPTS: '' @@ -6482,7 +6644,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &117 + - &120 name: Run tests run: "./script/bundler_wrapper exec rake test" env: @@ -6510,7 +6672,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *117 + - *120 env: RAILS_ENV: test JRUBY_OPTS: '' @@ -6536,7 +6698,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &118 + - &121 name: Run tests run: "./script/bundler_wrapper exec rake test" env: @@ -6564,7 +6726,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *118 + - *121 env: RAILS_ENV: test JRUBY_OPTS: '' @@ -6590,7 +6752,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &119 + - &122 name: Run tests run: "./script/bundler_wrapper exec rake test" env: @@ -6618,7 +6780,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *119 + - *122 env: RAILS_ENV: test JRUBY_OPTS: '' @@ -6644,7 +6806,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &120 + - &123 name: Run tests run: "./script/bundler_wrapper exec rake test" env: @@ -6672,7 +6834,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *120 + - *123 env: RAILS_ENV: test JRUBY_OPTS: '' @@ -6698,7 +6860,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &121 + - &124 name: Run tests run: "./script/bundler_wrapper exec rake test" env: @@ -6726,7 +6888,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *121 + - *124 env: RAILS_ENV: test JRUBY_OPTS: '' @@ -6752,7 +6914,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &122 + - &125 name: Run tests run: "./script/bundler_wrapper exec rake test" env: @@ -6780,7 +6942,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *122 + - *125 env: RAILS_ENV: test JRUBY_OPTS: '' @@ -6806,7 +6968,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &123 + - &126 name: Run tests run: "./script/bundler_wrapper exec rake test" env: @@ -6834,7 +6996,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *123 + - *126 env: RAILS_ENV: test JRUBY_OPTS: '' @@ -6860,7 +7022,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &124 + - &127 name: Run tests run: "./script/bundler_wrapper exec rake test" env: @@ -6888,7 +7050,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *124 + - *127 env: RAILS_ENV: test JRUBY_OPTS: '' @@ -6914,7 +7076,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &125 + - &128 name: Run tests run: "./script/bundler_wrapper exec rake test" env: @@ -6942,7 +7104,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *125 + - *128 env: RAILS_ENV: test JRUBY_OPTS: '' @@ -6968,7 +7130,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &126 + - &129 name: Run tests run: "./script/bundler_wrapper exec rake test" env: @@ -6996,14 +7158,68 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *126 + - *129 + 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'" + - &130 + 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'" + - *130 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 + 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: @@ -7022,16 +7238,16 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &127 + - &131 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 + 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: @@ -7050,14 +7266,14 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *127 + - *131 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 + BUNDLE_GEMFILE: gemfiles/sequel-collector.gemfile + ruby_3-2-5__shoryuken_ubuntu-latest: + name: Ruby 3.2.5 - shoryuken needs: ruby_3-2-5_ubuntu-latest runs-on: ubuntu-latest steps: @@ -7076,16 +7292,16 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &128 + - &132 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 + BUNDLE_GEMFILE: gemfiles/shoryuken.gemfile + ruby_3-2-5__shoryuken-collector_ubuntu-latest: + name: Ruby 3.2.5 - shoryuken-collector needs: ruby_3-2-5_ubuntu-latest runs-on: ubuntu-latest steps: @@ -7104,12 +7320,12 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *128 + - *132 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/sequel-collector.gemfile + BUNDLE_GEMFILE: gemfiles/shoryuken-collector.gemfile ruby_3-2-5__sinatra_ubuntu-latest: name: Ruby 3.2.5 - sinatra needs: ruby_3-2-5_ubuntu-latest @@ -7130,7 +7346,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &129 + - &133 name: Run tests run: "./script/bundler_wrapper exec rake test" env: @@ -7158,7 +7374,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *129 + - *133 env: RAILS_ENV: test JRUBY_OPTS: '' @@ -7184,7 +7400,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &130 + - &134 name: Run tests run: "./script/bundler_wrapper exec rake test" env: @@ -7212,7 +7428,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *130 + - *134 env: RAILS_ENV: test JRUBY_OPTS: '' @@ -7238,7 +7454,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &131 + - &135 name: Run tests run: "./script/bundler_wrapper exec rake test" env: @@ -7266,7 +7482,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *131 + - *135 env: RAILS_ENV: test JRUBY_OPTS: '' @@ -7292,7 +7508,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &132 + - &136 name: Run tests run: "./script/bundler_wrapper exec rake test" env: @@ -7320,7 +7536,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *132 + - *136 env: RAILS_ENV: test JRUBY_OPTS: '' @@ -7375,7 +7591,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &133 + - &137 name: Run tests run: "./script/bundler_wrapper exec rake test" - name: Run tests without extension @@ -7405,7 +7621,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *133 + - *137 env: RAILS_ENV: test JRUBY_OPTS: '' @@ -7431,7 +7647,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &134 + - &138 name: Run tests run: "./script/bundler_wrapper exec rake test" env: @@ -7459,7 +7675,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *134 + - *138 env: RAILS_ENV: test JRUBY_OPTS: '' @@ -7485,7 +7701,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &135 + - &139 name: Run tests run: "./script/bundler_wrapper exec rake test" env: @@ -7513,7 +7729,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *135 + - *139 env: RAILS_ENV: test JRUBY_OPTS: '' @@ -7539,7 +7755,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &136 + - &140 name: Run tests run: "./script/bundler_wrapper exec rake test" env: @@ -7567,7 +7783,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *136 + - *140 env: RAILS_ENV: test JRUBY_OPTS: '' @@ -7593,7 +7809,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &137 + - &141 name: Run tests run: "./script/bundler_wrapper exec rake test" env: @@ -7621,7 +7837,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *137 + - *141 env: RAILS_ENV: test JRUBY_OPTS: '' @@ -7647,7 +7863,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &138 + - &142 name: Run tests run: "./script/bundler_wrapper exec rake test" env: @@ -7675,7 +7891,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *138 + - *142 env: RAILS_ENV: test JRUBY_OPTS: '' @@ -7701,7 +7917,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &139 + - &143 name: Run tests run: "./script/bundler_wrapper exec rake test" env: @@ -7729,7 +7945,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *139 + - *143 env: RAILS_ENV: test JRUBY_OPTS: '' @@ -7755,7 +7971,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &140 + - &144 name: Run tests run: "./script/bundler_wrapper exec rake test" env: @@ -7783,7 +7999,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *140 + - *144 env: RAILS_ENV: test JRUBY_OPTS: '' @@ -7809,7 +8025,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &141 + - &145 name: Run tests run: "./script/bundler_wrapper exec rake test" env: @@ -7837,7 +8053,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *141 + - *145 env: RAILS_ENV: test JRUBY_OPTS: '' @@ -7863,7 +8079,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &142 + - &146 name: Run tests run: "./script/bundler_wrapper exec rake test" env: @@ -7891,7 +8107,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *142 + - *146 env: RAILS_ENV: test JRUBY_OPTS: '' @@ -7917,7 +8133,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &143 + - &147 name: Run tests run: "./script/bundler_wrapper exec rake test" env: @@ -7945,7 +8161,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *143 + - *147 env: RAILS_ENV: test JRUBY_OPTS: '' @@ -7971,7 +8187,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &144 + - &148 name: Run tests run: "./script/bundler_wrapper exec rake test" env: @@ -7999,7 +8215,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *144 + - *148 env: RAILS_ENV: test JRUBY_OPTS: '' @@ -8025,7 +8241,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &145 + - &149 name: Run tests run: "./script/bundler_wrapper exec rake test" env: @@ -8053,7 +8269,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *145 + - *149 env: RAILS_ENV: test JRUBY_OPTS: '' @@ -8079,7 +8295,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &146 + - &150 name: Run tests run: "./script/bundler_wrapper exec rake test" env: @@ -8107,7 +8323,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *146 + - *150 env: RAILS_ENV: test JRUBY_OPTS: '' @@ -8133,7 +8349,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &147 + - &151 name: Run tests run: "./script/bundler_wrapper exec rake test" env: @@ -8161,7 +8377,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *147 + - *151 env: RAILS_ENV: test JRUBY_OPTS: '' @@ -8187,7 +8403,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &148 + - &152 name: Run tests run: "./script/bundler_wrapper exec rake test" env: @@ -8215,7 +8431,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *148 + - *152 env: RAILS_ENV: test JRUBY_OPTS: '' @@ -8241,7 +8457,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &149 + - &153 name: Run tests run: "./script/bundler_wrapper exec rake test" env: @@ -8269,7 +8485,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *149 + - *153 env: RAILS_ENV: test JRUBY_OPTS: '' @@ -8295,7 +8511,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &150 + - &154 name: Run tests run: "./script/bundler_wrapper exec rake test" env: @@ -8323,7 +8539,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *150 + - *154 env: RAILS_ENV: test JRUBY_OPTS: '' @@ -8349,7 +8565,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &151 + - &155 name: Run tests run: "./script/bundler_wrapper exec rake test" env: @@ -8377,7 +8593,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *151 + - *155 env: RAILS_ENV: test JRUBY_OPTS: '' @@ -8403,7 +8619,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &152 + - &156 name: Run tests run: "./script/bundler_wrapper exec rake test" env: @@ -8431,7 +8647,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *152 + - *156 env: RAILS_ENV: test JRUBY_OPTS: '' @@ -8457,7 +8673,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &153 + - &157 name: Run tests run: "./script/bundler_wrapper exec rake test" env: @@ -8485,7 +8701,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *153 + - *157 env: RAILS_ENV: test JRUBY_OPTS: '' @@ -8511,7 +8727,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &154 + - &158 name: Run tests run: "./script/bundler_wrapper exec rake test" env: @@ -8539,7 +8755,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *154 + - *158 env: RAILS_ENV: test JRUBY_OPTS: '' @@ -8565,7 +8781,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &155 + - &159 name: Run tests run: "./script/bundler_wrapper exec rake test" env: @@ -8593,7 +8809,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *155 + - *159 env: RAILS_ENV: test JRUBY_OPTS: '' @@ -8619,7 +8835,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &156 + - &160 name: Run tests run: "./script/bundler_wrapper exec rake test" env: @@ -8647,7 +8863,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *156 + - *160 env: RAILS_ENV: test JRUBY_OPTS: '' @@ -8673,7 +8889,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &157 + - &161 name: Run tests run: "./script/bundler_wrapper exec rake test" env: @@ -8701,7 +8917,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *157 + - *161 env: RAILS_ENV: test JRUBY_OPTS: '' @@ -8727,7 +8943,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &158 + - &162 name: Run tests run: "./script/bundler_wrapper exec rake test" env: @@ -8755,12 +8971,66 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *158 + - *162 env: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' BUNDLE_GEMFILE: gemfiles/sequel-collector.gemfile + ruby_3-1-6__shoryuken_ubuntu-latest: + name: Ruby 3.1.6 - shoryuken + 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'" + - &163 + name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/shoryuken.gemfile + ruby_3-1-6__shoryuken-collector_ubuntu-latest: + name: Ruby 3.1.6 - shoryuken-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'" + - *163 + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/shoryuken-collector.gemfile ruby_3-1-6__sinatra_ubuntu-latest: name: Ruby 3.1.6 - sinatra needs: ruby_3-1-6_ubuntu-latest @@ -8781,7 +9051,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &159 + - &164 name: Run tests run: "./script/bundler_wrapper exec rake test" env: @@ -8809,7 +9079,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *159 + - *164 env: RAILS_ENV: test JRUBY_OPTS: '' @@ -8835,7 +9105,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &160 + - &165 name: Run tests run: "./script/bundler_wrapper exec rake test" env: @@ -8863,7 +9133,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *160 + - *165 env: RAILS_ENV: test JRUBY_OPTS: '' @@ -8889,7 +9159,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &161 + - &166 name: Run tests run: "./script/bundler_wrapper exec rake test" env: @@ -8917,7 +9187,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *161 + - *166 env: RAILS_ENV: test JRUBY_OPTS: '' @@ -8943,7 +9213,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - &162 + - &167 name: Run tests run: "./script/bundler_wrapper exec rake test" env: @@ -8971,7 +9241,7 @@ jobs: - name: Print Makefile log file run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file found'" - - *162 + - *167 env: RAILS_ENV: test JRUBY_OPTS: '' @@ -9683,6 +9953,33 @@ jobs: JRUBY_OPTS: '' COV: '1' BUNDLE_GEMFILE: gemfiles/sequel.gemfile + ruby_3-0-7__shoryuken_ubuntu-latest: + name: Ruby 3.0.7 - shoryuken + needs: ruby_3-0-7_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.0.7 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/shoryuken.gemfile ruby_3-0-7__sinatra_ubuntu-latest: name: Ruby 3.0.7 - sinatra needs: ruby_3-0-7_ubuntu-latest @@ -10335,6 +10632,33 @@ jobs: JRUBY_OPTS: '' COV: '1' BUNDLE_GEMFILE: gemfiles/sequel.gemfile + ruby_2-7-8__shoryuken_ubuntu-latest: + name: Ruby 2.7.8 - shoryuken + needs: ruby_2-7-8_ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 2.7.8 + bundler-cache: true + - name: Install gem extension + run: "./script/bundler_wrapper exec rake extension:install" + - name: Print extension install report + run: "[ -e ext/install.report ] && cat ext/install.report || echo 'No ext/install.report + file found'" + - name: Print Makefile log file + run: "[ -f ext/mkmf.log ] && cat ext/mkmf.log || echo 'No ext/mkmf.log file + found'" + - name: Run tests + run: "./script/bundler_wrapper exec rake test" + env: + RAILS_ENV: test + JRUBY_OPTS: '' + COV: '1' + BUNDLE_GEMFILE: gemfiles/shoryuken.gemfile ruby_2-7-8__sinatra_ubuntu-latest: name: Ruby 2.7.8 - sinatra needs: ruby_2-7-8_ubuntu-latest diff --git a/build_matrix.yml b/build_matrix.yml index dc383a8e0..6e49eafa6 100644 --- a/build_matrix.yml +++ b/build_matrix.yml @@ -303,19 +303,7 @@ matrix: - "3.1.6" - "3.0.7" - gem: "sequel" - - gem: "shoryuken-6" - only: - ruby: - - "3.1.6" - - "3.0.7" - - "2.7.8" - - gem: "shoryuken-7" - only: - ruby: - - "4.0.0" - - "3.4.1" - - "3.3.4" - - "3.2.5" + - gem: "shoryuken" - gem: "sinatra" - gem: "webmachine2" - gem: "redis-4" diff --git a/gemfiles/shoryuken-collector.gemfile b/gemfiles/shoryuken-collector.gemfile new file mode 100644 index 000000000..e6131592c --- /dev/null +++ b/gemfiles/shoryuken-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.gemfile. + +eval_gemfile File.expand_path("shoryuken.gemfile", __dir__) +eval_gemfile File.expand_path("collector.rb", __dir__) diff --git a/gemfiles/shoryuken.gemfile b/gemfiles/shoryuken.gemfile new file mode 100644 index 000000000..d4ac697e4 --- /dev/null +++ b/gemfiles/shoryuken.gemfile @@ -0,0 +1,5 @@ +source "https://rubygems.org" + +gem "shoryuken" + +gemspec :path => "../" diff --git a/lib/appsignal/hooks/shoryuken.rb b/lib/appsignal/hooks/shoryuken.rb index 4ab7cd205..b95f5b834 100644 --- a/lib/appsignal/hooks/shoryuken.rb +++ b/lib/appsignal/hooks/shoryuken.rb @@ -19,9 +19,9 @@ def install end # Servers enqueue jobs too, so they need the client middleware that - # records the enqueue event. Shoryuken only yields `configure_client` - # outside the server, so register it here as well for enqueues from - # within a worker. + # writes the trace context onto outgoing messages. Shoryuken only + # yields `configure_client` outside the server, so register it here as + # well for enqueues from within a worker. config.client_middleware do |chain| chain.add Appsignal::Integrations::ShoryukenClientMiddleware end diff --git a/lib/appsignal/integrations/shoryuken.rb b/lib/appsignal/integrations/shoryuken.rb index 3c46b4b2c..4b072f3fa 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,26 +157,11 @@ def fetch_args(batch, sqs_msg, body) # # @!visibility private class ShoryukenClientMiddleware - def call(options, &block) - # 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? - - Appsignal.instrument("enqueue.shoryuken", enqueue_title(options), &block) - end - - private - - # Enqueues through a Shoryuken worker carry the worker class in the - # `shoryuken_class` message attribute. Raw `send_message` enqueues don't, - # so there's no worker class to name -- fall back to the queue instead. - 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}" + def call(options) + Appsignal.instrument("enqueue.shoryuken", :opentelemetry_kind => :producer) do + ShoryukenTraceContext.inject(options) + yield + end 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..a417e2462 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,48 @@ 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! + + producer = event_spans.find { |s| s.name == "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 cdb298ac5..0fe6cefb1 100644 --- a/spec/lib/appsignal/integrations/shoryuken_spec.rb +++ b/spec/lib/appsignal/integrations/shoryuken_spec.rb @@ -7,7 +7,7 @@ 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) { {} } @@ -33,7 +33,11 @@ def perform_shoryuken_job(&block) context "with a performance call" do 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 @@ -174,6 +178,51 @@ def perform 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 + + # 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 describe "sets the exception on the transaction" do def perform @@ -302,6 +351,78 @@ def perform .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" } } + + def enqueue(&block) + block ||= lambda {} + described_class.new.call(options, &block) + end + + context "with an active transaction" do + describe "records the enqueue under the transaction" do + def perform + transaction = http_request_transaction + set_current_transaction(transaction) + enqueue + transaction + end + + it "in agent mode", :agent_mode do + start_agent + transaction = perform + + # Records an enqueue event on the transaction; no wire context in agent mode. + event_names = transaction.to_h["events"].map { |event| event["name"] } + expect(event_names).to include("enqueue.shoryuken") + expect(options).to_not have_key(:message_attributes) + 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. + producer = event_spans.find { |s| s.name == "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 + end + + context "without an active transaction" do + 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 From 99a9e7138a9569c997ddfff592ed4a440faad157 Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Fri, 3 Jul 2026 19:00:32 +0200 Subject: [PATCH 138/151] Add aws-sdk-sqs to the Shoryuken gemfile Shoryuken loads the AWS SDK, which since v3 requires aws-sdk-sqs to be installed separately. Older Rubies resolve that combination in CI and fail to load shoryuken without the gem present. --- gemfiles/shoryuken.gemfile | 3 +++ 1 file changed, 3 insertions(+) diff --git a/gemfiles/shoryuken.gemfile b/gemfiles/shoryuken.gemfile index d4ac697e4..edc4319ee 100644 --- a/gemfiles/shoryuken.gemfile +++ b/gemfiles/shoryuken.gemfile @@ -1,5 +1,8 @@ source "https://rubygems.org" +# Shoryuken loads the AWS SDK, which since v3 needs aws-sdk-sqs declared +# separately or it raises on load. +gem "aws-sdk-sqs" gem "shoryuken" gemspec :path => "../" From 556c4828a042b41f17120ed5fed66aa8e00d30ea Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Fri, 3 Jul 2026 18:49:47 +0200 Subject: [PATCH 139/151] Suppress nested enqueue events under Active Job Active Job enqueues through an adapter (Sidekiq, Resque, Que), each of which has its own enqueue instrumentation. A single enqueue was recorded twice: as the Active Job event and once as the nested adapter event. Active Job now records the enqueue inside a suppression block, so the adapter's own enqueue instrumentation skips recording while nested. The enqueue is recorded once, as the outermost event. This mirrors how the Faraday integration suppresses the downstream HTTP client. The adapter integrations react to this flag in their own commits. --- lib/appsignal/hooks/active_job.rb | 10 ++++++- spec/lib/appsignal/hooks/activejob_spec.rb | 34 ++++++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/lib/appsignal/hooks/active_job.rb b/lib/appsignal/hooks/active_job.rb index 26f7660e1..7f275ea71 100644 --- a/lib/appsignal/hooks/active_job.rb +++ b/lib/appsignal/hooks/active_job.rb @@ -157,7 +157,15 @@ module ActiveJobTraceContext def enqueue(*, **) Appsignal.instrument("enqueue.active_job", :opentelemetry_kind => :producer) do Appsignal::OpenTelemetry.inject_context(__otel_headers) - super + # 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 diff --git a/spec/lib/appsignal/hooks/activejob_spec.rb b/spec/lib/appsignal/hooks/activejob_spec.rb index 3d25c45ad..9d997dec4 100644 --- a/spec/lib/appsignal/hooks/activejob_spec.rb +++ b/spec/lib/appsignal/hooks/activejob_spec.rb @@ -612,6 +612,40 @@ def enqueue_within_transaction end end + describe "suppressing nested adapter enqueue events" do + before { ActiveJob::Base.queue_adapter = :test } + + # 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| + 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 From 4c1f01b9bd9319376430ccfca534c5a3ef279eff Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Fri, 3 Jul 2026 18:51:40 +0200 Subject: [PATCH 140/151] Skip the Sidekiq enqueue event when suppressed When enqueuing through Active Job, the enqueue is already recorded as an `enqueue.active_job` event. The Sidekiq client middleware now skips recording its own `enqueue.sidekiq` event while enqueue events are suppressed, so the enqueue is recorded once. The trace context is still injected onto the job, so the performed job links back to the enqueuer. --- lib/appsignal/integrations/sidekiq.rb | 9 +++++ .../appsignal/integrations/sidekiq_spec.rb | 33 +++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/lib/appsignal/integrations/sidekiq.rb b/lib/appsignal/integrations/sidekiq.rb index 16cd78712..79ffc816f 100644 --- a/lib/appsignal/integrations/sidekiq.rb +++ b/lib/appsignal/integrations/sidekiq.rb @@ -61,6 +61,15 @@ def call(exception, sidekiq_context, _sidekiq_config = nil) # @!visibility private class SidekiqClientMiddleware 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. The trace + # context is still injected so the performed job links back. + if Appsignal::Transaction.current? && + Appsignal::Transaction.current.job_enqueue_events_suppressed? + Appsignal::OpenTelemetry.inject_context(job) + return yield + end + Appsignal.instrument("enqueue.sidekiq", :opentelemetry_kind => :producer) do Appsignal::OpenTelemetry.inject_context(job) yield diff --git a/spec/lib/appsignal/integrations/sidekiq_spec.rb b/spec/lib/appsignal/integrations/sidekiq_spec.rb index 4b18bc9d2..42b01d287 100644 --- a/spec/lib/appsignal/integrations/sidekiq_spec.rb +++ b/spec/lib/appsignal/integrations/sidekiq_spec.rb @@ -387,6 +387,39 @@ def enqueue 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. + 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(: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 describe Appsignal::Integrations::SidekiqMiddleware do From 9def8cb1229a125bdbae1b6bb36e4f5ae0ec3130 Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Fri, 3 Jul 2026 18:53:08 +0200 Subject: [PATCH 141/151] Skip the Resque enqueue event when suppressed When enqueuing through Active Job, the enqueue is already recorded as an `enqueue.active_job` event. The Resque push wrapper now skips recording its own `enqueue.resque` event while enqueue events are suppressed, so the enqueue is recorded once. The trace context is still injected onto the job, so the performed job links back to the enqueuer. --- lib/appsignal/integrations/resque.rb | 9 +++++ .../lib/appsignal/integrations/resque_spec.rb | 33 +++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/lib/appsignal/integrations/resque.rb b/lib/appsignal/integrations/resque.rb index d28f56694..6f86a732c 100644 --- a/lib/appsignal/integrations/resque.rb +++ b/lib/appsignal/integrations/resque.rb @@ -42,6 +42,15 @@ def perform # @!visibility private module ResquePushIntegration def push(queue, item) + # 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? + Appsignal::OpenTelemetry.inject_context(item) + return super + end + Appsignal.instrument("enqueue.resque", :opentelemetry_kind => :producer) do Appsignal::OpenTelemetry.inject_context(item) super diff --git a/spec/lib/appsignal/integrations/resque_spec.rb b/spec/lib/appsignal/integrations/resque_spec.rb index d7f49a834..d82fbed5c 100644 --- a/spec/lib/appsignal/integrations/resque_spec.rb +++ b/spec/lib/appsignal/integrations/resque_spec.rb @@ -289,6 +289,39 @@ def enqueue 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 From 444e0415056e8433d0efc48da3e68104e1a56257 Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Fri, 3 Jul 2026 18:55:04 +0200 Subject: [PATCH 142/151] Skip the Que enqueue event when suppressed When enqueuing through Active Job, the enqueue is already recorded as an `enqueue.active_job` event. The Que enqueue wrapper now skips recording its own `enqueue.que` (or `bulk_enqueue.que`) event while enqueue events are suppressed, so the enqueue is recorded once. The trace context is injected onto the job's tags, so the performed job links back to the enqueuer. --- lib/appsignal/integrations/que.rb | 20 ++++++- spec/lib/appsignal/integrations/que_spec.rb | 66 +++++++++++++++++++++ 2 files changed, 83 insertions(+), 3 deletions(-) diff --git a/lib/appsignal/integrations/que.rb b/lib/appsignal/integrations/que.rb index d217a4242..601accf76 100644 --- a/lib/appsignal/integrations/que.rb +++ b/lib/appsignal/integrations/que.rb @@ -128,13 +128,27 @@ def enqueue(*args, job_options: {}, **rest) # performs links back. Yields the (possibly tag-augmented) `job_options` to # do the actual enqueue. def record_enqueue(job_options, event_name = "enqueue.que") + # 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, :opentelemetry_kind => :producer) do - tags = QueTraceContext.inject(job_options[:tags]) - merged = tags.empty? ? job_options : job_options.merge(:tags => tags) - yield merged + 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 + def bulk_insert_in_progress? !Thread.current[:que_jobs_to_bulk_insert].nil? end diff --git a/spec/lib/appsignal/integrations/que_spec.rb b/spec/lib/appsignal/integrations/que_spec.rb index e29a8abc5..212b5d686 100644 --- a/spec/lib/appsignal/integrations/que_spec.rb +++ b/spec/lib/appsignal/integrations/que_spec.rb @@ -441,6 +441,39 @@ def enqueue(tags: ["user:42"]) 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) + + enqueue_suppressed(transaction) + + # The outer integration records the enqueue, so this one doesn't. + event_names = transaction.to_h["events"].map { |event| event["name"] } + expect(event_names).to_not include("enqueue.que") + end + + it "in collector mode", :collector_mode do + start_collector_agent + transaction = http_request_transaction + set_current_transaction(transaction) + + enqueue_suppressed(transaction) + Appsignal::Transaction.complete_current! + + # No producer span for the suppressed enqueue... + expect(span_exporter.finished_spans.map(&:name)).to_not include("enqueue.que") + # ...but the trace context is still injected so the job links back. + expect(enqueued_tags).to include(a_string_starting_with("traceparent:")) + end + end + # `bulk_enqueue` is Que 2 only. The whole batch 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 @@ -509,6 +542,39 @@ def bulk_enqueue(tags: ["user:42"]) 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. + 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) + + 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 end From c4859de321636515cc49ead0e1df787592c0e56d Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Fri, 3 Jul 2026 18:56:19 +0200 Subject: [PATCH 143/151] Skip the Shoryuken enqueue event when suppressed When enqueuing through Active Job, the enqueue is already recorded as an `enqueue.active_job` event. The Shoryuken client middleware now skips recording its own `enqueue.shoryuken` event while enqueue events are suppressed, so the enqueue is recorded once. The trace context is still injected onto the message, so the performed job links back to the enqueuer. --- lib/appsignal/integrations/shoryuken.rb | 9 +++++ .../appsignal/integrations/shoryuken_spec.rb | 33 +++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/lib/appsignal/integrations/shoryuken.rb b/lib/appsignal/integrations/shoryuken.rb index 4b072f3fa..fa92ca316 100644 --- a/lib/appsignal/integrations/shoryuken.rb +++ b/lib/appsignal/integrations/shoryuken.rb @@ -158,6 +158,15 @@ def fetch_args(batch, sqs_msg, body) # @!visibility private class ShoryukenClientMiddleware def call(options) + # 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? + ShoryukenTraceContext.inject(options) + return yield + end + Appsignal.instrument("enqueue.shoryuken", :opentelemetry_kind => :producer) do ShoryukenTraceContext.inject(options) yield diff --git a/spec/lib/appsignal/integrations/shoryuken_spec.rb b/spec/lib/appsignal/integrations/shoryuken_spec.rb index 0fe6cefb1..f8d8e07da 100644 --- a/spec/lib/appsignal/integrations/shoryuken_spec.rb +++ b/spec/lib/appsignal/integrations/shoryuken_spec.rb @@ -426,6 +426,39 @@ def perform end 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) + + 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 describe Appsignal::Integrations::ShoryukenClientMiddleware do From 84af757361c28a6cc0c64ab80e3b239e7c1635e7 Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Fri, 3 Jul 2026 18:58:10 +0200 Subject: [PATCH 144/151] Title the Active Job enqueue event with the job The `enqueue.active_job` event carries an "enqueue job" title so the timeline shows which job was enqueued. In collector mode this becomes the event span name; the dotted name stays as the category. --- lib/appsignal/hooks/active_job.rb | 6 +++++- spec/lib/appsignal/hooks/activejob_spec.rb | 13 +++++++++---- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/lib/appsignal/hooks/active_job.rb b/lib/appsignal/hooks/active_job.rb index 7f275ea71..71a2934cd 100644 --- a/lib/appsignal/hooks/active_job.rb +++ b/lib/appsignal/hooks/active_job.rb @@ -155,7 +155,11 @@ module ActiveJobTraceContext # transparent pass-through when there's no active transaction, and # `inject_context` no-ops outside collector mode. def enqueue(*, **) - Appsignal.instrument("enqueue.active_job", :opentelemetry_kind => :producer) do + 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 diff --git a/spec/lib/appsignal/hooks/activejob_spec.rb b/spec/lib/appsignal/hooks/activejob_spec.rb index 9d997dec4..2d7699d5b 100644 --- a/spec/lib/appsignal/hooks/activejob_spec.rb +++ b/spec/lib/appsignal/hooks/activejob_spec.rb @@ -585,8 +585,10 @@ def enqueue_within_transaction enqueue_within_transaction Appsignal::Transaction.complete_current! - # The enqueue is a producer event span under the enqueuing transaction. - producer = event_spans.find { |s| s.name == "enqueue.active_job" } + # 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) @@ -604,8 +606,11 @@ def enqueue_within_transaction # Exactly one enqueue event: ours. The native `enqueue.active_job` # notification is suppressed so it isn't recorded a second time. - event_names = transaction.to_h["events"].map { |event| event["name"] } - expect(event_names.count("enqueue.active_job")).to eq(1) + 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") enqueued = ActiveJob::Base.queue_adapter.enqueued_jobs.first expect(enqueued).to_not have_key("__otel_headers") From a8897d17c4120ed6cba3428d067aabff4955490a Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Fri, 3 Jul 2026 18:59:07 +0200 Subject: [PATCH 145/151] Title the Sidekiq enqueue event with the job The `enqueue.sidekiq` event now carries an "enqueue job" title so the timeline shows which job was enqueued. In collector mode this becomes the event span name; the dotted name stays as the category. --- lib/appsignal/integrations/sidekiq.rb | 6 +++++- spec/lib/appsignal/integrations/sidekiq_spec.rb | 14 +++++++++----- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/lib/appsignal/integrations/sidekiq.rb b/lib/appsignal/integrations/sidekiq.rb index 79ffc816f..861ad6dbe 100644 --- a/lib/appsignal/integrations/sidekiq.rb +++ b/lib/appsignal/integrations/sidekiq.rb @@ -70,7 +70,11 @@ def call(_worker_class, job, _queue, _redis_pool) return yield end - Appsignal.instrument("enqueue.sidekiq", :opentelemetry_kind => :producer) do + Appsignal.instrument( + "enqueue.sidekiq", + "enqueue #{job["class"]} job", + :opentelemetry_kind => :producer + ) do Appsignal::OpenTelemetry.inject_context(job) yield end diff --git a/spec/lib/appsignal/integrations/sidekiq_spec.rb b/spec/lib/appsignal/integrations/sidekiq_spec.rb index 42b01d287..bdb249db0 100644 --- a/spec/lib/appsignal/integrations/sidekiq_spec.rb +++ b/spec/lib/appsignal/integrations/sidekiq_spec.rb @@ -340,9 +340,11 @@ def enqueue expect(enqueue).to eq(:enqueued) - # Records an enqueue event on the transaction; no wire context in agent mode. - event_names = transaction.to_h["events"].map { |event| event["name"] } - expect(event_names).to include("enqueue.sidekiq") + # 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 @@ -354,8 +356,10 @@ def enqueue expect(enqueue).to eq(:enqueued) Appsignal::Transaction.complete_current! - # The enqueue is a producer event span under the active transaction. - producer = event_spans.find { |s| s.name == "enqueue.sidekiq" } + # 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) From 20ef66bb825b1d7cbb6cd27c98d7351f0c500006 Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Fri, 3 Jul 2026 18:59:52 +0200 Subject: [PATCH 146/151] Title the Resque enqueue event with the job The `enqueue.resque` event now carries an "enqueue job" title so the timeline shows which job was enqueued. In collector mode it becomes the event span name; the dotted name stays as the category. --- lib/appsignal/integrations/resque.rb | 6 +++++- spec/lib/appsignal/integrations/resque_spec.rb | 14 +++++++++----- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/lib/appsignal/integrations/resque.rb b/lib/appsignal/integrations/resque.rb index 6f86a732c..d7154f87a 100644 --- a/lib/appsignal/integrations/resque.rb +++ b/lib/appsignal/integrations/resque.rb @@ -51,7 +51,11 @@ def push(queue, item) return super end - Appsignal.instrument("enqueue.resque", :opentelemetry_kind => :producer) do + Appsignal.instrument( + "enqueue.resque", + "enqueue #{item["class"]} job", + :opentelemetry_kind => :producer + ) do Appsignal::OpenTelemetry.inject_context(item) super end diff --git a/spec/lib/appsignal/integrations/resque_spec.rb b/spec/lib/appsignal/integrations/resque_spec.rb index d82fbed5c..1b2cce0b0 100644 --- a/spec/lib/appsignal/integrations/resque_spec.rb +++ b/spec/lib/appsignal/integrations/resque_spec.rb @@ -244,9 +244,11 @@ def enqueue expect(enqueue).to eq(:pushed) - # Records an enqueue event on the transaction; no wire context in agent mode. - event_names = transaction.to_h["events"].map { |event| event["name"] } - expect(event_names).to include("enqueue.resque") + # 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 @@ -258,8 +260,10 @@ def enqueue expect(enqueue).to eq(:pushed) Appsignal::Transaction.complete_current! - # The enqueue is a producer event span under the active transaction. - producer = event_spans.find { |s| s.name == "enqueue.resque" } + # 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) From 5763ae93bf92ef4f6aa225b18fcdeb2fbf0aa9df Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Fri, 3 Jul 2026 19:02:20 +0200 Subject: [PATCH 147/151] Title the Que enqueue events with the job The `enqueue.que` event now carries an "enqueue job" title, and `bulk_enqueue.que` a "bulk enqueue jobs" title, so the timeline shows which job was enqueued. The class is resolved the way Que resolves it: an explicit `:job_class`, else the class the enqueue was called on. For a bulk enqueue called on `Que::Job` itself the class isn't known up front, so the title is left class-less. --- lib/appsignal/integrations/que.rb | 25 +++++++++++++++++---- spec/lib/appsignal/integrations/que_spec.rb | 23 +++++++++++++------ 2 files changed, 37 insertions(+), 11 deletions(-) diff --git a/lib/appsignal/integrations/que.rb b/lib/appsignal/integrations/que.rb index 601accf76..5aa7ac0f4 100644 --- a/lib/appsignal/integrations/que.rb +++ b/lib/appsignal/integrations/que.rb @@ -116,7 +116,10 @@ def enqueue(*args, job_options: {}, **rest) # propagation are recorded once by the `bulk_enqueue` wrapper. return super if bulk_insert_in_progress? - record_enqueue(job_options) do |merged| + # 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" + record_enqueue(job_options, "enqueue.que", title) do |merged| super(*args, :job_options => merged, **rest) end end @@ -127,7 +130,7 @@ def enqueue(*args, job_options: {}, **rest) # 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 = "enqueue.que") + 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. @@ -136,7 +139,7 @@ def record_enqueue(job_options, event_name = "enqueue.que") return yield job_options_with_context(job_options) end - Appsignal.instrument(event_name, :opentelemetry_kind => :producer) do + Appsignal.instrument(event_name, title, :opentelemetry_kind => :producer) do yield job_options_with_context(job_options) end end @@ -163,10 +166,24 @@ def bulk_insert_in_progress? # the inner enqueues are pass-throughs. module QueBulkClientPlugin def bulk_enqueue(job_options: {}, **rest, &block) - record_enqueue(job_options, "bulk_enqueue.que") do |merged| + record_enqueue(job_options, "bulk_enqueue.que", bulk_enqueue_title(job_options)) do |merged| super(:job_options => merged, **rest, &block) end end + + private + + # The batch's job class is known up front only from an explicit + # `:job_class` or when `bulk_enqueue` is called on a concrete subclass; + # called on `Que::Job` itself the class isn't known until the inner + # enqueues run, so the title is left class-less. + def bulk_enqueue_title(job_options) + job_class = job_options[:job_class] + job_class ||= name unless equal?(::Que::Job) + return "bulk enqueue jobs" unless job_class + + "bulk enqueue #{job_class} jobs" + end end end end diff --git a/spec/lib/appsignal/integrations/que_spec.rb b/spec/lib/appsignal/integrations/que_spec.rb index 212b5d686..327358fb8 100644 --- a/spec/lib/appsignal/integrations/que_spec.rb +++ b/spec/lib/appsignal/integrations/que_spec.rb @@ -388,8 +388,10 @@ def enqueue(tags: ["user:42"]) enqueue - event_names = transaction.to_h["events"].map { |event| event["name"] } - expect(event_names).to include("enqueue.que") + # Records an enqueue event on the transaction, titled after the job. + event = transaction.to_h["events"].find { |e| e["name"] == "enqueue.que" } + expect(event).to_not be_nil + expect(event["title"]).to eq("enqueue MyQueJob job") # No wire context in agent mode; only the user's own tag persists. expect(enqueued_tags).to eq(["user:42"]) end @@ -402,8 +404,10 @@ def enqueue(tags: ["user:42"]) enqueue Appsignal::Transaction.complete_current! - # The enqueue is a producer event span under the active transaction. - producer = event_spans.find { |s| s.name == "enqueue.que" } + # 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) @@ -503,9 +507,13 @@ def bulk_enqueue(tags: ["user:42"]) 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"] } - # One event for the whole batch -- the inner enqueues don't add their own. - expect(event_names.count { |name| name == "bulk_enqueue.que" }).to eq(1) expect(event_names).to_not include("enqueue.que") expect(enqueued_tags).to eq(["user:42"]) end @@ -518,9 +526,10 @@ def bulk_enqueue(tags: ["user:42"]) bulk_enqueue Appsignal::Transaction.complete_current! - producers = event_spans.select { |s| s.name == "bulk_enqueue.que" } + 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) From c297b2b0862420de01d3237939e8de6a56b83c98 Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Fri, 3 Jul 2026 19:04:33 +0200 Subject: [PATCH 148/151] Title the Shoryuken enqueue event with the job The `enqueue.shoryuken` event now carries an "enqueue job" title when the enqueue goes through a Shoryuken worker, so the timeline shows which job was enqueued. A raw `send_message` has no worker class, so they fall back to "enqueue on ". In collector mode this becomes the event span name; the dotted name stays as the category. --- lib/appsignal/integrations/shoryuken.rb | 19 +++++- .../integrations/shoryuken_client_spec.rb | 4 +- .../appsignal/integrations/shoryuken_spec.rb | 67 +++++++++++++++---- 3 files changed, 76 insertions(+), 14 deletions(-) diff --git a/lib/appsignal/integrations/shoryuken.rb b/lib/appsignal/integrations/shoryuken.rb index fa92ca316..6b79894c1 100644 --- a/lib/appsignal/integrations/shoryuken.rb +++ b/lib/appsignal/integrations/shoryuken.rb @@ -167,11 +167,28 @@ def call(options) return yield end - Appsignal.instrument("enqueue.shoryuken", :opentelemetry_kind => :producer) do + Appsignal.instrument( + "enqueue.shoryuken", + enqueue_title(options), + :opentelemetry_kind => :producer + ) do ShoryukenTraceContext.inject(options) yield end end + + private + + # Enqueues through a Shoryuken worker carry the worker class in the + # `shoryuken_class` message attribute. Raw `send_message` enqueues don't, + # so there's no worker class to name -- fall back to the queue instead. + 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}" + end end end end diff --git a/spec/lib/appsignal/integrations/shoryuken_client_spec.rb b/spec/lib/appsignal/integrations/shoryuken_client_spec.rb index a417e2462..185aa865b 100644 --- a/spec/lib/appsignal/integrations/shoryuken_client_spec.rb +++ b/spec/lib/appsignal/integrations/shoryuken_client_spec.rb @@ -57,7 +57,9 @@ def send_message sent = send_message Appsignal::Transaction.complete_current! - producer = event_spans.find { |s| s.name == "enqueue.shoryuken" } + # 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 diff --git a/spec/lib/appsignal/integrations/shoryuken_spec.rb b/spec/lib/appsignal/integrations/shoryuken_spec.rb index f8d8e07da..03783d65f 100644 --- a/spec/lib/appsignal/integrations/shoryuken_spec.rb +++ b/spec/lib/appsignal/integrations/shoryuken_spec.rb @@ -368,22 +368,36 @@ def enqueue(&block) end context "with an active transaction" do - describe "records the enqueue under the transaction" do - def perform - transaction = http_request_transaction - set_current_transaction(transaction) - enqueue - transaction + 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. + describe "enqueued through a worker" do + let(:options) do + { + :message_body => "foo", + :queue_url => "https://sqs.us-east-1.amazonaws.com/0/my-queue", + :message_attributes => { + "shoryuken_class" => { :string_value => "MyShoryukenWorker", :data_type => "String" } + } + } end it "in agent mode", :agent_mode do start_agent transaction = perform - # Records an enqueue event on the transaction; no wire context in agent mode. - event_names = transaction.to_h["events"].map { |event| event["name"] } - expect(event_names).to include("enqueue.shoryuken") - expect(options).to_not have_key(:message_attributes) + # 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 @@ -391,8 +405,10 @@ def perform perform Appsignal::Transaction.complete_current! - # The enqueue is a producer event span under the active transaction. - producer = event_spans.find { |s| s.name == "enqueue.shoryuken" } + # 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) @@ -404,6 +420,33 @@ def perform ) end end + + # A raw `send_message` enqueue has no worker class, so the event falls back + # to naming the queue it was sent to. + 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 "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 From 7cfeb520fce79e8a88a1fbf85793998e8f83a4ca Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Mon, 6 Jul 2026 20:42:38 +0200 Subject: [PATCH 149/151] Track Que bulk enqueue with our own flag The inner enqueues in a bulk_enqueue block detected the batch by reading Que's internal :que_jobs_to_bulk_insert thread-local. Set our own flag in the bulk_enqueue wrapper instead, so the pass-through doesn't depend on Que internals. --- lib/appsignal/integrations/que.rb | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/lib/appsignal/integrations/que.rb b/lib/appsignal/integrations/que.rb index 5aa7ac0f4..361688275 100644 --- a/lib/appsignal/integrations/que.rb +++ b/lib/appsignal/integrations/que.rb @@ -110,11 +110,11 @@ def _run(*args) # transparent pass-through. module QueClientPlugin def enqueue(*args, job_options: {}, **rest) - # Inside a Que 2 `bulk_enqueue` block the per-job enqueue must stay a + # 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 bulk_insert_in_progress? + return super if Thread.current[:appsignal_que_bulk_enqueue] # Resolve the job class the way Que does: an explicit `:job_class`, else # the class `enqueue` was called on. @@ -151,10 +151,6 @@ def job_options_with_context(job_options) tags = QueTraceContext.inject(job_options[:tags]) tags.empty? ? job_options : job_options.merge(:tags => tags) end - - def bulk_insert_in_progress? - !Thread.current[:que_jobs_to_bulk_insert].nil? - end end # @!visibility private @@ -167,7 +163,15 @@ def bulk_insert_in_progress? module QueBulkClientPlugin def bulk_enqueue(job_options: {}, **rest, &block) record_enqueue(job_options, "bulk_enqueue.que", bulk_enqueue_title(job_options)) do |merged| - super(:job_options => merged, **rest, &block) + # 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(:job_options => merged, **rest, &block) + ensure + Thread.current[:appsignal_que_bulk_enqueue] = was_bulk + end end end From aa57fbc9c6d621701ba70cbeda5c76e1090e5ef8 Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Tue, 7 Jul 2026 10:59:28 +0200 Subject: [PATCH 150/151] Drop enqueue specs duplicated by the rebase Rebasing onto main pulled in the backports' agent-mode enqueue specs, which the wip dual-mode specs already cover. Keep the dual-mode versions and drop the duplicates. --- .../lib/appsignal/integrations/resque_spec.rb | 62 --------------- .../appsignal/integrations/shoryuken_spec.rb | 77 ------------------- 2 files changed, 139 deletions(-) diff --git a/spec/lib/appsignal/integrations/resque_spec.rb b/spec/lib/appsignal/integrations/resque_spec.rb index 1b2cce0b0..cf9c574f7 100644 --- a/spec/lib/appsignal/integrations/resque_spec.rb +++ b/spec/lib/appsignal/integrations/resque_spec.rb @@ -386,66 +386,4 @@ def perform 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) - - # 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 - end - end end diff --git a/spec/lib/appsignal/integrations/shoryuken_spec.rb b/spec/lib/appsignal/integrations/shoryuken_spec.rb index 03783d65f..2774a8906 100644 --- a/spec/lib/appsignal/integrations/shoryuken_spec.rb +++ b/spec/lib/appsignal/integrations/shoryuken_spec.rb @@ -503,80 +503,3 @@ def enqueue_suppressed(transaction) 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 {} - described_class.new.call(options, &block) - end - - context "with an active transaction" do - # 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 - let(:options) do - { - :message_body => "foo", - :queue_url => "https://sqs.us-east-1.amazonaws.com/0/my-queue", - :message_attributes => { - "shoryuken_class" => { :string_value => "MyShoryukenWorker", :data_type => "String" } - } - } - end - - it "records the enqueue under the transaction, titled after the worker" do - transaction = http_request_transaction - set_current_transaction(transaction) - - enqueue - - 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") - 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 - 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 - - 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 - end - end - - context "without an active transaction" do - it "passes through without recording" do - expect { |block| enqueue(&block) }.to yield_control - 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) - - transaction.suppress_job_enqueue_events { enqueue } - - # 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 - end -end From f6eaff9a132cc7ccc2ecf1322c14840c79fe2424 Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Tue, 7 Jul 2026 10:59:28 +0200 Subject: [PATCH 151/151] Test Shoryuken 6/7 in agent and collector modes main's enqueue backport split Shoryuken CI into a 6 and 7 version matrix, while wip tested a single Shoryuken in collector mode. Adopt the version matrix and generate collector variants for both, so each version runs in both modes. --- .github/workflows/ci.yml | 72 +++++++++---------- build_matrix.yml | 14 +++- ....gemfile => shoryuken-6-collector.gemfile} | 4 +- gemfiles/shoryuken-7-collector.gemfile | 6 ++ gemfiles/shoryuken.gemfile | 8 --- 5 files changed, 57 insertions(+), 47 deletions(-) rename gemfiles/{shoryuken-collector.gemfile => shoryuken-6-collector.gemfile} (68%) create mode 100644 gemfiles/shoryuken-7-collector.gemfile delete mode 100644 gemfiles/shoryuken.gemfile diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d1f6b6e29..1ccb1178e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1401,8 +1401,8 @@ jobs: JRUBY_OPTS: '' COV: '1' BUNDLE_GEMFILE: gemfiles/sequel-collector.gemfile - ruby_4-0-0__shoryuken_ubuntu-latest: - name: Ruby 4.0.0 - shoryuken + 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: @@ -1428,9 +1428,9 @@ jobs: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/shoryuken.gemfile - ruby_4-0-0__shoryuken-collector_ubuntu-latest: - name: Ruby 4.0.0 - shoryuken-collector + 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: @@ -1454,7 +1454,7 @@ jobs: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/shoryuken-collector.gemfile + 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 @@ -3376,8 +3376,8 @@ jobs: JRUBY_OPTS: '' COV: '1' BUNDLE_GEMFILE: gemfiles/sequel-collector.gemfile - ruby_3-4-1__shoryuken_ubuntu-latest: - name: Ruby 3.4.1 - shoryuken + 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: @@ -3403,9 +3403,9 @@ jobs: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/shoryuken.gemfile - ruby_3-4-1__shoryuken-collector_ubuntu-latest: - name: Ruby 3.4.1 - shoryuken-collector + 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: @@ -3429,7 +3429,7 @@ jobs: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/shoryuken-collector.gemfile + 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 @@ -5405,8 +5405,8 @@ jobs: JRUBY_OPTS: '' COV: '1' BUNDLE_GEMFILE: gemfiles/sequel-collector.gemfile - ruby_3-3-4__shoryuken_ubuntu-latest: - name: Ruby 3.3.4 - shoryuken + 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: @@ -5432,9 +5432,9 @@ jobs: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/shoryuken.gemfile - ruby_3-3-4__shoryuken-collector_ubuntu-latest: - name: Ruby 3.3.4 - shoryuken-collector + 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: @@ -5458,7 +5458,7 @@ jobs: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/shoryuken-collector.gemfile + 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 @@ -7272,8 +7272,8 @@ jobs: JRUBY_OPTS: '' COV: '1' BUNDLE_GEMFILE: gemfiles/sequel-collector.gemfile - ruby_3-2-5__shoryuken_ubuntu-latest: - name: Ruby 3.2.5 - shoryuken + 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: @@ -7299,9 +7299,9 @@ jobs: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/shoryuken.gemfile - ruby_3-2-5__shoryuken-collector_ubuntu-latest: - name: Ruby 3.2.5 - shoryuken-collector + 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: @@ -7325,7 +7325,7 @@ jobs: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/shoryuken-collector.gemfile + 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 @@ -8977,8 +8977,8 @@ jobs: JRUBY_OPTS: '' COV: '1' BUNDLE_GEMFILE: gemfiles/sequel-collector.gemfile - ruby_3-1-6__shoryuken_ubuntu-latest: - name: Ruby 3.1.6 - shoryuken + 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: @@ -9004,9 +9004,9 @@ jobs: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/shoryuken.gemfile - ruby_3-1-6__shoryuken-collector_ubuntu-latest: - name: Ruby 3.1.6 - shoryuken-collector + 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: @@ -9030,7 +9030,7 @@ jobs: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/shoryuken-collector.gemfile + 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 @@ -9953,8 +9953,8 @@ jobs: JRUBY_OPTS: '' COV: '1' BUNDLE_GEMFILE: gemfiles/sequel.gemfile - ruby_3-0-7__shoryuken_ubuntu-latest: - name: Ruby 3.0.7 - shoryuken + ruby_3-0-7__shoryuken-6_ubuntu-latest: + name: Ruby 3.0.7 - shoryuken-6 needs: ruby_3-0-7_ubuntu-latest runs-on: ubuntu-latest steps: @@ -9979,7 +9979,7 @@ jobs: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/shoryuken.gemfile + BUNDLE_GEMFILE: gemfiles/shoryuken-6.gemfile ruby_3-0-7__sinatra_ubuntu-latest: name: Ruby 3.0.7 - sinatra needs: ruby_3-0-7_ubuntu-latest @@ -10632,8 +10632,8 @@ jobs: JRUBY_OPTS: '' COV: '1' BUNDLE_GEMFILE: gemfiles/sequel.gemfile - ruby_2-7-8__shoryuken_ubuntu-latest: - name: Ruby 2.7.8 - shoryuken + ruby_2-7-8__shoryuken-6_ubuntu-latest: + name: Ruby 2.7.8 - shoryuken-6 needs: ruby_2-7-8_ubuntu-latest runs-on: ubuntu-latest steps: @@ -10658,7 +10658,7 @@ jobs: RAILS_ENV: test JRUBY_OPTS: '' COV: '1' - BUNDLE_GEMFILE: gemfiles/shoryuken.gemfile + BUNDLE_GEMFILE: gemfiles/shoryuken-6.gemfile ruby_2-7-8__sinatra_ubuntu-latest: name: Ruby 2.7.8 - sinatra needs: ruby_2-7-8_ubuntu-latest diff --git a/build_matrix.yml b/build_matrix.yml index 6e49eafa6..dc383a8e0 100644 --- a/build_matrix.yml +++ b/build_matrix.yml @@ -303,7 +303,19 @@ matrix: - "3.1.6" - "3.0.7" - gem: "sequel" - - gem: "shoryuken" + - gem: "shoryuken-6" + only: + ruby: + - "3.1.6" + - "3.0.7" + - "2.7.8" + - gem: "shoryuken-7" + only: + ruby: + - "4.0.0" + - "3.4.1" + - "3.3.4" + - "3.2.5" - gem: "sinatra" - gem: "webmachine2" - gem: "redis-4" diff --git a/gemfiles/shoryuken-collector.gemfile b/gemfiles/shoryuken-6-collector.gemfile similarity index 68% rename from gemfiles/shoryuken-collector.gemfile rename to gemfiles/shoryuken-6-collector.gemfile index e6131592c..cfb5bb853 100644 --- a/gemfiles/shoryuken-collector.gemfile +++ b/gemfiles/shoryuken-6-collector.gemfile @@ -1,6 +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.gemfile. +# It layers the optional OpenTelemetry gems (gemfiles/collector.rb) on top of shoryuken-6.gemfile. -eval_gemfile File.expand_path("shoryuken.gemfile", __dir__) +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/shoryuken.gemfile b/gemfiles/shoryuken.gemfile deleted file mode 100644 index edc4319ee..000000000 --- a/gemfiles/shoryuken.gemfile +++ /dev/null @@ -1,8 +0,0 @@ -source "https://rubygems.org" - -# Shoryuken loads the AWS SDK, which since v3 needs aws-sdk-sqs declared -# separately or it raises on load. -gem "aws-sdk-sqs" -gem "shoryuken" - -gemspec :path => "../"