diff --git a/.github/workflows/build_batch_release.yml b/.github/workflows/build_batch_release.yml index cb0639992..ffc30fcbd 100644 --- a/.github/workflows/build_batch_release.yml +++ b/.github/workflows/build_batch_release.yml @@ -13,7 +13,7 @@ jobs: - name: Set up Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 2.7 + ruby-version: 3.3 - name: Build gem source run: ruby .scripts/batch_build.rb - name: Archive Artifacts diff --git a/.github/workflows/sentry_ruby_test.yml b/.github/workflows/sentry_ruby_test.yml index 2e6f64126..a154df326 100644 --- a/.github/workflows/sentry_ruby_test.yml +++ b/.github/workflows/sentry_ruby_test.yml @@ -26,6 +26,7 @@ jobs: name: Ruby ${{ matrix.ruby_version }} & Rack ${{ matrix.rack_version }}, options - ${{ toJson(matrix.options) }} runs-on: ubuntu-latest strategy: + fail-fast: false matrix: ruby_version: ${{ fromJson(needs.ruby-versions.outputs.versions) }} rack_version: [2.0, 3.0] diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f3c3586d..d3d9f5627 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,99 @@ -## 5.17.3 +## 5.19.0 + +### Features + +- Use `Concurrent.available_processor_count` instead of `Concurrent.usable_processor_count` ([#2358](https://github.com/getsentry/sentry-ruby/pull/2358)) + +- Support for tracing Faraday requests ([#2345](https://github.com/getsentry/sentry-ruby/pull/2345)) + - Closes [#1795](https://github.com/getsentry/sentry-ruby/issues/1795) + - Please note that the Faraday instrumentation has some limitations in case of async requests: https://github.com/lostisland/faraday/issues/1381 +- Support for attachments ([#2357](https://github.com/getsentry/sentry-ruby/pull/2357)) + + Usage: + + ```ruby + Sentry.add_attachment(path: '/foo/bar.txt') + Sentry.add_attachment(filename: 'payload.json', bytes: '{"value": 42}')) + ``` +- Transaction data are now included in the context ([#2365](https://github.com/getsentry/sentry-ruby/pull/2365)) + - Closes [#2364](https://github.com/getsentry/sentry-ruby/issues/2363) + +- Inject Sentry meta tags in the Rails application layout automatically in the generator ([#2369](https://github.com/getsentry/sentry-ruby/pull/2369)) + + To turn this behavior off, use + ```bash + bin/rails generate sentry --inject-meta false + ``` + +### Bug Fixes + +- Fix skipping `connect` spans in open-telemetry [#2364](https://github.com/getsentry/sentry-ruby/pull/2364) + +## 5.18.2 + +### Bug Fixes + +- Don't overwrite `ip_address` if already set on `user` [#2350](https://github.com/getsentry/sentry-ruby/pull/2350) + - Fixes [#2347](https://github.com/getsentry/sentry-ruby/issues/2347) +- `teardown_sentry_test` helper should clear global even processors too ([#2342](https://github.com/getsentry/sentry-ruby/pull/2342)) +- Suppress the unnecessary “unsupported options notice” ([#2349](https://github.com/getsentry/sentry-ruby/pull/2349)) + +### Internal + +- Use `Concurrent.usable_processor_count` when it is available ([#2339](https://github.com/getsentry/sentry-ruby/pull/2339)) +- Report dropped spans in Client Reports ([#2346](https://github.com/getsentry/sentry-ruby/pull/2346)) + +## 5.18.1 + +### Bug Fixes + +- Drop `Gem::Specification`'s usage so it doesn't break bundler standalone ([#2335](https://github.com/getsentry/sentry-ruby/pull/2335)) + +## 5.18.0 ### Features +- Add generator for initializer generation ([#2286](https://github.com/getsentry/sentry-ruby/pull/2286)) + + Rails users will be able to use `bin/rails generate sentry` to generate their `config/initializers/sentry.rb` file. + +- Notify users when their custom options are discarded ([#2303](https://github.com/getsentry/sentry-ruby/pull/2303)) +- Add a new `:graphql` patch to automatically enable instrumenting GraphQL spans ([#2308](https://github.com/getsentry/sentry-ruby/pull/2308)) + + Usage: + + ```rb + Sentry.init do |config| + # ... + config.enabled_patches += [:graphql] + end + ``` + +- Add `Sentry.get_trace_propagation_meta` helper for injecting meta tags into views ([#2314](https://github.com/getsentry/sentry-ruby/pull/2314)) +- Add query source support to `sentry-rails` ([#2313](https://github.com/getsentry/sentry-ruby/pull/2313)) + + The feature is only activated in apps that use Ruby 3.2+ and Rails 7.1+. By default only queries that take longer than 100ms will have source recorded, which can be adjusted by updating the value of `config.rails.db_query_source_threshold_ms`. +- Log envelope delivery message with debug instead of info ([#2320](https://github.com/getsentry/sentry-ruby/pull/2320)) + +### Bug Fixes + +- Don't throw error on arbitrary arguments being passed to `capture_event` options [#2301](https://github.com/getsentry/sentry-ruby/pull/2301) + - Fixes [#2299](https://github.com/getsentry/sentry-ruby/issues/2299) +- Decrease the default number of background worker threads by half ([#2305](https://github.com/getsentry/sentry-ruby/pull/2305)) + - Fixes [#2297](https://github.com/getsentry/sentry-ruby/issues/2297) +- Don't mutate `enabled_environments` when using `Sentry::TestHelper` ([#2317](https://github.com/getsentry/sentry-ruby/pull/2317)) +- Don't use array for transaction names and sources on scope ([#2324](https://github.com/getsentry/sentry-ruby/pull/2324)) + - Fixes [#2257](https://github.com/getsentry/sentry-ruby/issues/2257) + - **BREAKING** This removes the internal `scope.transaction_names` method, please use `scope.transaction_name` instead + +### Internal + +- Add `origin` to spans and transactions to track integration sources for instrumentation ([#2319](https://github.com/getsentry/sentry-ruby/pull/2319)) + +## 5.17.3 + +### Internal + - Update key, unit and tags sanitization logic for metrics [#2292](https://github.com/getsentry/sentry-ruby/pull/2292) - Consolidate client report and rate limit handling with data categories [#2294](https://github.com/getsentry/sentry-ruby/pull/2294) - Record `:network_error` client reports for `send_envelope` [#2295](https://github.com/getsentry/sentry-ruby/pull/2295) @@ -13,7 +105,7 @@ ## 5.17.2 -### Features +### Internal - Add `Mechanism` interface and default to unhandled for integration exceptions [#2280](https://github.com/getsentry/sentry-ruby/pull/2280) diff --git a/Gemfile b/Gemfile index 515440efe..c4344ea66 100644 --- a/Gemfile +++ b/Gemfile @@ -9,10 +9,7 @@ ruby_version = Gem::Version.new(RUBY_VERSION) if ruby_version >= Gem::Version.new("2.7.0") gem "debug", github: "ruby/debug", platform: :ruby gem "irb" - - if ruby_version >= Gem::Version.new("3.0.0") - gem "ruby-lsp-rspec" - end + gem "ruby-lsp-rspec" if ruby_version >= Gem::Version.new("3.0.0") && RUBY_PLATFORM != "java" end # For RSpec diff --git a/sentry-delayed_job/Gemfile b/sentry-delayed_job/Gemfile index c1b2a698b..936bec48a 100644 --- a/sentry-delayed_job/Gemfile +++ b/sentry-delayed_job/Gemfile @@ -20,7 +20,10 @@ platform :jruby do gem "jdbc-sqlite3" end -# 1.7.0 dropped support for ruby < 3.0, remove later after upgrading craft setup -gem "sqlite3", "1.6.9", platform: :ruby +if Gem::Version.new(RUBY_VERSION) < Gem::Version.new("2.5.0") + gem "sqlite3", "~> 1.3.0", platform: :ruby +else + gem "sqlite3", "~> 1.6.9", platform: :ruby +end eval_gemfile File.expand_path("../Gemfile", __dir__) diff --git a/sentry-delayed_job/lib/sentry/delayed_job/plugin.rb b/sentry-delayed_job/lib/sentry/delayed_job/plugin.rb index 29e1d9e4e..66093f340 100644 --- a/sentry-delayed_job/lib/sentry/delayed_job/plugin.rb +++ b/sentry-delayed_job/lib/sentry/delayed_job/plugin.rb @@ -8,7 +8,8 @@ class Plugin < ::Delayed::Plugin # need to symbolize strings as keyword arguments in Ruby 2.4~2.6 DELAYED_JOB_CONTEXT_KEY = :"Delayed-Job" ACTIVE_JOB_CONTEXT_KEY = :"Active-Job" - OP_NAME = "queue.delayed_job".freeze + OP_NAME = "queue.delayed_job" + SPAN_ORIGIN = "auto.queue.delayed_job" callbacks do |lifecycle| lifecycle.before(:enqueue) do |job, *args, &block| @@ -93,7 +94,13 @@ def self.report?(job) end def self.start_transaction(scope, env, contexts) - options = { name: scope.transaction_name, source: scope.transaction_source, op: OP_NAME } + options = { + name: scope.transaction_name, + source: scope.transaction_source, + op: OP_NAME, + origin: SPAN_ORIGIN + } + transaction = Sentry.continue_trace(env, **options) Sentry.start_transaction(transaction: transaction, custom_sampling_context: contexts, **options) end diff --git a/sentry-delayed_job/lib/sentry/delayed_job/version.rb b/sentry-delayed_job/lib/sentry/delayed_job/version.rb index daf287304..95d3630b0 100644 --- a/sentry-delayed_job/lib/sentry/delayed_job/version.rb +++ b/sentry-delayed_job/lib/sentry/delayed_job/version.rb @@ -1,5 +1,5 @@ module Sentry module DelayedJob - VERSION = "5.17.3" + VERSION = "5.19.0" end end diff --git a/sentry-delayed_job/sentry-delayed_job.gemspec b/sentry-delayed_job/sentry-delayed_job.gemspec index ce952668f..e97096bcb 100644 --- a/sentry-delayed_job/sentry-delayed_job.gemspec +++ b/sentry-delayed_job/sentry-delayed_job.gemspec @@ -7,21 +7,27 @@ Gem::Specification.new do |spec| spec.description = spec.summary = "A gem that provides DelayedJob integration for the Sentry error logger" spec.email = "accounts@sentry.io" spec.license = 'MIT' - spec.homepage = "https://github.com/getsentry/sentry-ruby" spec.platform = Gem::Platform::RUBY spec.required_ruby_version = '>= 2.4' spec.extra_rdoc_files = ["README.md", "LICENSE.txt"] spec.files = `git ls-files | grep -Ev '^(spec|benchmarks|examples)'`.split("\n") - spec.metadata["homepage_uri"] = spec.homepage - spec.metadata["source_code_uri"] = spec.homepage - spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/master/CHANGELOG.md" + github_root_uri = 'https://github.com/getsentry/sentry-ruby' + spec.homepage = "#{github_root_uri}/tree/#{spec.version}/#{spec.name}" + + spec.metadata = { + "homepage_uri" => spec.homepage, + "source_code_uri" => spec.homepage, + "changelog_uri" => "#{github_root_uri}/blob/#{spec.version}/CHANGELOG.md", + "bug_tracker_uri" => "#{github_root_uri}/issues", + "documentation_uri" => "http://www.rubydoc.info/gems/#{spec.name}/#{spec.version}" + } spec.bindir = "exe" spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } spec.require_paths = ["lib"] - spec.add_dependency "sentry-ruby", "~> 5.17.3" + spec.add_dependency "sentry-ruby", "~> 5.19.0" spec.add_dependency "delayed_job", ">= 4.0" end diff --git a/sentry-delayed_job/spec/sentry/delayed_job_spec.rb b/sentry-delayed_job/spec/sentry/delayed_job_spec.rb index 7c7440452..ac2d31fb1 100644 --- a/sentry-delayed_job/spec/sentry/delayed_job_spec.rb +++ b/sentry-delayed_job/spec/sentry/delayed_job_spec.rb @@ -383,6 +383,7 @@ def perform expect(transaction.contexts.dig(:trace, :span_id)).to be_a(String) expect(transaction.contexts.dig(:trace, :status)).to eq("ok") expect(transaction.contexts.dig(:trace, :op)).to eq("queue.delayed_job") + expect(transaction.contexts.dig(:trace, :origin)).to eq("auto.queue.delayed_job") end it "records transaction with exception" do diff --git a/sentry-opentelemetry/lib/sentry/opentelemetry/span_processor.rb b/sentry-opentelemetry/lib/sentry/opentelemetry/span_processor.rb index d35bb7025..71152ad29 100644 --- a/sentry-opentelemetry/lib/sentry/opentelemetry/span_processor.rb +++ b/sentry-opentelemetry/lib/sentry/opentelemetry/span_processor.rb @@ -11,6 +11,7 @@ class SpanProcessor < ::OpenTelemetry::SDK::Trace::SpanProcessor SEMANTIC_CONVENTIONS = ::OpenTelemetry::SemanticConventions::Trace INTERNAL_SPAN_KINDS = %i[client internal] + SPAN_ORIGIN = "auto.otel" # The mapping from otel span ids to sentry spans # @return [Hash] @@ -34,7 +35,8 @@ def on_start(otel_span, parent_context) sentry_parent_span.start_child( span_id: trace_data.span_id, description: otel_span.name, - start_timestamp: otel_span.start_timestamp / 1e9 + start_timestamp: otel_span.start_timestamp / 1e9, + origin: SPAN_ORIGIN ) else options = { @@ -45,7 +47,8 @@ def on_start(otel_span, parent_context) parent_span_id: trace_data.parent_span_id, parent_sampled: trace_data.parent_sampled, baggage: trace_data.baggage, - start_timestamp: otel_span.start_timestamp / 1e9 + start_timestamp: otel_span.start_timestamp / 1e9, + origin: SPAN_ORIGIN } Sentry.start_transaction(**options) @@ -80,7 +83,7 @@ def from_sentry_sdk?(otel_span) dsn = Sentry.configuration.dsn return false unless dsn - if otel_span.name.start_with?("HTTP") + if otel_span.name.start_with?("HTTP") || otel_span.name == "connect" # only check client requests, connects are sometimes internal return false unless INTERNAL_SPAN_KINDS.include?(otel_span.kind) diff --git a/sentry-opentelemetry/lib/sentry/opentelemetry/version.rb b/sentry-opentelemetry/lib/sentry/opentelemetry/version.rb index ec7f7366e..ddc0cbe67 100644 --- a/sentry-opentelemetry/lib/sentry/opentelemetry/version.rb +++ b/sentry-opentelemetry/lib/sentry/opentelemetry/version.rb @@ -2,6 +2,6 @@ module Sentry module OpenTelemetry - VERSION = "5.17.3" + VERSION = "5.19.0" end end diff --git a/sentry-opentelemetry/sentry-opentelemetry.gemspec b/sentry-opentelemetry/sentry-opentelemetry.gemspec index 5ff363e99..aaee606be 100644 --- a/sentry-opentelemetry/sentry-opentelemetry.gemspec +++ b/sentry-opentelemetry/sentry-opentelemetry.gemspec @@ -9,21 +9,27 @@ Gem::Specification.new do |spec| spec.description = spec.summary = "A gem that provides OpenTelemetry integration for the Sentry error logger" spec.email = "accounts@sentry.io" spec.license = 'MIT' - spec.homepage = "https://github.com/getsentry/sentry-ruby" spec.platform = Gem::Platform::RUBY spec.required_ruby_version = '>= 2.4' spec.extra_rdoc_files = ["README.md", "LICENSE.txt"] spec.files = `git ls-files | grep -Ev '^(spec|benchmarks|examples)'`.split("\n") - spec.metadata["homepage_uri"] = spec.homepage - spec.metadata["source_code_uri"] = spec.homepage - spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/master/CHANGELOG.md" + github_root_uri = 'https://github.com/getsentry/sentry-ruby' + spec.homepage = "#{github_root_uri}/tree/#{spec.version}/#{spec.name}" + + spec.metadata = { + "homepage_uri" => spec.homepage, + "source_code_uri" => spec.homepage, + "changelog_uri" => "#{github_root_uri}/blob/#{spec.version}/CHANGELOG.md", + "bug_tracker_uri" => "#{github_root_uri}/issues", + "documentation_uri" => "http://www.rubydoc.info/gems/#{spec.name}/#{spec.version}" + } spec.bindir = "exe" spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } spec.require_paths = ["lib"] - spec.add_dependency "sentry-ruby", "~> 5.17.3" + spec.add_dependency "sentry-ruby", "~> 5.19.0" spec.add_dependency "opentelemetry-sdk", "~> 1.0" end diff --git a/sentry-opentelemetry/spec/sentry/opentelemetry/span_processor_spec.rb b/sentry-opentelemetry/spec/sentry/opentelemetry/span_processor_spec.rb index e90179498..0d5626730 100644 --- a/sentry-opentelemetry/spec/sentry/opentelemetry/span_processor_spec.rb +++ b/sentry-opentelemetry/spec/sentry/opentelemetry/span_processor_spec.rb @@ -64,6 +64,18 @@ tracer.start_span('HTTP POST', with_parent: root_parent_context, attributes: attributes, kind: :client) end + let(:child_internal_span_connect) do + attributes = { + 'http.method' => 'POST', + 'http.scheme' => 'https', + 'http.target' => '/api/5434472/envelope/', + 'net.peer.name' => 'sentry.localdomain', + 'net.peer.port' => 443 + } + + tracer.start_span('connect', with_parent: root_parent_context, attributes: attributes, kind: :internal) + end + before do perform_basic_setup perform_otel_setup @@ -92,6 +104,7 @@ expect(event.contexts).to include(:trace) expect(event.contexts[:trace][:trace_id]).to eq(root_span.context.hex_trace_id) expect(event.contexts[:trace][:span_id]).to eq(root_span.context.hex_span_id) + expect(event.contexts[:trace][:origin]).to eq('auto.otel') end end end @@ -134,6 +147,7 @@ expect(transaction.span_id).to eq(span_id) expect(transaction.trace_id).to eq(trace_id) expect(transaction.start_timestamp).to eq(root_span.start_timestamp / 1e9) + expect(transaction.origin).to eq('auto.otel') expect(transaction.parent_span_id).to eq(nil) expect(transaction.parent_sampled).to eq(nil) @@ -151,6 +165,11 @@ subject.on_start(child_internal_span, root_parent_context) end + it 'noops on `connect` requests' do + expect(transaction).not_to receive(:start_child) + subject.on_start(child_internal_span_connect, root_parent_context) + end + it 'starts a sentry child span on otel child span' do expect(transaction).to receive(:start_child).and_call_original subject.on_start(child_db_span, root_parent_context) @@ -168,6 +187,7 @@ expect(sentry_span.trace_id).to eq(trace_id) expect(sentry_span.description).to eq(child_db_span.name) expect(sentry_span.start_timestamp).to eq(child_db_span.start_timestamp / 1e9) + expect(sentry_span.origin).to eq('auto.otel') end end end @@ -215,6 +235,7 @@ subject.on_finish(finished_db_span) expect(sentry_span.op).to eq('db') + expect(sentry_span.origin).to eq('auto.otel') expect(sentry_span.description).to eq(finished_db_span.attributes['db.statement']) expect(sentry_span.data).to include(finished_db_span.attributes) expect(sentry_span.data).to include({ 'otel.kind' => finished_db_span.kind }) @@ -235,6 +256,7 @@ subject.on_finish(finished_http_span) expect(sentry_span.op).to eq('http.client') + expect(sentry_span.origin).to eq('auto.otel') expect(sentry_span.description).to eq('GET www.google.com/search') expect(sentry_span.data).to include(finished_http_span.attributes) expect(sentry_span.data).to include({ 'otel.kind' => finished_http_span.kind }) @@ -259,6 +281,7 @@ subject.on_finish(finished_root_span) expect(transaction.op).to eq('http.server') + expect(transaction.origin).to eq('auto.otel') expect(transaction.name).to eq(finished_root_span.name) expect(transaction.status).to eq('ok') expect(transaction.contexts[:otel]).to eq({ diff --git a/sentry-rails/Gemfile b/sentry-rails/Gemfile index fd2ce76cb..8449f342a 100644 --- a/sentry-rails/Gemfile +++ b/sentry-rails/Gemfile @@ -17,8 +17,11 @@ rails_version = Gem::Version.new(rails_version) if rails_version < Gem::Version.new("6.0.0") gem "sqlite3", "~> 1.3.0", platform: :ruby else - # 1.7.0 dropped support for ruby < 3.0, remove later after upgrading craft setup - gem "sqlite3", "1.6.9", platform: :ruby + if Gem::Version.new(RUBY_VERSION) >= Gem::Version.new("2.7.0") + gem "sqlite3", "~> 1.7.3", platform: :ruby + else + gem "sqlite3", "~> 1.6.9", platform: :ruby + end end if rails_version >= Gem::Version.new("7.2.0.alpha") diff --git a/sentry-rails/lib/generators/sentry_generator.rb b/sentry-rails/lib/generators/sentry_generator.rb new file mode 100644 index 000000000..de694e40d --- /dev/null +++ b/sentry-rails/lib/generators/sentry_generator.rb @@ -0,0 +1,29 @@ +require "rails/generators/base" + +class SentryGenerator < ::Rails::Generators::Base + class_option :dsn, type: :string, desc: "Sentry DSN" + + class_option :inject_meta, type: :boolean, default: true, desc: "Inject meta tag into layout" + + def copy_initializer_file + dsn = options[:dsn] ? "'#{options[:dsn]}'" : "ENV['SENTRY_DSN']" + + create_file "config/initializers/sentry.rb", <<~RUBY + # frozen_string_literal: true + + Sentry.init do |config| + config.breadcrumbs_logger = [:active_support_logger] + config.dsn = #{dsn} + config.enable_tracing = true + end + RUBY + end + + def inject_code_into_layout + return unless options[:inject_meta] + + inject_into_file "app/views/layouts/application.html.erb", before: "\n" do + " <%= Sentry.get_trace_propagation_meta.html_safe %>\n " + end + end +end diff --git a/sentry-rails/lib/sentry/rails/action_cable.rb b/sentry-rails/lib/sentry/rails/action_cable.rb index 155377466..06833a68b 100644 --- a/sentry-rails/lib/sentry/rails/action_cable.rb +++ b/sentry-rails/lib/sentry/rails/action_cable.rb @@ -3,6 +3,7 @@ module Rails module ActionCableExtensions class ErrorHandler OP_NAME = "websocket.server".freeze + SPAN_ORIGIN = "auto.http.rails.actioncable" class << self def capture(connection, transaction_name:, extra_context: nil, &block) @@ -33,7 +34,13 @@ def capture(connection, transaction_name:, extra_context: nil, &block) end def start_transaction(env, scope) - options = { name: scope.transaction_name, source: scope.transaction_source, op: OP_NAME } + options = { + name: scope.transaction_name, + source: scope.transaction_source, + op: OP_NAME, + origin: SPAN_ORIGIN + } + transaction = Sentry.continue_trace(env, **options) Sentry.start_transaction(transaction: transaction, **options) end diff --git a/sentry-rails/lib/sentry/rails/active_job.rb b/sentry-rails/lib/sentry/rails/active_job.rb index cf0c7ca1a..afdf87cd7 100644 --- a/sentry-rails/lib/sentry/rails/active_job.rb +++ b/sentry-rails/lib/sentry/rails/active_job.rb @@ -17,6 +17,7 @@ def already_supported_by_sentry_integration? class SentryReporter OP_NAME = "queue.active_job".freeze + SPAN_ORIGIN = "auto.queue.active_job".freeze class << self def record(job, &block) @@ -27,7 +28,12 @@ def record(job, &block) if job.is_a?(::Sentry::SendEventJob) nil else - Sentry.start_transaction(name: scope.transaction_name, source: scope.transaction_source, op: OP_NAME) + Sentry.start_transaction( + name: scope.transaction_name, + source: scope.transaction_source, + op: OP_NAME, + origin: SPAN_ORIGIN + ) end scope.set_span(transaction) if transaction diff --git a/sentry-rails/lib/sentry/rails/capture_exceptions.rb b/sentry-rails/lib/sentry/rails/capture_exceptions.rb index 8d93784ed..d52456081 100644 --- a/sentry-rails/lib/sentry/rails/capture_exceptions.rb +++ b/sentry-rails/lib/sentry/rails/capture_exceptions.rb @@ -2,6 +2,7 @@ module Sentry module Rails class CaptureExceptions < Sentry::Rack::CaptureExceptions RAILS_7_1 = Gem::Version.new(::Rails.version) >= Gem::Version.new("7.1.0.alpha") + SPAN_ORIGIN = 'auto.http.rails'.freeze def initialize(_) super @@ -32,7 +33,12 @@ def capture_exception(exception, env) end def start_transaction(env, scope) - options = { name: scope.transaction_name, source: scope.transaction_source, op: transaction_op } + options = { + name: scope.transaction_name, + source: scope.transaction_source, + op: transaction_op, + origin: SPAN_ORIGIN + } if @assets_regexp && scope.transaction_name.match?(@assets_regexp) options.merge!(sampled: false) diff --git a/sentry-rails/lib/sentry/rails/configuration.rb b/sentry-rails/lib/sentry/rails/configuration.rb index ce385408c..883700142 100644 --- a/sentry-rails/lib/sentry/rails/configuration.rb +++ b/sentry-rails/lib/sentry/rails/configuration.rb @@ -126,6 +126,14 @@ class Configuration attr_accessor :tracing_subscribers + # When the ActiveRecordSubscriber is enabled, capture the source location of the query in the span data. + # This is enabled by default, but can be disabled by setting this to false. + attr_accessor :enable_db_query_source + + # The threshold in milliseconds for the ActiveRecordSubscriber to capture the source location of the query + # in the span data. Default is 100ms. + attr_accessor :db_query_source_threshold_ms + # sentry-rails by default skips asset request' transactions by checking if the path matches # # ```rb @@ -157,6 +165,8 @@ def initialize Sentry::Rails::Tracing::ActiveRecordSubscriber, Sentry::Rails::Tracing::ActiveStorageSubscriber ]) + @enable_db_query_source = true + @db_query_source_threshold_ms = 100 @active_support_logger_subscription_items = Sentry::Rails::ACTIVE_SUPPORT_LOGGER_SUBSCRIPTION_ITEMS_DEFAULT.dup end end diff --git a/sentry-rails/lib/sentry/rails/controller_transaction.rb b/sentry-rails/lib/sentry/rails/controller_transaction.rb index 72f31e6c1..b85506a3d 100644 --- a/sentry-rails/lib/sentry/rails/controller_transaction.rb +++ b/sentry-rails/lib/sentry/rails/controller_transaction.rb @@ -1,6 +1,8 @@ module Sentry module Rails module ControllerTransaction + SPAN_ORIGIN = 'auto.view.rails'.freeze + def self.included(base) base.prepend_around_action(:sentry_around_action) end @@ -11,7 +13,7 @@ def sentry_around_action if Sentry.initialized? transaction_name = "#{self.class}##{action_name}" Sentry.get_current_scope.set_transaction_name(transaction_name, source: :view) - Sentry.with_child_span(op: "view.process_action.action_controller", description: transaction_name) do |child_span| + Sentry.with_child_span(op: "view.process_action.action_controller", description: transaction_name, origin: SPAN_ORIGIN) do |child_span| if child_span begin result = yield diff --git a/sentry-rails/lib/sentry/rails/railtie.rb b/sentry-rails/lib/sentry/rails/railtie.rb index ef09851c5..44dd450dc 100644 --- a/sentry-rails/lib/sentry/rails/railtie.rb +++ b/sentry-rails/lib/sentry/rails/railtie.rb @@ -12,7 +12,7 @@ class Railtie < ::Rails::Railtie app.config.middleware.insert_after ActionDispatch::DebugExceptions, Sentry::Rails::RescuedExceptionInterceptor end - # because the extension works by registering the around_perform callcack, it should always be ran + # because the extension works by registering the around_perform callback, it should always be run # before the application is eager-loaded (before user's jobs register their own callbacks) # See https://github.com/getsentry/sentry-ruby/issues/1249#issuecomment-853871871 for the detail explanation initializer "sentry.extend_active_job", before: :eager_load! do |app| diff --git a/sentry-rails/lib/sentry/rails/tracing/action_controller_subscriber.rb b/sentry-rails/lib/sentry/rails/tracing/action_controller_subscriber.rb index 8c3ec5ddf..11e9eadf2 100644 --- a/sentry-rails/lib/sentry/rails/tracing/action_controller_subscriber.rb +++ b/sentry-rails/lib/sentry/rails/tracing/action_controller_subscriber.rb @@ -9,6 +9,7 @@ class ActionControllerSubscriber < AbstractSubscriber EVENT_NAMES = ["process_action.action_controller"].freeze OP_NAME = "view.process_action.action_controller".freeze + SPAN_ORIGIN = "auto.view.rails".freeze def self.subscribe! Sentry.logger.warn <<~MSG @@ -22,6 +23,7 @@ def self.subscribe! record_on_current_span( op: OP_NAME, + origin: SPAN_ORIGIN, start_timestamp: payload[START_TIMESTAMP_NAME], description: "#{controller}##{action}", duration: duration diff --git a/sentry-rails/lib/sentry/rails/tracing/action_view_subscriber.rb b/sentry-rails/lib/sentry/rails/tracing/action_view_subscriber.rb index 588af1374..baed2c7e5 100644 --- a/sentry-rails/lib/sentry/rails/tracing/action_view_subscriber.rb +++ b/sentry-rails/lib/sentry/rails/tracing/action_view_subscriber.rb @@ -6,10 +6,17 @@ module Tracing class ActionViewSubscriber < AbstractSubscriber EVENT_NAMES = ["render_template.action_view"].freeze SPAN_PREFIX = "template.".freeze + SPAN_ORIGIN = "auto.template.rails".freeze def self.subscribe! subscribe_to_event(EVENT_NAMES) do |event_name, duration, payload| - record_on_current_span(op: SPAN_PREFIX + event_name, start_timestamp: payload[START_TIMESTAMP_NAME], description: payload[:identifier], duration: duration) + record_on_current_span( + op: SPAN_PREFIX + event_name, + origin: SPAN_ORIGIN, + start_timestamp: payload[START_TIMESTAMP_NAME], + description: payload[:identifier], + duration: duration + ) end end end diff --git a/sentry-rails/lib/sentry/rails/tracing/active_record_subscriber.rb b/sentry-rails/lib/sentry/rails/tracing/active_record_subscriber.rb index ab25273a9..480e7011a 100644 --- a/sentry-rails/lib/sentry/rails/tracing/active_record_subscriber.rb +++ b/sentry-rails/lib/sentry/rails/tracing/active_record_subscriber.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "sentry/rails/tracing/abstract_subscriber" module Sentry @@ -5,41 +7,95 @@ module Rails module Tracing class ActiveRecordSubscriber < AbstractSubscriber EVENT_NAMES = ["sql.active_record"].freeze - SPAN_PREFIX = "db.".freeze + SPAN_PREFIX = "db." + SPAN_ORIGIN = "auto.db.rails" EXCLUDED_EVENTS = ["SCHEMA", "TRANSACTION"].freeze - def self.subscribe! - subscribe_to_event(EVENT_NAMES) do |event_name, duration, payload| - next if EXCLUDED_EVENTS.include? payload[:name] + SUPPORT_SOURCE_LOCATION = ActiveSupport::BacktraceCleaner.method_defined?(:clean_frame) - record_on_current_span(op: SPAN_PREFIX + event_name, start_timestamp: payload[START_TIMESTAMP_NAME], description: payload[:sql], duration: duration) do |span| - span.set_tag(:cached, true) if payload.fetch(:cached, false) # cached key is only set for hits in the QueryCache, from Rails 5.1 + if SUPPORT_SOURCE_LOCATION + class_attribute :backtrace_cleaner, default: (ActiveSupport::BacktraceCleaner.new.tap do |cleaner| + cleaner.add_silencer { |line| line.include?("sentry-ruby/lib") || line.include?("sentry-rails/lib") } + end) + end - connection = payload[:connection] + class << self + def subscribe! + record_query_source = SUPPORT_SOURCE_LOCATION && Sentry.configuration.rails.enable_db_query_source + query_source_threshold = Sentry.configuration.rails.db_query_source_threshold_ms - if payload[:connection_id] - span.set_data(:connection_id, payload[:connection_id]) + subscribe_to_event(EVENT_NAMES) do |event_name, duration, payload| + next if EXCLUDED_EVENTS.include? payload[:name] - # we fallback to the base connection on rails < 6.0.0 since the payload doesn't have it - connection ||= ActiveRecord::Base.connection_pool.connections.find { |conn| conn.object_id == payload[:connection_id] } - end + record_on_current_span( + op: SPAN_PREFIX + event_name, + origin: SPAN_ORIGIN, + start_timestamp: payload[START_TIMESTAMP_NAME], + description: payload[:sql], + duration: duration + ) do |span| + span.set_tag(:cached, true) if payload.fetch(:cached, false) # cached key is only set for hits in the QueryCache, from Rails 5.1 - next unless connection + connection = payload[:connection] - db_config = - if connection.pool.respond_to?(:db_config) - connection.pool.db_config.configuration_hash - elsif connection.pool.respond_to?(:spec) - connection.pool.spec.config + if payload[:connection_id] + span.set_data(:connection_id, payload[:connection_id]) + + # we fallback to the base connection on rails < 6.0.0 since the payload doesn't have it + connection ||= ActiveRecord::Base.connection_pool.connections.find { |conn| conn.object_id == payload[:connection_id] } end - next unless db_config + next unless connection + + db_config = + if connection.pool.respond_to?(:db_config) + connection.pool.db_config.configuration_hash + elsif connection.pool.respond_to?(:spec) + connection.pool.spec.config + end + + next unless db_config + + span.set_data(Span::DataConventions::DB_SYSTEM, db_config[:adapter]) if db_config[:adapter] + span.set_data(Span::DataConventions::DB_NAME, db_config[:database]) if db_config[:database] + span.set_data(Span::DataConventions::SERVER_ADDRESS, db_config[:host]) if db_config[:host] + span.set_data(Span::DataConventions::SERVER_PORT, db_config[:port]) if db_config[:port] + span.set_data(Span::DataConventions::SERVER_SOCKET_ADDRESS, db_config[:socket]) if db_config[:socket] + + next unless record_query_source + + # both duration and query_source_threshold are in ms + next unless duration >= query_source_threshold - span.set_data(Span::DataConventions::DB_SYSTEM, db_config[:adapter]) if db_config[:adapter] - span.set_data(Span::DataConventions::DB_NAME, db_config[:database]) if db_config[:database] - span.set_data(Span::DataConventions::SERVER_ADDRESS, db_config[:host]) if db_config[:host] - span.set_data(Span::DataConventions::SERVER_PORT, db_config[:port]) if db_config[:port] - span.set_data(Span::DataConventions::SERVER_SOCKET_ADDRESS, db_config[:socket]) if db_config[:socket] + source_location = query_source_location + + if source_location + backtrace_line = Sentry::Backtrace::Line.parse(source_location) + span.set_data(Span::DataConventions::FILEPATH, backtrace_line.file) if backtrace_line.file + span.set_data(Span::DataConventions::LINENO, backtrace_line.number) if backtrace_line.number + span.set_data(Span::DataConventions::FUNCTION, backtrace_line.method) if backtrace_line.method + # Only JRuby has namespace in the backtrace + span.set_data(Span::DataConventions::NAMESPACE, backtrace_line.module_name) if backtrace_line.module_name + end + end + end + end + + # Thread.each_caller_location is an API added in Ruby 3.2 that doesn't always collect the entire stack like + # Kernel#caller or #caller_locations do. See https://github.com/rails/rails/pull/49095 for more context. + if SUPPORT_SOURCE_LOCATION && Thread.respond_to?(:each_caller_location) + def query_source_location + Thread.each_caller_location do |location| + frame = backtrace_cleaner.clean_frame(location) + return frame if frame + end + nil + end + else + # Since Sentry is mostly used in production, we don't want to fallback to the slower implementation + # and adds potentially big overhead to the application. + def query_source_location + nil end end end diff --git a/sentry-rails/lib/sentry/rails/tracing/active_storage_subscriber.rb b/sentry-rails/lib/sentry/rails/tracing/active_storage_subscriber.rb index 4a8f8a306..5d62bad22 100644 --- a/sentry-rails/lib/sentry/rails/tracing/active_storage_subscriber.rb +++ b/sentry-rails/lib/sentry/rails/tracing/active_storage_subscriber.rb @@ -19,9 +19,17 @@ class ActiveStorageSubscriber < AbstractSubscriber analyze.active_storage ].freeze + SPAN_ORIGIN = "auto.file.rails".freeze + def self.subscribe! subscribe_to_event(EVENT_NAMES) do |event_name, duration, payload| - record_on_current_span(op: "file.#{event_name}".freeze, start_timestamp: payload[START_TIMESTAMP_NAME], description: payload[:service], duration: duration) do |span| + record_on_current_span( + op: "file.#{event_name}".freeze, + origin: SPAN_ORIGIN, + start_timestamp: payload[START_TIMESTAMP_NAME], + description: payload[:service], + duration: duration + ) do |span| payload.each do |key, value| span.set_data(key, value) unless key == START_TIMESTAMP_NAME end diff --git a/sentry-rails/lib/sentry/rails/version.rb b/sentry-rails/lib/sentry/rails/version.rb index 5cc93512e..ac6bcb23b 100644 --- a/sentry-rails/lib/sentry/rails/version.rb +++ b/sentry-rails/lib/sentry/rails/version.rb @@ -1,5 +1,5 @@ module Sentry module Rails - VERSION = "5.17.3" + VERSION = "5.19.0" end end diff --git a/sentry-rails/sentry-rails.gemspec b/sentry-rails/sentry-rails.gemspec index f7d61c958..7cfdd0e70 100644 --- a/sentry-rails/sentry-rails.gemspec +++ b/sentry-rails/sentry-rails.gemspec @@ -7,21 +7,27 @@ Gem::Specification.new do |spec| spec.description = spec.summary = "A gem that provides Rails integration for the Sentry error logger" spec.email = "accounts@sentry.io" spec.license = 'MIT' - spec.homepage = "https://github.com/getsentry/sentry-ruby" spec.platform = Gem::Platform::RUBY spec.required_ruby_version = '>= 2.4' spec.extra_rdoc_files = ["README.md", "LICENSE.txt"] spec.files = `git ls-files | grep -Ev '^(spec|benchmarks|examples)'`.split("\n") - spec.metadata["homepage_uri"] = spec.homepage - spec.metadata["source_code_uri"] = spec.homepage - spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/master/CHANGELOG.md" + github_root_uri = 'https://github.com/getsentry/sentry-ruby' + spec.homepage = "#{github_root_uri}/tree/#{spec.version}/#{spec.name}" + + spec.metadata = { + "homepage_uri" => spec.homepage, + "source_code_uri" => spec.homepage, + "changelog_uri" => "#{github_root_uri}/blob/#{spec.version}/CHANGELOG.md", + "bug_tracker_uri" => "#{github_root_uri}/issues", + "documentation_uri" => "http://www.rubydoc.info/gems/#{spec.name}/#{spec.version}" + } spec.bindir = "exe" spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } spec.require_paths = ["lib"] spec.add_dependency "railties", ">= 5.0" - spec.add_dependency "sentry-ruby", "~> 5.17.3" + spec.add_dependency "sentry-ruby", "~> 5.19.0" end diff --git a/sentry-rails/spec/sentry/generator_spec.rb b/sentry-rails/spec/sentry/generator_spec.rb new file mode 100644 index 000000000..7da9e12a7 --- /dev/null +++ b/sentry-rails/spec/sentry/generator_spec.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +require "fileutils" +require "rails/generators/test_case" +require "generators/sentry_generator" + +RSpec.describe SentryGenerator do + include ::Rails::Generators::Testing::Behaviour + include FileUtils + self.destination File.expand_path('../../tmp', __dir__) + self.generator_class = described_class + + let(:layout_file) do + File.join(destination_root, "app/views/layouts/application.html.erb") + end + + before do + prepare_destination + + FileUtils.mkdir_p(File.dirname(layout_file)) + + File.write(layout_file, <<~STR) + + + + SentryTesting + + <%= csrf_meta_tags %> + <%= csp_meta_tag %> + + <%= stylesheet_link_tag "application", "data-turbo-track": "reload" %> + <%= javascript_importmap_tags %> + + + + <%= yield %> + + + STR + end + + it "creates a initializer file" do + run_generator + + file = File.join(destination_root, "config/initializers/sentry.rb") + expect(File).to exist(file) + content = File.read(file) + expect(content).to include(<<~RUBY) + Sentry.init do |config| + config.breadcrumbs_logger = [:active_support_logger] + config.dsn = ENV['SENTRY_DSN'] + config.enable_tracing = true + end + RUBY + end + + it "injects meta tag into the layout" do + run_generator + + content = File.read(layout_file) + + expect(content).to include("Sentry.get_trace_propagation_meta.html_safe") + end + + it "doesn't inject meta tag when it's disabled" do + run_generator %w[--inject-meta false] + + content = File.read(layout_file) + + expect(content).not_to include("Sentry.get_trace_propagation_meta.html_safe") + end + + context "with a DSN option" do + it "creates a initializer file with the DSN" do + run_generator %w[--dsn foobarbaz] + + file = File.join(destination_root, "config/initializers/sentry.rb") + expect(File).to exist(file) + content = File.read(file) + expect(content).to include(<<~RUBY) + Sentry.init do |config| + config.breadcrumbs_logger = [:active_support_logger] + config.dsn = 'foobarbaz' + config.enable_tracing = true + end + RUBY + end + end +end diff --git a/sentry-rails/spec/sentry/rails/action_cable_spec.rb b/sentry-rails/spec/sentry/rails/action_cable_spec.rb index 40db25c74..4b353adff 100644 --- a/sentry-rails/spec/sentry/rails/action_cable_spec.rb +++ b/sentry-rails/spec/sentry/rails/action_cable_spec.rb @@ -204,7 +204,8 @@ def disconnect expect(transaction["contexts"]).to include( "trace" => hash_including( "op" => "websocket.server", - "status" => "internal_error" + "status" => "internal_error", + "origin" => "auto.http.rails.actioncable" ) ) end @@ -230,7 +231,8 @@ def disconnect expect(subscription_transaction["contexts"]).to include( "trace" => hash_including( "op" => "websocket.server", - "status" => "ok" + "status" => "ok", + "origin" => "auto.http.rails.actioncable" ) ) @@ -259,7 +261,8 @@ def disconnect expect(action_transaction["contexts"]).to include( "trace" => hash_including( "op" => "websocket.server", - "status" => "internal_error" + "status" => "internal_error", + "origin" => "auto.http.rails.actioncable" ) ) end @@ -281,7 +284,8 @@ def disconnect expect(subscription_transaction["contexts"]).to include( "trace" => hash_including( "op" => "websocket.server", - "status" => "ok" + "status" => "ok", + "origin" => "auto.http.rails.actioncable" ) ) @@ -308,7 +312,8 @@ def disconnect expect(transaction["contexts"]).to include( "trace" => hash_including( "op" => "websocket.server", - "status" => "internal_error" + "status" => "internal_error", + "origin" => "auto.http.rails.actioncable" ) ) end diff --git a/sentry-rails/spec/sentry/rails/activejob_spec.rb b/sentry-rails/spec/sentry/rails/activejob_spec.rb index f4d1550be..f5892d944 100644 --- a/sentry-rails/spec/sentry/rails/activejob_spec.rb +++ b/sentry-rails/spec/sentry/rails/activejob_spec.rb @@ -182,6 +182,7 @@ def post.to_global_id expect(transaction.contexts.dig(:trace, :span_id)).to be_present expect(transaction.contexts.dig(:trace, :status)).to eq("ok") expect(transaction.contexts.dig(:trace, :op)).to eq("queue.active_job") + expect(transaction.contexts.dig(:trace, :origin)).to eq("auto.queue.active_job") expect(transaction.spans.count).to eq(1) expect(transaction.spans.first[:op]).to eq("db.sql.active_record") @@ -199,6 +200,7 @@ def post.to_global_id expect(transaction.contexts.dig(:trace, :trace_id)).to be_present expect(transaction.contexts.dig(:trace, :span_id)).to be_present expect(transaction.contexts.dig(:trace, :status)).to eq("internal_error") + expect(transaction.contexts.dig(:trace, :origin)).to eq("auto.queue.active_job") event = transport.events.last expect(event.transaction).to eq("FailedWithExtraJob") diff --git a/sentry-rails/spec/sentry/rails/tracing/action_controller_subscriber_spec.rb b/sentry-rails/spec/sentry/rails/tracing/action_controller_subscriber_spec.rb index 58852df52..f147e1b8e 100644 --- a/sentry-rails/spec/sentry/rails/tracing/action_controller_subscriber_spec.rb +++ b/sentry-rails/spec/sentry/rails/tracing/action_controller_subscriber_spec.rb @@ -36,6 +36,7 @@ span = transaction[:spans][0] expect(span[:op]).to eq("view.process_action.action_controller") + expect(span[:origin]).to eq("auto.view.rails") expect(span[:description]).to eq("HelloController#world") expect(span[:trace_id]).to eq(transaction.dig(:contexts, :trace, :trace_id)) expect(span[:data].keys).to match_array(["http.response.status_code", :format, :method, :path, :params]) diff --git a/sentry-rails/spec/sentry/rails/tracing/action_view_subscriber_spec.rb b/sentry-rails/spec/sentry/rails/tracing/action_view_subscriber_spec.rb index f6d3ff31f..aa2473312 100644 --- a/sentry-rails/spec/sentry/rails/tracing/action_view_subscriber_spec.rb +++ b/sentry-rails/spec/sentry/rails/tracing/action_view_subscriber_spec.rb @@ -25,6 +25,7 @@ # ignore the first span, which is for controller action span = transaction[:spans][1] expect(span[:op]).to eq("template.render_template.action_view") + expect(span[:origin]).to eq("auto.template.rails") expect(span[:description]).to match(/test_template\.html\.erb/) expect(span[:trace_id]).to eq(transaction.dig(:contexts, :trace, :trace_id)) end diff --git a/sentry-rails/spec/sentry/rails/tracing/active_record_subscriber_spec.rb b/sentry-rails/spec/sentry/rails/tracing/active_record_subscriber_spec.rb index 4e385a374..aa29b82d2 100644 --- a/sentry-rails/spec/sentry/rails/tracing/active_record_subscriber_spec.rb +++ b/sentry-rails/spec/sentry/rails/tracing/active_record_subscriber_spec.rb @@ -6,10 +6,15 @@ end context "when transaction is sampled" do + let(:enable_db_query_source) { true } + let(:db_query_source_threshold_ms) { 0 } + before do make_basic_app do |config| config.traces_sample_rate = 1.0 config.rails.tracing_subscribers = [described_class] + config.rails.enable_db_query_source = enable_db_query_source + config.rails.db_query_source_threshold_ms = db_query_source_threshold_ms end end @@ -29,6 +34,7 @@ span = transaction[:spans][0] expect(span[:op]).to eq("db.sql.active_record") + expect(span[:origin]).to eq("auto.db.rails") expect(span[:description]).to eq("SELECT \"posts\".* FROM \"posts\"") expect(span[:tags].key?(:cached)).to eq(false) expect(span[:trace_id]).to eq(transaction.dig(:contexts, :trace, :trace_id)) @@ -38,6 +44,78 @@ expect(data["db.system"]).to eq("sqlite3") end + context "when query source location is avaialble", skip: RUBY_VERSION.to_f < 3.2 || Rails.version.to_f < 7.1 do + def foo + Post.all.to_a + end + query_line = __LINE__ - 2 + + before do + transaction = Sentry::Transaction.new(sampled: true, hub: Sentry.get_current_hub) + Sentry.get_current_scope.set_span(transaction) + + foo + + transaction.finish + end + + context "when config.rails.enable_db_query_source is false" do + let(:enable_db_query_source) { false } + + it "doesn't record query's source location" do + expect(transport.events.count).to eq(1) + + transaction = transport.events.first.to_hash + expect(transaction[:type]).to eq("transaction") + expect(transaction[:spans].count).to eq(1) + + span = transaction[:spans][0] + data = span[:data] + expect(data["db.name"]).to include("db") + expect(data["code.filepath"]).to eq(nil) + expect(data["code.lineno"]).to eq(nil) + expect(data["code.function"]).to eq(nil) + end + end + + context "when the query takes longer than the threshold" do + let(:db_query_source_threshold_ms) { 0 } + + it "records query's source location" do + expect(transport.events.count).to eq(1) + + transaction = transport.events.first.to_hash + expect(transaction[:type]).to eq("transaction") + expect(transaction[:spans].count).to eq(1) + + span = transaction[:spans][0] + data = span[:data] + expect(data["code.filepath"]).to eq(__FILE__) + expect(data["code.lineno"]).to eq(query_line) + expect(data["code.function"]).to eq("foo") + end + end + + context "when the query takes shorter than the threshold" do + let(:db_query_source_threshold_ms) { 1000 } + + it "doesn't record query's source location" do + expect(transport.events.count).to eq(1) + + transaction = transport.events.first.to_hash + expect(transaction[:type]).to eq("transaction") + expect(transaction[:spans].count).to eq(1) + + span = transaction[:spans][0] + data = span[:data] + expect(data["db.name"]).to include("db") + expect(data["code.filepath"]).to eq(nil) + expect(data["code.lineno"]).to eq(nil) + expect(data["code.function"]).to eq(nil) + end + end + end + it "records database cached query events", skip: Rails.version.to_f < 5.1 do transaction = Sentry::Transaction.new(sampled: true, hub: Sentry.get_current_hub) Sentry.get_current_scope.set_span(transaction) @@ -57,6 +135,7 @@ cached_query_span = transaction[:spans][1] expect(cached_query_span[:op]).to eq("db.sql.active_record") + expect(cached_query_span[:origin]).to eq("auto.db.rails") expect(cached_query_span[:description]).to eq("SELECT \"posts\".* FROM \"posts\"") expect(cached_query_span[:tags]).to include({ cached: true }) diff --git a/sentry-rails/spec/sentry/rails/tracing/active_storage_subscriber_spec.rb b/sentry-rails/spec/sentry/rails/tracing/active_storage_subscriber_spec.rb index bfacfe433..5d34db1e3 100644 --- a/sentry-rails/spec/sentry/rails/tracing/active_storage_subscriber_spec.rb +++ b/sentry-rails/spec/sentry/rails/tracing/active_storage_subscriber_spec.rb @@ -29,10 +29,13 @@ if Rails.version.to_f > 6.1 expect(analysis_transaction[:spans].count).to eq(2) expect(analysis_transaction[:spans][0][:op]).to eq("file.service_streaming_download.active_storage") + expect(analysis_transaction[:spans][0][:origin]).to eq("auto.file.rails") expect(analysis_transaction[:spans][1][:op]).to eq("file.analyze.active_storage") + expect(analysis_transaction[:spans][1][:origin]).to eq("auto.file.rails") else expect(analysis_transaction[:spans].count).to eq(1) expect(analysis_transaction[:spans][0][:op]).to eq("file.service_streaming_download.active_storage") + expect(analysis_transaction[:spans][0][:origin]).to eq("auto.file.rails") end request_transaction = transport.events.last.to_hash @@ -41,6 +44,7 @@ span = request_transaction[:spans][1] expect(span[:op]).to eq("file.service_upload.active_storage") + expect(span[:origin]).to eq("auto.file.rails") expect(span[:description]).to eq("Disk") expect(span.dig(:data, :key)).to eq(p.cover.key) expect(span[:trace_id]).to eq(request_transaction.dig(:contexts, :trace, :trace_id)) diff --git a/sentry-rails/spec/sentry/rails/tracing_spec.rb b/sentry-rails/spec/sentry/rails/tracing_spec.rb index 719ab1794..287bd8acc 100644 --- a/sentry-rails/spec/sentry/rails/tracing_spec.rb +++ b/sentry-rails/spec/sentry/rails/tracing_spec.rb @@ -32,11 +32,13 @@ expect(transaction[:type]).to eq("transaction") expect(transaction.dig(:contexts, :trace, :op)).to eq("http.server") + expect(transaction.dig(:contexts, :trace, :origin)).to eq("auto.http.rails") parent_span_id = transaction.dig(:contexts, :trace, :span_id) expect(transaction[:spans].count).to eq(2) first_span = transaction[:spans][0] expect(first_span[:op]).to eq("view.process_action.action_controller") + expect(first_span[:origin]).to eq("auto.view.rails") expect(first_span[:description]).to eq("PostsController#index") expect(first_span[:parent_span_id]).to eq(parent_span_id) expect(first_span[:status]).to eq("internal_error") @@ -44,6 +46,7 @@ second_span = transaction[:spans][1] expect(second_span[:op]).to eq("db.sql.active_record") + expect(second_span[:origin]).to eq("auto.db.rails") expect(second_span[:description]).to eq("SELECT \"posts\".* FROM \"posts\"") expect(second_span[:parent_span_id]).to eq(first_span[:span_id]) @@ -63,19 +66,21 @@ expect(transaction[:type]).to eq("transaction") expect(transaction.dig(:contexts, :trace, :op)).to eq("http.server") + expect(transaction.dig(:contexts, :trace, :origin)).to eq("auto.http.rails") parent_span_id = transaction.dig(:contexts, :trace, :span_id) expect(transaction[:spans].count).to eq(3) first_span = transaction[:spans][0] expect(first_span[:data].keys).to match_array(["http.response.status_code", :format, :method, :path, :params]) expect(first_span[:op]).to eq("view.process_action.action_controller") + expect(first_span[:origin]).to eq("auto.view.rails") expect(first_span[:description]).to eq("PostsController#show") expect(first_span[:parent_span_id]).to eq(parent_span_id) expect(first_span[:status]).to eq("ok") - second_span = transaction[:spans][1] expect(second_span[:op]).to eq("db.sql.active_record") + expect(second_span[:origin]).to eq("auto.db.rails") expect(second_span[:description].squeeze("\s")).to eq( 'SELECT "posts".* FROM "posts" WHERE "posts"."id" = ? LIMIT ?' ) @@ -86,6 +91,7 @@ third_span = transaction[:spans][2] expect(third_span[:op]).to eq("template.render_template.action_view") + expect(third_span[:origin]).to eq("auto.template.rails") expect(third_span[:description].squeeze("\s")).to eq("text template") expect(third_span[:parent_span_id]).to eq(first_span[:span_id]) end @@ -239,6 +245,7 @@ expect(transaction.timestamp).not_to be_nil expect(transaction.contexts.dig(:trace, :status)).to eq("ok") expect(transaction.contexts.dig(:trace, :op)).to eq("http.server") + expect(transaction.contexts.dig(:trace, :origin)).to eq("auto.http.rails") expect(transaction.spans.count).to eq(3) # should inherit information from the external_transaction diff --git a/sentry-resque/lib/sentry/resque.rb b/sentry-resque/lib/sentry/resque.rb index f204e5236..84907d84f 100644 --- a/sentry-resque/lib/sentry/resque.rb +++ b/sentry-resque/lib/sentry/resque.rb @@ -15,6 +15,8 @@ def perform end class SentryReporter + SPAN_ORIGIN = "auto.queue.resque" + class << self def record(queue, worker, payload, &block) Sentry.with_scope do |scope| @@ -25,7 +27,13 @@ def record(queue, worker, payload, &block) name = contexts.dig(:"Active-Job", :job_class) || contexts.dig(:"Resque", :job_class) scope.set_transaction_name(name, source: :task) - transaction = Sentry.start_transaction(name: scope.transaction_name, source: scope.transaction_source, op: "queue.resque") + transaction = Sentry.start_transaction( + name: scope.transaction_name, + source: scope.transaction_source, + op: "queue.resque", + origin: SPAN_ORIGIN + ) + scope.set_span(transaction) if transaction yield diff --git a/sentry-resque/lib/sentry/resque/version.rb b/sentry-resque/lib/sentry/resque/version.rb index 0575cfb47..9e6a6ec4f 100644 --- a/sentry-resque/lib/sentry/resque/version.rb +++ b/sentry-resque/lib/sentry/resque/version.rb @@ -1,5 +1,5 @@ module Sentry module Resque - VERSION = "5.17.3" + VERSION = "5.19.0" end end diff --git a/sentry-resque/sentry-resque.gemspec b/sentry-resque/sentry-resque.gemspec index 6a51a680a..36d777b00 100644 --- a/sentry-resque/sentry-resque.gemspec +++ b/sentry-resque/sentry-resque.gemspec @@ -7,21 +7,27 @@ Gem::Specification.new do |spec| spec.description = spec.summary = "A gem that provides Resque integration for the Sentry error logger" spec.email = "accounts@sentry.io" spec.license = 'MIT' - spec.homepage = "https://github.com/getsentry/sentry-ruby" spec.platform = Gem::Platform::RUBY spec.required_ruby_version = '>= 2.4' spec.extra_rdoc_files = ["README.md", "LICENSE.txt"] spec.files = `git ls-files | grep -Ev '^(spec|benchmarks|examples)'`.split("\n") - spec.metadata["homepage_uri"] = spec.homepage - spec.metadata["source_code_uri"] = spec.homepage - spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/master/CHANGELOG.md" + github_root_uri = 'https://github.com/getsentry/sentry-ruby' + spec.homepage = "#{github_root_uri}/tree/#{spec.version}/#{spec.name}" + + spec.metadata = { + "homepage_uri" => spec.homepage, + "source_code_uri" => spec.homepage, + "changelog_uri" => "#{github_root_uri}/blob/#{spec.version}/CHANGELOG.md", + "bug_tracker_uri" => "#{github_root_uri}/issues", + "documentation_uri" => "http://www.rubydoc.info/gems/#{spec.name}/#{spec.version}" + } spec.bindir = "exe" spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } spec.require_paths = ["lib"] - spec.add_dependency "sentry-ruby", "~> 5.17.3" + spec.add_dependency "sentry-ruby", "~> 5.19.0" spec.add_dependency "resque", ">= 1.24" end diff --git a/sentry-resque/spec/sentry/tracing_spec.rb b/sentry-resque/spec/sentry/tracing_spec.rb index 01605b950..e77b84f4e 100644 --- a/sentry-resque/spec/sentry/tracing_spec.rb +++ b/sentry-resque/spec/sentry/tracing_spec.rb @@ -42,6 +42,7 @@ def self.perform(msg) expect(tracing_event[:type]).to eq("transaction") expect(tracing_event.dig(:contexts, :trace, :status)).to eq("ok") expect(tracing_event.dig(:contexts, :trace, :op)).to eq("queue.resque") + expect(tracing_event.dig(:contexts, :trace, :origin)).to eq("auto.queue.resque") end it "records tracing events with exceptions" do @@ -59,6 +60,7 @@ def self.perform(msg) expect(tracing_event[:type]).to eq("transaction") expect(tracing_event.dig(:contexts, :trace, :status)).to eq("internal_error") expect(tracing_event.dig(:contexts, :trace, :op)).to eq("queue.resque") + expect(tracing_event.dig(:contexts, :trace, :origin)).to eq("auto.queue.resque") end context "with instrumenter :otel" do diff --git a/sentry-ruby/Gemfile b/sentry-ruby/Gemfile index be196db7e..c6e1db011 100644 --- a/sentry-ruby/Gemfile +++ b/sentry-ruby/Gemfile @@ -15,6 +15,8 @@ gem "puma" gem "timecop" gem "stackprof" unless RUBY_PLATFORM == "java" +gem "graphql", ">= 2.2.6" if RUBY_VERSION.to_f >= 2.7 + gem "benchmark-ips" gem "benchmark_driver" gem "benchmark-ipsa" @@ -22,5 +24,6 @@ gem "benchmark-memory" gem "yard", github: "lsegal/yard" gem "webrick" +gem "faraday" eval_gemfile File.expand_path("../Gemfile", __dir__) diff --git a/sentry-ruby/README.md b/sentry-ruby/README.md index 1c82fa467..c98757a32 100644 --- a/sentry-ruby/README.md +++ b/sentry-ruby/README.md @@ -13,14 +13,14 @@ _Bad software is everywhere, and we're tired of it. Sentry is on a mission to he Sentry SDK for Ruby =========== -| current version | build | coverage | downloads | -| --- | ----- | -------- | --------- | -| [![Gem Version](https://img.shields.io/gem/v/sentry-ruby?label=sentry-ruby)](https://rubygems.org/gems/sentry-ruby) | [![Build Status](https://github.com/getsentry/sentry-ruby/actions/workflows/sentry_ruby_test.yml/badge.svg)](https://github.com/getsentry/sentry-ruby/actions/workflows/sentry_ruby_test.yml) | [![Coverage Status](https://img.shields.io/codecov/c/github/getsentry/sentry-ruby/master?logo=codecov)](https://codecov.io/gh/getsentry/sentry-ruby/branch/master) | [![Downloads](https://img.shields.io/gem/dt/sentry-ruby.svg)](https://rubygems.org/gems/sentry-ruby/) | -| [![Gem Version](https://img.shields.io/gem/v/sentry-rails?label=sentry-rails)](https://rubygems.org/gems/sentry-rails) | [![Build Status](https://github.com/getsentry/sentry-ruby/actions/workflows/sentry_rails_test.yml/badge.svg)](https://github.com/getsentry/sentry-ruby/actions/workflows/sentry_rails_test.yml) | [![Coverage Status](https://img.shields.io/codecov/c/github/getsentry/sentry-ruby/master?logo=codecov)](https://codecov.io/gh/getsentry/sentry-ruby/branch/master) | [![Downloads](https://img.shields.io/gem/dt/sentry-rails.svg)](https://rubygems.org/gems/sentry-rails/) | -| [![Gem Version](https://img.shields.io/gem/v/sentry-sidekiq?label=sentry-sidekiq)](https://rubygems.org/gems/sentry-sidekiq) | [![Build Status](https://github.com/getsentry/sentry-ruby/actions/workflows/sentry_sidekiq_test.yml/badge.svg)](https://github.com/getsentry/sentry-ruby/actions/workflows/sentry_sidekiq_test.yml) | [![Coverage Status](https://img.shields.io/codecov/c/github/getsentry/sentry-ruby/master?logo=codecov)](https://codecov.io/gh/getsentry/sentry-ruby/branch/master) | [![Downloads](https://img.shields.io/gem/dt/sentry-sidekiq.svg)](https://rubygems.org/gems/sentry-sidekiq/) | -| [![Gem Version](https://img.shields.io/gem/v/sentry-delayed_job?label=sentry-delayed_job)](https://rubygems.org/gems/sentry-delayed_job) | [![Build Status](https://github.com/getsentry/sentry-ruby/actions/workflows/sentry_delayed_job_test.yml/badge.svg)](https://github.com/getsentry/sentry-ruby/actions/workflows/sentry_delayed_job_test.yml) | [![Coverage Status](https://img.shields.io/codecov/c/github/getsentry/sentry-ruby/master?logo=codecov)](https://codecov.io/gh/getsentry/sentry-ruby/branch/master) | [![Downloads](https://img.shields.io/gem/dt/sentry-delayed_job.svg)](https://rubygems.org/gems/sentry-delayed_job/) | -| [![Gem Version](https://img.shields.io/gem/v/sentry-resque?label=sentry-resque)](https://rubygems.org/gems/sentry-resque) | [![Build Status](https://github.com/getsentry/sentry-ruby/actions/workflows/sentry_resque_test.yml/badge.svg)](https://github.com/getsentry/sentry-ruby/actions/workflows/sentry_resque_test.yml) | [![Coverage Status](https://img.shields.io/codecov/c/github/getsentry/sentry-ruby/master?logo=codecov)](https://codecov.io/gh/getsentry/sentry-ruby/branch/master) | [![Downloads](https://img.shields.io/gem/dt/sentry-resque.svg)](https://rubygems.org/gems/sentry-resque/) | -| [![Gem Version](https://img.shields.io/gem/v/sentry-opentelemetry?label=sentry-opentelemetry)](https://rubygems.org/gems/sentry-opentelemetry) | [![Build Status](https://github.com/getsentry/sentry-ruby/actions/workflows/sentry_opentelemetry_test.yml/badge.svg)](https://github.com/getsentry/sentry-ruby/actions/workflows/sentry_opentelemetry_test.yml) | [![Coverage Status](https://img.shields.io/codecov/c/github/getsentry/sentry-ruby/master?logo=codecov)](https://codecov.io/gh/getsentry/sentry-ruby/branch/master) | [![Downloads](https://img.shields.io/gem/dt/sentry-opentelemetry.svg)](https://rubygems.org/gems/sentry-opentelemetry/) | +| Current version | Build | Coverage | API doc | +| ---------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------- | +| [![Gem Version](https://img.shields.io/gem/v/sentry-ruby?label=sentry-ruby)](https://rubygems.org/gems/sentry-ruby) | [![Build Status](https://github.com/getsentry/sentry-ruby/actions/workflows/sentry_ruby_test.yml/badge.svg)](https://github.com/getsentry/sentry-ruby/actions/workflows/sentry_ruby_test.yml) | [![Coverage Status](https://img.shields.io/codecov/c/github/getsentry/sentry-ruby/master?logo=codecov)](https://codecov.io/gh/getsentry/sentry-ruby/branch/master) | [![API doc](https://img.shields.io/badge/API%20doc-rubydoc.info-blue)](https://www.rubydoc.info/gems/sentry-ruby) | +| [![Gem Version](https://img.shields.io/gem/v/sentry-rails?label=sentry-rails)](https://rubygems.org/gems/sentry-rails) | [![Build Status](https://github.com/getsentry/sentry-ruby/actions/workflows/sentry_rails_test.yml/badge.svg)](https://github.com/getsentry/sentry-ruby/actions/workflows/sentry_rails_test.yml) | [![Coverage Status](https://img.shields.io/codecov/c/github/getsentry/sentry-ruby/master?logo=codecov)](https://codecov.io/gh/getsentry/sentry-ruby/branch/master) | [![API doc](https://img.shields.io/badge/API%20doc-rubydoc.info-blue)](https://www.rubydoc.info/gems/sentry-rails) | +| [![Gem Version](https://img.shields.io/gem/v/sentry-sidekiq?label=sentry-sidekiq)](https://rubygems.org/gems/sentry-sidekiq) | [![Build Status](https://github.com/getsentry/sentry-ruby/actions/workflows/sentry_sidekiq_test.yml/badge.svg)](https://github.com/getsentry/sentry-ruby/actions/workflows/sentry_sidekiq_test.yml) | [![Coverage Status](https://img.shields.io/codecov/c/github/getsentry/sentry-ruby/master?logo=codecov)](https://codecov.io/gh/getsentry/sentry-ruby/branch/master) | [![API doc](https://img.shields.io/badge/API%20doc-rubydoc.info-blue)](https://www.rubydoc.info/gems/sentry-sidekiq) | +| [![Gem Version](https://img.shields.io/gem/v/sentry-delayed_job?label=sentry-delayed_job)](https://rubygems.org/gems/sentry-delayed_job) | [![Build Status](https://github.com/getsentry/sentry-ruby/actions/workflows/sentry_delayed_job_test.yml/badge.svg)](https://github.com/getsentry/sentry-ruby/actions/workflows/sentry_delayed_job_test.yml) | [![Coverage Status](https://img.shields.io/codecov/c/github/getsentry/sentry-ruby/master?logo=codecov)](https://codecov.io/gh/getsentry/sentry-ruby/branch/master) | [![API doc](https://img.shields.io/badge/API%20doc-rubydoc.info-blue)](https://www.rubydoc.info/gems/sentry-delayed_job) | +| [![Gem Version](https://img.shields.io/gem/v/sentry-resque?label=sentry-resque)](https://rubygems.org/gems/sentry-resque) | [![Build Status](https://github.com/getsentry/sentry-ruby/actions/workflows/sentry_resque_test.yml/badge.svg)](https://github.com/getsentry/sentry-ruby/actions/workflows/sentry_resque_test.yml) | [![Coverage Status](https://img.shields.io/codecov/c/github/getsentry/sentry-ruby/master?logo=codecov)](https://codecov.io/gh/getsentry/sentry-ruby/branch/master) | [![API doc](https://img.shields.io/badge/API%20doc-rubydoc.info-blue)](https://www.rubydoc.info/gems/sentry-resque) | +| [![Gem Version](https://img.shields.io/gem/v/sentry-opentelemetry?label=sentry-opentelemetry)](https://rubygems.org/gems/sentry-opentelemetry) | [![Build Status](https://github.com/getsentry/sentry-ruby/actions/workflows/sentry_opentelemetry_test.yml/badge.svg)](https://github.com/getsentry/sentry-ruby/actions/workflows/sentry_opentelemetry_test.yml) | [![Coverage Status](https://img.shields.io/codecov/c/github/getsentry/sentry-ruby/master?logo=codecov)](https://codecov.io/gh/getsentry/sentry-ruby/branch/master) | [![API doc](https://img.shields.io/badge/API%20doc-rubydoc.info-blue)](https://www.rubydoc.info/gems/sentry-opentelemetry) | @@ -103,6 +103,16 @@ To learn more about sampling transactions, please visit the [official documentat * [![Ruby docs](https://img.shields.io/badge/documentation-sentry.io-green.svg?label=ruby%20docs)](https://docs.sentry.io/platforms/ruby/) * [![Forum](https://img.shields.io/badge/forum-sentry-green.svg)](https://forum.sentry.io/c/sdks) -* [![Discord Chat](https://img.shields.io/discord/621778831602221064?logo=discord&logoColor=ffffff&color=7389D8)](https://discord.gg/PXa5Apfe7K) +* [![Discord Chat](https://img.shields.io/discord/621778831602221064?logo=discord&logoColor=ffffff&color=7389D8)](https://discord.gg/PXa5Apfe7K) * [![Stack Overflow](https://img.shields.io/badge/stack%20overflow-sentry-green.svg)](https://stackoverflow.com/questions/tagged/sentry) * [![Twitter Follow](https://img.shields.io/twitter/follow/getsentry?label=getsentry&style=social)](https://twitter.com/intent/follow?screen_name=getsentry) + +## Contributing to the SDK + +Please make sure to read the [CONTRIBUTING.md](https://github.com/getsentry/sentry-ruby/blob/master/CONTRIBUTING.md) before making a pull request. + +Thanks to everyone who has contributed to this project so far. + + + + diff --git a/sentry-ruby/bin/console b/sentry-ruby/bin/console index 81e352ff7..57a532604 100755 --- a/sentry-ruby/bin/console +++ b/sentry-ruby/bin/console @@ -1,6 +1,7 @@ #!/usr/bin/env ruby require "bundler/setup" +require "debug" require "sentry-ruby" # You can add fixtures and/or initialization code here to make experimenting diff --git a/sentry-ruby/lib/sentry-ruby.rb b/sentry-ruby/lib/sentry-ruby.rb index 439d83ce5..358a7c329 100644 --- a/sentry-ruby/lib/sentry-ruby.rb +++ b/sentry-ruby/lib/sentry-ruby.rb @@ -20,6 +20,7 @@ require "sentry/transaction" require "sentry/hub" require "sentry/background_worker" +require "sentry/threaded_periodic_worker" require "sentry/session_flusher" require "sentry/backpressure_monitor" require "sentry/cron/monitor_check_ins" @@ -210,6 +211,13 @@ def set_context(*args) get_current_scope.set_context(*args) end + # @!method add_attachment + # @!macro add_attachment + def add_attachment(**opts) + return unless initialized? + get_current_scope.add_attachment(**opts) + end + ##### Main APIs ##### # Initializes the SDK with given configuration. @@ -550,6 +558,15 @@ def get_trace_propagation_headers get_current_hub.get_trace_propagation_headers end + # Returns the a Hash containing sentry-trace and baggage. + # Can be either from the currently active span or the propagation context. + # + # @return [String] + def get_trace_propagation_meta + return '' unless initialized? + get_current_hub.get_trace_propagation_meta + end + # Continue an incoming trace from a rack env like hash. # # @param env [Hash] @@ -590,3 +607,5 @@ def utc_now require "sentry/net/http" require "sentry/redis" require "sentry/puma" +require "sentry/graphql" +require "sentry/faraday" diff --git a/sentry-ruby/lib/sentry/attachment.rb b/sentry-ruby/lib/sentry/attachment.rb new file mode 100644 index 000000000..f40971599 --- /dev/null +++ b/sentry-ruby/lib/sentry/attachment.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +module Sentry + class Attachment + PathNotFoundError = Class.new(StandardError) + + attr_reader :bytes, :filename, :path, :content_type + + def initialize(bytes: nil, filename: nil, content_type: nil, path: nil) + @bytes = bytes + @filename = infer_filename(filename, path) + @path = path + @content_type = content_type + end + + def to_envelope_headers + { type: 'attachment', filename: filename, content_type: content_type, length: payload.bytesize } + end + + def payload + @payload ||= if bytes + bytes + else + File.binread(path) + end + rescue Errno::ENOENT + raise PathNotFoundError, "Failed to read attachment file, file not found: #{path}" + end + + private + + def infer_filename(filename, path) + return filename if filename + + if path + File.basename(path) + else + raise ArgumentError, "filename or path is required" + end + end + end +end diff --git a/sentry-ruby/lib/sentry/backpressure_monitor.rb b/sentry-ruby/lib/sentry/backpressure_monitor.rb index 4b8695a5a..b3f5652c7 100644 --- a/sentry-ruby/lib/sentry/backpressure_monitor.rb +++ b/sentry-ruby/lib/sentry/backpressure_monitor.rb @@ -1,19 +1,13 @@ # frozen_string_literal: true module Sentry - class BackpressureMonitor - include LoggingHelper - + class BackpressureMonitor < ThreadedPeriodicWorker DEFAULT_INTERVAL = 10 MAX_DOWNSAMPLE_FACTOR = 10 def initialize(configuration, client, interval: DEFAULT_INTERVAL) - @interval = interval + super(configuration.logger, interval) @client = client - @logger = configuration.logger - - @thread = nil - @exited = false @healthy = true @downsample_factor = 0 @@ -47,29 +41,5 @@ def set_downsample_factor log_debug("[BackpressureMonitor] health check negative, downsampling with a factor of #{@downsample_factor}") end end - - def kill - log_debug("[BackpressureMonitor] killing monitor") - - @exited = true - @thread&.kill - end - - private - - def ensure_thread - return if @exited - return if @thread&.alive? - - @thread = Thread.new do - loop do - sleep(@interval) - run - end - end - rescue ThreadError - log_debug("[BackpressureMonitor] Thread creation failed") - @exited = true - end end end diff --git a/sentry-ruby/lib/sentry/client.rb b/sentry-ruby/lib/sentry/client.rb index 608c4e1d5..6176508b7 100644 --- a/sentry-ruby/lib/sentry/client.rb +++ b/sentry-ruby/lib/sentry/client.rb @@ -55,19 +55,29 @@ def capture_event(event, scope, hint = {}) event_type = event.is_a?(Event) ? event.type : event["type"] data_category = Envelope::Item.data_category(event_type) + + is_transaction = event.is_a?(TransactionEvent) + spans_before = is_transaction ? event.spans.size : 0 + event = scope.apply_to_event(event, hint) if event.nil? log_debug("Discarded event because one of the event processors returned nil") transport.record_lost_event(:event_processor, data_category) + transport.record_lost_event(:event_processor, 'span', num: spans_before + 1) if is_transaction return + elsif is_transaction + spans_delta = spans_before - event.spans.size + transport.record_lost_event(:event_processor, 'span', num: spans_delta) if spans_delta > 0 end if async_block = configuration.async dispatch_async_event(async_block, event, hint) elsif configuration.background_worker_threads != 0 && hint.fetch(:background, true) - queued = dispatch_background_event(event, hint) - transport.record_lost_event(:queue_overflow, data_category) unless queued + unless dispatch_background_event(event, hint) + transport.record_lost_event(:queue_overflow, data_category) + transport.record_lost_event(:queue_overflow, 'span', num: spans_before + 1) if is_transaction + end else send_event(event, hint) end @@ -168,6 +178,7 @@ def event_from_transaction(transaction) def send_event(event, hint = nil) event_type = event.is_a?(Event) ? event.type : event["type"] data_category = Envelope::Item.data_category(event_type) + spans_before = event.is_a?(TransactionEvent) ? event.spans.size : 0 if event_type != TransactionEvent::TYPE && configuration.before_send event = configuration.before_send.call(event, hint) @@ -184,8 +195,13 @@ def send_event(event, hint = nil) if event.nil? log_debug("Discarded event because before_send_transaction returned nil") - transport.record_lost_event(:before_send, data_category) + transport.record_lost_event(:before_send, 'transaction') + transport.record_lost_event(:before_send, 'span', num: spans_before + 1) return + else + spans_after = event.is_a?(TransactionEvent) ? event.spans.size : 0 + spans_delta = spans_before - spans_after + transport.record_lost_event(:before_send, 'span', num: spans_delta) if spans_delta > 0 end end @@ -196,6 +212,7 @@ def send_event(event, hint = nil) rescue => e log_error("Event sending failed", e, debug: configuration.debug) transport.record_lost_event(:network_error, data_category) + transport.record_lost_event(:network_error, 'span', num: spans_before + 1) if event.is_a?(TransactionEvent) raise end diff --git a/sentry-ruby/lib/sentry/configuration.rb b/sentry-ruby/lib/sentry/configuration.rb index a44fa21a1..480172a88 100644 --- a/sentry-ruby/lib/sentry/configuration.rb +++ b/sentry-ruby/lib/sentry/configuration.rb @@ -351,7 +351,7 @@ def add_post_initialization_callback(&block) def initialize self.app_dirs_pattern = nil self.debug = false - self.background_worker_threads = Concurrent.processor_count + self.background_worker_threads = (processor_count / 2.0).ceil self.background_worker_max_queue = BackgroundWorker::DEFAULT_MAX_QUEUE self.backtrace_cleanup_callback = nil self.max_breadcrumbs = BreadcrumbBuffer::DEFAULT_SIZE @@ -654,5 +654,10 @@ def run_post_initialization_callbacks instance_eval(&hook) end end + + def processor_count + available_processor_count = Concurrent.available_processor_count if Concurrent.respond_to?(:available_processor_count) + available_processor_count || Concurrent.processor_count + end end end diff --git a/sentry-ruby/lib/sentry/envelope.rb b/sentry-ruby/lib/sentry/envelope.rb index 96bb83beb..51220a062 100644 --- a/sentry-ruby/lib/sentry/envelope.rb +++ b/sentry-ruby/lib/sentry/envelope.rb @@ -21,7 +21,7 @@ def type # rate limits and client reports use the data_category rather than envelope item type def self.data_category(type) case type - when 'session', 'attachment', 'transaction', 'profile' then type + when 'session', 'attachment', 'transaction', 'profile', 'span' then type when 'sessions' then 'session' when 'check_in' then 'monitor' when 'statsd', 'metric_meta' then 'metric_bucket' diff --git a/sentry-ruby/lib/sentry/event.rb b/sentry-ruby/lib/sentry/event.rb index 97777a1c9..7fbd6172c 100644 --- a/sentry-ruby/lib/sentry/event.rb +++ b/sentry-ruby/lib/sentry/event.rb @@ -42,6 +42,9 @@ class Event # @return [Hash, nil] attr_accessor :dynamic_sampling_context + # @return [Array] + attr_accessor :attachments + # @param configuration [Configuration] # @param integration_meta [Hash, nil] # @param message [String, nil] @@ -57,6 +60,7 @@ def initialize(configuration:, integration_meta: nil, message: nil) @extra = {} @contexts = {} @tags = {} + @attachments = [] @fingerprint = [] @dynamic_sampling_context = nil @@ -104,9 +108,7 @@ def rack_env=(env) unless request || env.empty? add_request_interface(env) - if @send_default_pii - user[:ip_address] = calculate_real_ip_from_rack(env) - end + user[:ip_address] ||= calculate_real_ip_from_rack(env) if @send_default_pii if request_id = Utils::RequestId.read_from(env) tags[:request_id] = request_id diff --git a/sentry-ruby/lib/sentry/faraday.rb b/sentry-ruby/lib/sentry/faraday.rb new file mode 100644 index 000000000..05d5f45bd --- /dev/null +++ b/sentry-ruby/lib/sentry/faraday.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +module Sentry + module Faraday + OP_NAME = "http.client" + + module Connection + # Since there's no way to preconfigure Faraday connections and add our instrumentation + # by default, we need to extend the connection constructor and do it there + # + # @see https://lostisland.github.io/faraday/#/customization/index?id=configuration + def initialize(url = nil, options = nil) + super + + # Ensure that we attach instrumentation only if the adapter is not net/http + # because if is is, then the net/http instrumentation will take care of it + if builder.adapter.name != "Faraday::Adapter::NetHttp" + # Make sure that it's going to be the first middleware so that it can capture + # the entire request processing involving other middlewares + builder.insert(0, ::Faraday::Request::Instrumentation, name: OP_NAME, instrumenter: Instrumenter.new) + end + end + end + + class Instrumenter + SPAN_ORIGIN = "auto.http.faraday" + BREADCRUMB_CATEGORY = "http" + + include Utils::HttpTracing + + def instrument(op_name, env, &block) + return block.call unless Sentry.initialized? + + Sentry.with_child_span(op: op_name, start_timestamp: Sentry.utc_now.to_f, origin: SPAN_ORIGIN) do |sentry_span| + request_info = extract_request_info(env) + + if propagate_trace?(request_info[:url]) + set_propagation_headers(env[:request_headers]) + end + + res = block.call + response_status = res.status + + if record_sentry_breadcrumb? + record_sentry_breadcrumb(request_info, response_status) + end + + if sentry_span + set_span_info(sentry_span, request_info, response_status) + end + + res + end + end + + private + + def extract_request_info(env) + url = env[:url].scheme + "://" + env[:url].host + env[:url].path + result = { method: env[:method].to_s.upcase, url: url } + + if Sentry.configuration.send_default_pii + result[:query] = env[:url].query + result[:body] = env[:body] + end + + result + end + end + end +end + +Sentry.register_patch(:faraday) do + if defined?(::Faraday) + ::Faraday::Connection.prepend(Sentry::Faraday::Connection) + end +end diff --git a/sentry-ruby/lib/sentry/graphql.rb b/sentry-ruby/lib/sentry/graphql.rb new file mode 100644 index 000000000..17bb792c6 --- /dev/null +++ b/sentry-ruby/lib/sentry/graphql.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +Sentry.register_patch(:graphql) do |config| + if defined?(::GraphQL::Schema) && defined?(::GraphQL::Tracing::SentryTrace) && ::GraphQL::Schema.respond_to?(:trace_with) + ::GraphQL::Schema.trace_with(::GraphQL::Tracing::SentryTrace, set_transaction_name: true) + else + config.logger.warn(Sentry::LOGGER_PROGNAME) { 'You tried to enable the GraphQL integration but no GraphQL gem was detected. Make sure you have the `graphql` gem (>= 2.2.6) in your Gemfile.' } + end +end diff --git a/sentry-ruby/lib/sentry/hub.rb b/sentry-ruby/lib/sentry/hub.rb index a2e82445f..66d0b379a 100644 --- a/sentry-ruby/lib/sentry/hub.rb +++ b/sentry-ruby/lib/sentry/hub.rb @@ -193,7 +193,14 @@ def capture_event(event, **options, &block) elsif custom_scope = options[:scope] scope.update_from_scope(custom_scope) elsif !options.empty? - scope.update_from_options(**options) + unsupported_option_keys = scope.update_from_options(**options) + + unless unsupported_option_keys.empty? + configuration.log_debug <<~MSG + Options #{unsupported_option_keys} are not supported and will not be applied to the event. + You may want to set them under the `extra` option. + MSG + end end event = current_client.capture_event(event, scope, hint) @@ -279,6 +286,12 @@ def get_trace_propagation_headers headers end + def get_trace_propagation_meta + get_trace_propagation_headers.map do |k, v| + "" + end.join("\n") + end + def continue_trace(env, **options) configure_scope { |s| s.generate_propagation_context(env) } diff --git a/sentry-ruby/lib/sentry/metrics.rb b/sentry-ruby/lib/sentry/metrics.rb index e67fc769f..99bd9f7f1 100644 --- a/sentry-ruby/lib/sentry/metrics.rb +++ b/sentry-ruby/lib/sentry/metrics.rb @@ -15,6 +15,7 @@ module Metrics FRACTIONAL_UNITS = %w[ratio percent] OP_NAME = 'metric.timing' + SPAN_ORIGIN = 'auto.metric.timing' class << self def increment(key, value = 1.0, unit: 'none', tags: {}, timestamp: nil) @@ -37,7 +38,7 @@ def timing(key, unit: 'second', tags: {}, timestamp: nil, &block) return unless block_given? return yield unless DURATION_UNITS.include?(unit) - result, value = Sentry.with_child_span(op: OP_NAME, description: key) do |span| + result, value = Sentry.with_child_span(op: OP_NAME, description: key, origin: SPAN_ORIGIN) do |span| tags.each { |k, v| span.set_tag(k, v.is_a?(Array) ? v.join(', ') : v.to_s) } if span start = Timing.send(unit.to_sym) diff --git a/sentry-ruby/lib/sentry/metrics/aggregator.rb b/sentry-ruby/lib/sentry/metrics/aggregator.rb index e02def2c0..45ffbd687 100644 --- a/sentry-ruby/lib/sentry/metrics/aggregator.rb +++ b/sentry-ruby/lib/sentry/metrics/aggregator.rb @@ -2,9 +2,7 @@ module Sentry module Metrics - class Aggregator - include LoggingHelper - + class Aggregator < ThreadedPeriodicWorker FLUSH_INTERVAL = 5 ROLLUP_IN_SECONDS = 10 @@ -36,8 +34,8 @@ class Aggregator attr_reader :client, :thread, :buckets, :flush_shift, :code_locations def initialize(configuration, client) + super(configuration.logger, FLUSH_INTERVAL) @client = client - @logger = configuration.logger @before_emit = configuration.metrics.before_emit @enable_code_locations = configuration.metrics.enable_code_locations @stacktrace_builder = configuration.stacktrace_builder @@ -46,8 +44,6 @@ def initialize(configuration, client) @default_tags['release'] = configuration.release if configuration.release @default_tags['environment'] = configuration.environment if configuration.environment - @thread = nil - @exited = false @mutex = Mutex.new # a nested hash of timestamp -> bucket keys -> Metric instance @@ -120,34 +116,10 @@ def flush(force: false) @client.capture_envelope(envelope) end - def kill - log_debug('[Metrics::Aggregator] killing thread') - - @exited = true - @thread&.kill - end + alias_method :run, :flush private - def ensure_thread - return false if @exited - return true if @thread&.alive? - - @thread = Thread.new do - loop do - # TODO-neel-metrics use event for force flush later - sleep(FLUSH_INTERVAL) - flush - end - end - - true - rescue ThreadError - log_debug('[Metrics::Aggregator] thread creation failed') - @exited = true - false - end - # important to sort for key consistency def serialize_tags(tags) tags.flat_map do |k, v| diff --git a/sentry-ruby/lib/sentry/net/http.rb b/sentry-ruby/lib/sentry/net/http.rb index 9480ec081..c769b4c3d 100644 --- a/sentry-ruby/lib/sentry/net/http.rb +++ b/sentry-ruby/lib/sentry/net/http.rb @@ -2,12 +2,16 @@ require "net/http" require "resolv" +require "sentry/utils/http_tracing" module Sentry # @api private module Net module HTTP + include Utils::HttpTracing + OP_NAME = "http.client" + SPAN_ORIGIN = "auto.http.net_http" BREADCRUMB_CATEGORY = "net.http" # To explain how the entire thing works, we need to know how the original Net::HTTP#request works @@ -20,8 +24,7 @@ module HTTP # req['connection'] ||= 'close' # return request(req, body, &block) # <- request will be called for the second time from the first call # } - # end - # # ..... + # end # ..... # end # ``` # @@ -30,47 +33,29 @@ def request(req, body = nil, &block) return super unless started? && Sentry.initialized? return super if from_sentry_sdk? - Sentry.with_child_span(op: OP_NAME, start_timestamp: Sentry.utc_now.to_f) do |sentry_span| + Sentry.with_child_span(op: OP_NAME, start_timestamp: Sentry.utc_now.to_f, origin: SPAN_ORIGIN) do |sentry_span| request_info = extract_request_info(req) - if propagate_trace?(request_info[:url], Sentry.configuration) + if propagate_trace?(request_info[:url]) set_propagation_headers(req) end - super.tap do |res| - record_sentry_breadcrumb(request_info, res) + res = super + response_status = res.code.to_i - if sentry_span - sentry_span.set_description("#{request_info[:method]} #{request_info[:url]}") - sentry_span.set_data(Span::DataConventions::URL, request_info[:url]) - sentry_span.set_data(Span::DataConventions::HTTP_METHOD, request_info[:method]) - sentry_span.set_data(Span::DataConventions::HTTP_QUERY, request_info[:query]) if request_info[:query] - sentry_span.set_data(Span::DataConventions::HTTP_STATUS_CODE, res.code.to_i) - end + if record_sentry_breadcrumb? + record_sentry_breadcrumb(request_info, response_status) end - end - end - private + if sentry_span + set_span_info(sentry_span, request_info, response_status) + end - def set_propagation_headers(req) - Sentry.get_trace_propagation_headers&.each { |k, v| req[k] = v } + res + end end - def record_sentry_breadcrumb(request_info, res) - return unless Sentry.initialized? && Sentry.configuration.breadcrumbs_logger.include?(:http_logger) - - crumb = Sentry::Breadcrumb.new( - level: :info, - category: BREADCRUMB_CATEGORY, - type: :info, - data: { - status: res.code.to_i, - **request_info - } - ) - Sentry.add_breadcrumb(crumb) - end + private def from_sentry_sdk? dsn = Sentry.configuration.dsn @@ -93,12 +78,6 @@ def extract_request_info(req) result end - - def propagate_trace?(url, configuration) - url && - configuration.propagate_traces && - configuration.trace_propagation_targets.any? { |target| url.match?(target) } - end end end end diff --git a/sentry-ruby/lib/sentry/rack/capture_exceptions.rb b/sentry-ruby/lib/sentry/rack/capture_exceptions.rb index 99a84a0fe..653188e10 100644 --- a/sentry-ruby/lib/sentry/rack/capture_exceptions.rb +++ b/sentry-ruby/lib/sentry/rack/capture_exceptions.rb @@ -5,6 +5,7 @@ module Rack class CaptureExceptions ERROR_EVENT_ID_KEY = "sentry.error_event_id" MECHANISM_TYPE = "rack" + SPAN_ORIGIN = "auto.http.rack" def initialize(app) @app = app @@ -63,7 +64,13 @@ def capture_exception(exception, env) end def start_transaction(env, scope) - options = { name: scope.transaction_name, source: scope.transaction_source, op: transaction_op } + options = { + name: scope.transaction_name, + source: scope.transaction_source, + op: transaction_op, + origin: SPAN_ORIGIN + } + transaction = Sentry.continue_trace(env, **options) Sentry.start_transaction(transaction: transaction, custom_sampling_context: { env: env }, **options) end diff --git a/sentry-ruby/lib/sentry/redis.rb b/sentry-ruby/lib/sentry/redis.rb index 3c3832321..050b85346 100644 --- a/sentry-ruby/lib/sentry/redis.rb +++ b/sentry-ruby/lib/sentry/redis.rb @@ -4,6 +4,7 @@ module Sentry # @api private class Redis OP_NAME = "db.redis" + SPAN_ORIGIN = "auto.db.redis" LOGGER_NAME = :redis_logger def initialize(commands, host, port, db) @@ -13,7 +14,7 @@ def initialize(commands, host, port, db) def instrument return yield unless Sentry.initialized? - Sentry.with_child_span(op: OP_NAME, start_timestamp: Sentry.utc_now.to_f) do |span| + Sentry.with_child_span(op: OP_NAME, start_timestamp: Sentry.utc_now.to_f, origin: SPAN_ORIGIN) do |span| yield.tap do record_breadcrumb diff --git a/sentry-ruby/lib/sentry/scope.rb b/sentry-ruby/lib/sentry/scope.rb index 6e78c6e04..73e873f43 100644 --- a/sentry-ruby/lib/sentry/scope.rb +++ b/sentry-ruby/lib/sentry/scope.rb @@ -2,6 +2,7 @@ require "sentry/breadcrumb_buffer" require "sentry/propagation_context" +require "sentry/attachment" require "etc" module Sentry @@ -9,8 +10,8 @@ class Scope include ArgumentCheckingHelper ATTRIBUTES = [ - :transaction_names, - :transaction_sources, + :transaction_name, + :transaction_source, :contexts, :extra, :tags, @@ -22,6 +23,7 @@ class Scope :rack_env, :span, :session, + :attachments, :propagation_context ] @@ -55,6 +57,7 @@ def apply_to_event(event, hint = nil) event.level = level event.breadcrumbs = breadcrumbs event.rack_env = rack_env if rack_env + event.attachments = attachments end if span @@ -96,12 +99,13 @@ def dup copy.extra = extra.deep_dup copy.tags = tags.deep_dup copy.user = user.deep_dup - copy.transaction_names = transaction_names.dup - copy.transaction_sources = transaction_sources.dup + copy.transaction_name = transaction_name.dup + copy.transaction_source = transaction_source.dup copy.fingerprint = fingerprint.deep_dup copy.span = span.deep_dup copy.session = session.deep_dup copy.propagation_context = propagation_context.deep_dup + copy.attachments = attachments.dup copy end @@ -114,11 +118,12 @@ def update_from_scope(scope) self.extra = scope.extra self.tags = scope.tags self.user = scope.user - self.transaction_names = scope.transaction_names - self.transaction_sources = scope.transaction_sources + self.transaction_name = scope.transaction_name + self.transaction_source = scope.transaction_source self.fingerprint = scope.fingerprint self.span = scope.span self.propagation_context = scope.propagation_context + self.attachments = scope.attachments end # Updates the scope's data from the given options. @@ -128,14 +133,17 @@ def update_from_scope(scope) # @param user [Hash] # @param level [String, Symbol] # @param fingerprint [Array] - # @return [void] + # @param attachments [Array] + # @return [Array] def update_from_options( contexts: nil, extra: nil, tags: nil, user: nil, level: nil, - fingerprint: nil + fingerprint: nil, + attachments: nil, + **options ) self.contexts.merge!(contexts) if contexts self.extra.merge!(extra) if extra @@ -143,6 +151,9 @@ def update_from_options( self.user = user if user self.level = level if level self.fingerprint = fingerprint if fingerprint + + # Returns unsupported option keys so we can notify users. + options.keys end # Sets the scope's rack_env attribute. @@ -227,8 +238,8 @@ def set_level(level) # @param transaction_name [String] # @return [void] def set_transaction_name(transaction_name, source: :custom) - @transaction_names << transaction_name - @transaction_sources << source + @transaction_name = transaction_name + @transaction_source = source end # Sets the currently active session on the scope. @@ -238,20 +249,6 @@ def set_session(session) @session = session end - # Returns current transaction name. - # The "transaction" here does not refer to `Transaction` objects. - # @return [String, nil] - def transaction_name - @transaction_names.last - end - - # Returns current transaction source. - # The "transaction" here does not refer to `Transaction` objects. - # @return [String, nil] - def transaction_source - @transaction_sources.last - end - # These are high cardinality and thus bad. # @return [Boolean] def transaction_source_low_quality? @@ -293,6 +290,12 @@ def generate_propagation_context(env = nil) @propagation_context = PropagationContext.new(self, env) end + # Add a new attachment to the scope. + def add_attachment(**opts) + attachments << (attachment = Attachment.new(**opts)) + attachment + end + protected # for duplicating scopes internally @@ -307,12 +310,13 @@ def set_default_value @user = {} @level = :error @fingerprint = [] - @transaction_names = [] - @transaction_sources = [] + @transaction_name = nil + @transaction_source = nil @event_processors = [] @rack_env = {} @span = nil @session = nil + @attachments = [] generate_propagation_context set_new_breadcrumb_buffer end diff --git a/sentry-ruby/lib/sentry/session_flusher.rb b/sentry-ruby/lib/sentry/session_flusher.rb index 256320ca7..5971cfc59 100644 --- a/sentry-ruby/lib/sentry/session_flusher.rb +++ b/sentry-ruby/lib/sentry/session_flusher.rb @@ -1,19 +1,15 @@ # frozen_string_literal: true module Sentry - class SessionFlusher - include LoggingHelper - + class SessionFlusher < ThreadedPeriodicWorker FLUSH_INTERVAL = 60 def initialize(configuration, client) - @thread = nil - @exited = false + super(configuration.logger, FLUSH_INTERVAL) @client = client @pending_aggregates = {} @release = configuration.release @environment = configuration.environment - @logger = configuration.logger log_debug("[Sessions] Sessions won't be captured without a valid release") unless @release end @@ -25,30 +21,18 @@ def flush @pending_aggregates = {} end + alias_method :run, :flush + def add_session(session) - return if @exited return unless @release - begin - ensure_thread - rescue ThreadError - log_debug("Session flusher thread creation failed") - @exited = true - return - end + return unless ensure_thread return unless Session::AGGREGATE_STATUSES.include?(session.status) @pending_aggregates[session.aggregation_key] ||= init_aggregates(session.aggregation_key) @pending_aggregates[session.aggregation_key][session.status] += 1 end - def kill - log_debug("Killing session flusher") - - @exited = true - @thread&.kill - end - private def init_aggregates(aggregation_key) @@ -70,16 +54,5 @@ def pending_envelope def attrs { release: @release, environment: @environment } end - - def ensure_thread - return if @thread&.alive? - - @thread = Thread.new do - loop do - sleep(FLUSH_INTERVAL) - flush - end - end - end end end diff --git a/sentry-ruby/lib/sentry/span.rb b/sentry-ruby/lib/sentry/span.rb index 69374b496..da7232d9a 100644 --- a/sentry-ruby/lib/sentry/span.rb +++ b/sentry-ruby/lib/sentry/span.rb @@ -39,6 +39,11 @@ module DataConventions # Recommended: If different than server.port. # Example: 16456 SERVER_SOCKET_PORT = "server.socket.port" + + FILEPATH = "code.filepath" + LINENO = "code.lineno" + FUNCTION = "code.function" + NAMESPACE = "code.namespace" end STATUS_MAP = { @@ -55,6 +60,8 @@ module DataConventions 504 => "deadline_exceeded" } + DEFAULT_SPAN_ORIGIN = "manual" + # An uuid that can be used to identify a trace. # @return [String] attr_reader :trace_id @@ -88,6 +95,9 @@ module DataConventions # Span data # @return [Hash] attr_reader :data + # Span origin that tracks what kind of instrumentation created a span + # @return [String] + attr_reader :origin # The SpanRecorder the current span belongs to. # SpanRecorder holds all spans under the same Transaction object (including the Transaction itself). @@ -109,7 +119,8 @@ def initialize( parent_span_id: nil, sampled: nil, start_timestamp: nil, - timestamp: nil + timestamp: nil, + origin: nil ) @trace_id = trace_id || SecureRandom.uuid.delete("-") @span_id = span_id || SecureRandom.uuid.delete("-").slice(0, 16) @@ -123,6 +134,7 @@ def initialize( @status = status @data = {} @tags = {} + @origin = origin || DEFAULT_SPAN_ORIGIN end # Finishes the span by adding a timestamp. @@ -160,7 +172,8 @@ def to_hash op: @op, status: @status, tags: @tags, - data: @data + data: @data, + origin: @origin } summary = metrics_summary @@ -178,7 +191,9 @@ def get_trace_context parent_span_id: @parent_span_id, description: @description, op: @op, - status: @status + status: @status, + origin: @origin, + data: @data } end @@ -275,6 +290,12 @@ def set_tag(key, value) @tags[key] = value end + # Sets the origin of the span. + # @param origin [String] + def set_origin(origin) + @origin = origin + end + # Collects gauge metrics on the span for metric summaries. def metrics_local_aggregator @metrics_local_aggregator ||= Sentry::Metrics::LocalAggregator.new diff --git a/sentry-ruby/lib/sentry/test_helper.rb b/sentry-ruby/lib/sentry/test_helper.rb index 75a6f29bd..7db34cbd2 100644 --- a/sentry-ruby/lib/sentry/test_helper.rb +++ b/sentry-ruby/lib/sentry/test_helper.rb @@ -20,7 +20,7 @@ def setup_sentry_test(&block) # set transport to DummyTransport, so we can easily intercept the captured events dummy_config.transport.transport_class = Sentry::DummyTransport # make sure SDK allows sending under the current environment - dummy_config.enabled_environments << dummy_config.environment unless dummy_config.enabled_environments.include?(dummy_config.environment) + dummy_config.enabled_environments += [dummy_config.environment] unless dummy_config.enabled_environments.include?(dummy_config.environment) # disble async event sending dummy_config.background_worker_threads = 0 @@ -50,6 +50,7 @@ def teardown_sentry_test if Sentry.get_current_hub.instance_variable_get(:@stack).size > 1 Sentry.get_current_hub.pop_scope end + Sentry::Scope.global_event_processors.clear end # @return [Transport] diff --git a/sentry-ruby/lib/sentry/threaded_periodic_worker.rb b/sentry-ruby/lib/sentry/threaded_periodic_worker.rb new file mode 100644 index 000000000..cf1272083 --- /dev/null +++ b/sentry-ruby/lib/sentry/threaded_periodic_worker.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +module Sentry + class ThreadedPeriodicWorker + include LoggingHelper + + def initialize(logger, internal) + @thread = nil + @exited = false + @interval = internal + @logger = logger + end + + def ensure_thread + return false if @exited + return true if @thread&.alive? + + @thread = Thread.new do + loop do + sleep(@interval) + run + end + end + + true + rescue ThreadError + log_debug("[#{self.class.name}] thread creation failed") + @exited = true + false + end + + def kill + log_debug("[#{self.class.name}] thread killed") + + @exited = true + @thread&.kill + end + end +end diff --git a/sentry-ruby/lib/sentry/transaction.rb b/sentry-ruby/lib/sentry/transaction.rb index 456b49e74..89d169512 100644 --- a/sentry-ruby/lib/sentry/transaction.rb +++ b/sentry-ruby/lib/sentry/transaction.rb @@ -266,6 +266,7 @@ def finish(hub: nil, end_timestamp: nil) is_backpressure = Sentry.backpressure_monitor&.downsample_factor&.positive? reason = is_backpressure ? :backpressure : :sample_rate hub.current_client.transport.record_lost_event(reason, 'transaction') + hub.current_client.transport.record_lost_event(reason, 'span') end end diff --git a/sentry-ruby/lib/sentry/transport.rb b/sentry-ruby/lib/sentry/transport.rb index 326dcbf35..39a993fe5 100644 --- a/sentry-ruby/lib/sentry/transport.rb +++ b/sentry-ruby/lib/sentry/transport.rb @@ -61,7 +61,7 @@ def send_envelope(envelope) data, serialized_items = serialize_envelope(envelope) if data - log_info("[Transport] Sending envelope with items [#{serialized_items.map(&:type).join(', ')}] #{envelope.event_id} to Sentry") + log_debug("[Transport] Sending envelope with items [#{serialized_items.map(&:type).join(', ')}] #{envelope.event_id} to Sentry") send_data(data) end end @@ -145,17 +145,23 @@ def envelope_from_event(event) ) end + if event.is_a?(Event) && event.attachments.any? + event.attachments.each do |attachment| + envelope.add_item(attachment.to_envelope_headers, attachment.payload) + end + end + client_report_headers, client_report_payload = fetch_pending_client_report envelope.add_item(client_report_headers, client_report_payload) if client_report_headers envelope end - def record_lost_event(reason, data_category) + def record_lost_event(reason, data_category, num: 1) return unless @send_client_reports return unless CLIENT_REPORT_REASONS.include?(reason) - @discarded_events[[reason, data_category]] += 1 + @discarded_events[[reason, data_category]] += num end def flush diff --git a/sentry-ruby/lib/sentry/utils/http_tracing.rb b/sentry-ruby/lib/sentry/utils/http_tracing.rb new file mode 100644 index 000000000..abf6a9141 --- /dev/null +++ b/sentry-ruby/lib/sentry/utils/http_tracing.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module Sentry + module Utils + module HttpTracing + def set_span_info(sentry_span, request_info, response_status) + sentry_span.set_description("#{request_info[:method]} #{request_info[:url]}") + sentry_span.set_data(Span::DataConventions::URL, request_info[:url]) + sentry_span.set_data(Span::DataConventions::HTTP_METHOD, request_info[:method]) + sentry_span.set_data(Span::DataConventions::HTTP_QUERY, request_info[:query]) if request_info[:query] + sentry_span.set_data(Span::DataConventions::HTTP_STATUS_CODE, response_status) + end + + def set_propagation_headers(req) + Sentry.get_trace_propagation_headers&.each { |k, v| req[k] = v } + end + + def record_sentry_breadcrumb(request_info, response_status) + crumb = Sentry::Breadcrumb.new( + level: :info, + category: self.class::BREADCRUMB_CATEGORY, + type: :info, + data: { status: response_status, **request_info } + ) + + Sentry.add_breadcrumb(crumb) + end + + def record_sentry_breadcrumb? + Sentry.initialized? && Sentry.configuration.breadcrumbs_logger.include?(:http_logger) + end + + def propagate_trace?(url) + url && + Sentry.initialized? && + Sentry.configuration.propagate_traces && + Sentry.configuration.trace_propagation_targets.any? { |target| url.match?(target) } + end + end + end +end diff --git a/sentry-ruby/lib/sentry/utils/logging_helper.rb b/sentry-ruby/lib/sentry/utils/logging_helper.rb index 8c3166892..34a589e8f 100644 --- a/sentry-ruby/lib/sentry/utils/logging_helper.rb +++ b/sentry-ruby/lib/sentry/utils/logging_helper.rb @@ -11,10 +11,6 @@ def log_error(message, exception, debug: false) end end - def log_info(message) - @logger.info(LOGGER_PROGNAME) { message } - end - def log_debug(message) @logger.debug(LOGGER_PROGNAME) { message } end diff --git a/sentry-ruby/lib/sentry/version.rb b/sentry-ruby/lib/sentry/version.rb index 6829dc652..a06f8cebc 100644 --- a/sentry-ruby/lib/sentry/version.rb +++ b/sentry-ruby/lib/sentry/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module Sentry - VERSION = "5.17.3" + VERSION = "5.19.0" end diff --git a/sentry-ruby/sentry-ruby.gemspec b/sentry-ruby/sentry-ruby.gemspec index 77e9711ec..52cbcc180 100644 --- a/sentry-ruby/sentry-ruby.gemspec +++ b/sentry-ruby/sentry-ruby.gemspec @@ -7,19 +7,25 @@ Gem::Specification.new do |spec| spec.description = spec.summary = "A gem that provides a client interface for the Sentry error logger" spec.email = "accounts@sentry.io" spec.license = 'MIT' - spec.homepage = "https://github.com/getsentry/sentry-ruby" spec.platform = Gem::Platform::RUBY spec.required_ruby_version = '>= 2.4' spec.extra_rdoc_files = ["README.md", "LICENSE.txt"] spec.files = `git ls-files | grep -Ev '^(spec|benchmarks|examples)'`.split("\n") - spec.metadata["homepage_uri"] = spec.homepage - spec.metadata["source_code_uri"] = spec.homepage - spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/master/CHANGELOG.md" + github_root_uri = 'https://github.com/getsentry/sentry-ruby' + spec.homepage = "#{github_root_uri}/tree/#{spec.version}/#{spec.name}" + + spec.metadata = { + "homepage_uri" => spec.homepage, + "source_code_uri" => spec.homepage, + "changelog_uri" => "#{github_root_uri}/blob/#{spec.version}/CHANGELOG.md", + "bug_tracker_uri" => "#{github_root_uri}/issues", + "documentation_uri" => "http://www.rubydoc.info/gems/#{spec.name}/#{spec.version}" + } spec.require_paths = ["lib"] - spec.add_dependency "concurrent-ruby", '~> 1.0', '>= 1.0.2' + spec.add_dependency "concurrent-ruby", "~> 1.0", ">= 1.0.2" spec.add_dependency "bigdecimal" end diff --git a/sentry-ruby/spec/fixtures/attachment.txt b/sentry-ruby/spec/fixtures/attachment.txt new file mode 100644 index 000000000..3b18e512d --- /dev/null +++ b/sentry-ruby/spec/fixtures/attachment.txt @@ -0,0 +1 @@ +hello world diff --git a/sentry-ruby/spec/sentry/background_worker_spec.rb b/sentry-ruby/spec/sentry/background_worker_spec.rb index cefbc398b..e1b900094 100644 --- a/sentry-ruby/spec/sentry/background_worker_spec.rb +++ b/sentry-ruby/spec/sentry/background_worker_spec.rb @@ -28,10 +28,11 @@ context "when config.background_worker_threads is set" do it "initializes a background worker with correct number of threads and queue size" do + configuration.background_worker_threads = 4 worker = described_class.new(configuration) expect(worker.max_queue).to eq(30) - expect(worker.number_of_threads).to eq(Concurrent.processor_count) + expect(worker.number_of_threads).to eq(4) end end diff --git a/sentry-ruby/spec/sentry/backpressure_monitor_spec.rb b/sentry-ruby/spec/sentry/backpressure_monitor_spec.rb index 6f18d605f..1419cffdb 100644 --- a/sentry-ruby/spec/sentry/backpressure_monitor_spec.rb +++ b/sentry-ruby/spec/sentry/backpressure_monitor_spec.rb @@ -55,7 +55,7 @@ it 'logs error' do subject.healthy? - expect(string_io.string).to match(/\[BackpressureMonitor\] Thread creation failed/) + expect(string_io.string).to include("[#{described_class.name}] thread creation failed") end end @@ -111,7 +111,7 @@ subject.healthy? expect(subject.instance_variable_get(:@thread)).to receive(:kill) subject.kill - expect(string_io.string).to match(/\[BackpressureMonitor\] killing monitor/) + expect(string_io.string).to include("[#{described_class.name}] thread killed") end end end diff --git a/sentry-ruby/spec/sentry/client/event_sending_spec.rb b/sentry-ruby/spec/sentry/client/event_sending_spec.rb index 86098a469..b1b1a041c 100644 --- a/sentry-ruby/spec/sentry/client/event_sending_spec.rb +++ b/sentry-ruby/spec/sentry/client/event_sending_spec.rb @@ -8,16 +8,23 @@ config.transport.transport_class = Sentry::DummyTransport end end - subject { Sentry::Client.new(configuration) } + subject(:client) { Sentry::Client.new(configuration) } let(:hub) do - Sentry::Hub.new(subject, Sentry::Scope.new) + Sentry::Hub.new(client, Sentry::Scope.new) end + let(:transaction) do + transaction = Sentry::Transaction.new(name: "test transaction", op: "rack.request", hub: hub) + 5.times { |i| transaction.with_child_span(description: "span_#{i}") { } } + transaction + end + let(:transaction_event) { client.event_from_transaction(transaction) } + describe "#capture_event" do let(:message) { "Test message" } let(:scope) { Sentry::Scope.new } - let(:event) { subject.event_from_message(message) } + let(:event) { client.event_from_message(message) } context "with sample_rate set" do before do @@ -28,26 +35,23 @@ context "with Event" do it "sends the event when it's sampled" do allow(Random).to receive(:rand).and_return(0.49) - subject.capture_event(event, scope) - expect(subject.transport.events.count).to eq(1) + client.capture_event(event, scope) + expect(client.transport.events.count).to eq(1) end it "doesn't send the event when it's not sampled" do allow(Random).to receive(:rand).and_return(0.51) - subject.capture_event(event, scope) - expect(subject.transport).to have_recorded_lost_event(:sample_rate, 'error') - expect(subject.transport.events.count).to eq(0) + client.capture_event(event, scope) + expect(client.transport).to have_recorded_lost_event(:sample_rate, 'error') + expect(client.transport.events.count).to eq(0) end end context "with TransactionEvent" do it "ignores the sampling" do - transaction_event = subject.event_from_transaction(Sentry::Transaction.new(hub: hub)) allow(Random).to receive(:rand).and_return(0.51) - - subject.capture_event(transaction_event, scope) - - expect(subject.transport.events.count).to eq(1) + client.capture_event(transaction_event, scope) + expect(client.transport.events.count).to eq(1) end end end @@ -55,7 +59,7 @@ context 'with config.async set' do let(:async_block) do lambda do |event| - subject.send_event(event) + client.send_event(event) end end @@ -69,10 +73,10 @@ it "executes the given block" do expect(async_block).to receive(:call).and_call_original - returned = subject.capture_event(event, scope) + returned = client.capture_event(event, scope) expect(returned).to be_a(Sentry::ErrorEvent) - expect(subject.transport.events.first).to eq(event.to_json_compatible) + expect(client.transport.events.first).to eq(event.to_json_compatible) end it "doesn't call the async block if not allow sending events" do @@ -80,7 +84,7 @@ expect(async_block).not_to receive(:call) - returned = subject.capture_event(event, scope) + returned = client.capture_event(event, scope) expect(returned).to eq(nil) end @@ -88,12 +92,12 @@ context "with to json conversion failed" do let(:logger) { ::Logger.new(string_io) } let(:string_io) { StringIO.new } - let(:event) { subject.event_from_message("Bad data '\x80\xF8'") } + let(:event) { client.event_from_message("Bad data '\x80\xF8'") } it "does not mask the exception" do configuration.logger = logger - subject.capture_event(event, scope) + client.capture_event(event, scope) expect(string_io.string).to include("Converting event (#{event.event_id}) to JSON compatible hash failed: source sequence is illegal/malformed utf-8") end @@ -103,10 +107,10 @@ let(:async_block) { nil } it "doesn't cause any issue" do - returned = subject.capture_event(event, scope, { background: false }) + returned = client.capture_event(event, scope, { background: false }) expect(returned).to be_a(Sentry::ErrorEvent) - expect(subject.transport.events.first).to eq(event) + expect(client.transport.events.first).to eq(event) end end @@ -114,17 +118,17 @@ let(:async_block) do lambda do |event, hint| event["tags"]["hint"] = hint - subject.send_event(event) + client.send_event(event) end end it "serializes hint and supplies it as the second argument" do expect(configuration.async).to receive(:call).and_call_original - returned = subject.capture_event(event, scope, { foo: "bar" }) + returned = client.capture_event(event, scope, { foo: "bar" }) expect(returned).to be_a(Sentry::ErrorEvent) - event = subject.transport.events.first + event = client.transport.events.first expect(event.dig("tags", "hint")).to eq({ "foo" => "bar" }) end end @@ -140,20 +144,20 @@ end it "sends events asynchronously" do - subject.capture_event(event, scope) + client.capture_event(event, scope) - expect(subject.transport.events.count).to eq(0) + expect(client.transport.events.count).to eq(0) sleep(0.2) - expect(subject.transport.events.count).to eq(1) + expect(client.transport.events.count).to eq(1) end context "with hint: { background: false }" do it "sends the event immediately" do - subject.capture_event(event, scope, { background: false }) + client.capture_event(event, scope, { background: false }) - expect(subject.transport.events.count).to eq(1) + expect(client.transport.events.count).to eq(1) end end @@ -161,48 +165,57 @@ it "sends the event immediately" do configuration.background_worker_threads = 0 - subject.capture_event(event, scope) + client.capture_event(event, scope) - expect(subject.transport.events.count).to eq(1) + expect(client.transport.events.count).to eq(1) end end - it "records queue overflow" do + it "records queue overflow for error event" do + allow(Sentry.background_worker).to receive(:perform).and_return(false) + + client.capture_event(event, scope) + expect(client.transport).to have_recorded_lost_event(:queue_overflow, 'error') + + expect(client.transport.events.count).to eq(0) + sleep(0.2) + expect(client.transport.events.count).to eq(0) + end + + it "records queue overflow for transaction event with span counts" do allow(Sentry.background_worker).to receive(:perform).and_return(false) - subject.capture_event(event, scope) - expect(subject.transport).to have_recorded_lost_event(:queue_overflow, 'error') + client.capture_event(transaction_event, scope) + expect(client.transport).to have_recorded_lost_event(:queue_overflow, 'transaction') + expect(client.transport).to have_recorded_lost_event(:queue_overflow, 'span', num: 6) - expect(subject.transport.events.count).to eq(0) + expect(client.transport.events.count).to eq(0) sleep(0.2) - expect(subject.transport.events.count).to eq(0) + expect(client.transport.events.count).to eq(0) end end end describe "#send_event" do let(:event_object) do - subject.event_from_exception(ZeroDivisionError.new("divided by 0")) - end - let(:transaction_event_object) do - subject.event_from_transaction(Sentry::Transaction.new(hub: hub)) + client.event_from_exception(ZeroDivisionError.new("divided by 0")) end shared_examples "Event in send_event" do context "when there's an exception" do before do - expect(subject.transport).to receive(:send_event).and_raise(Sentry::ExternalError.new("networking error")) + expect(client.transport).to receive(:send_event).and_raise(Sentry::ExternalError.new("networking error")) end it "raises the error" do expect do - subject.send_event(event) + client.send_event(event) end.to raise_error(Sentry::ExternalError, "networking error") end end it "sends data through the transport" do - expect(subject.transport).to receive(:send_event).with(event) - subject.send_event(event) + expect(client.transport).to receive(:send_event).with(event) + client.send_event(event) end it "applies before_send callback before sending the event" do @@ -216,7 +229,7 @@ event end - subject.send_event(event) + client.send_event(event) if event.is_a?(Sentry::Event) expect(event.tags[:called]).to eq(true) @@ -231,7 +244,7 @@ configuration.before_send_transaction = dbl expect(dbl).not_to receive(:call) - subject.send_event(event) + client.send_event(event) end end @@ -245,7 +258,7 @@ shared_examples "TransactionEvent in send_event" do it "sends data through the transport" do - subject.send_event(event) + client.send_event(event) end it "doesn't apply before_send to TransactionEvent" do @@ -253,7 +266,7 @@ raise "shouldn't trigger me" end - subject.send_event(event) + client.send_event(event) end it "applies before_send_transaction callback before sending the event" do @@ -267,7 +280,7 @@ event end - subject.send_event(event) + client.send_event(event) if event.is_a?(Sentry::Event) expect(event.tags[:called]).to eq(true) @@ -278,11 +291,11 @@ end it_behaves_like "TransactionEvent in send_event" do - let(:event) { transaction_event_object } + let(:event) { transaction_event } end it_behaves_like "TransactionEvent in send_event" do - let(:event) { transaction_event_object.to_json_compatible } + let(:event) { transaction_event.to_json_compatible } end end @@ -300,7 +313,7 @@ let(:message) { "Test message" } let(:scope) { Sentry::Scope.new } - let(:event) { subject.event_from_message(message) } + let(:event) { client.event_from_message(message) } describe "#capture_event" do around do |example| @@ -317,11 +330,35 @@ end it "discards the event and logs a info" do - expect(subject.capture_event(event, scope)).to be_nil + expect(client.capture_event(event, scope)).to be_nil - expect(subject.transport).to have_recorded_lost_event(:event_processor, 'error') expect(string_io.string).to match(/Discarded event because one of the event processors returned nil/) end + + it "records correct client report for error event" do + client.capture_event(event, scope) + expect(client.transport).to have_recorded_lost_event(:event_processor, 'error') + end + + it "records correct transaction and span client reports for transaction event" do + client.capture_event(transaction_event, scope) + expect(client.transport).to have_recorded_lost_event(:event_processor, 'transaction') + expect(client.transport).to have_recorded_lost_event(:event_processor, 'span', num: 6) + end + end + + context "when scope.apply_to_event modifies spans" do + before do + scope.add_event_processor do |event, hint| + 2.times { event.spans.pop } + event + end + end + + it "records correct span delta client report for transaction event" do + client.capture_event(transaction_event, scope) + expect(client.transport).to have_recorded_lost_event(:event_processor, 'span', num: 2) + end end context "when scope.apply_to_event fails" do @@ -332,7 +369,7 @@ end it "swallows the event and logs the failure" do - expect(subject.capture_event(event, scope)).to be_nil + expect(client.capture_event(event, scope)).to be_nil expect(string_io.string).to match(/Event capturing failed: TypeError/) expect(string_io.string).not_to match(__FILE__) @@ -343,7 +380,7 @@ configuration.debug = true end it "logs the error with backtrace" do - expect(subject.capture_event(event, scope)).to be_nil + expect(client.capture_event(event, scope)).to be_nil expect(string_io.string).to match(/Event capturing failed: TypeError/) expect(string_io.string).to match(__FILE__) @@ -358,9 +395,8 @@ end it "swallows and logs Sentry::ExternalError (caused by transport's networking error)" do - expect(subject.capture_event(event, scope)).to be_nil + expect(client.capture_event(event, scope)).to be_nil - expect(subject.transport).to have_recorded_lost_event(:network_error, 'error') expect(string_io.string).to match(/Event sending failed: Failed to open TCP connection/) expect(string_io.string).to match(/Event capturing failed: Failed to open TCP connection/) end @@ -368,10 +404,21 @@ it "swallows and logs errors caused by the user (like in before_send)" do configuration.before_send = ->(_, _) { raise TypeError } - expect(subject.capture_event(event, scope)).to be_nil + expect(client.capture_event(event, scope)).to be_nil expect(string_io.string).to match(/Event sending failed: TypeError/) end + + it "captures client report for error event" do + client.capture_event(event, scope) + expect(client.transport).to have_recorded_lost_event(:network_error, 'error') + end + + it "captures client report for transaction event with span counts" do + client.capture_event(transaction_event, scope) + expect(client.transport).to have_recorded_lost_event(:network_error, 'transaction') + expect(client.transport).to have_recorded_lost_event(:network_error, 'span', num: 6) + end end context "when sending events in background causes error", retry: 3 do @@ -380,32 +427,44 @@ end it "swallows and logs Sentry::ExternalError (caused by transport's networking error)" do - expect(subject.capture_event(event, scope)).to be_a(Sentry::ErrorEvent) + expect(client.capture_event(event, scope)).to be_a(Sentry::ErrorEvent) sleep(0.2) - expect(subject.transport).to have_recorded_lost_event(:network_error, 'error') expect(string_io.string).to match(/Event sending failed: Failed to open TCP connection/) end it "swallows and logs errors caused by the user (like in before_send)" do configuration.before_send = ->(_, _) { raise TypeError } - expect(subject.capture_event(event, scope)).to be_a(Sentry::ErrorEvent) + expect(client.capture_event(event, scope)).to be_a(Sentry::ErrorEvent) sleep(0.2) expect(string_io.string).to match(/Event sending failed: TypeError/) end + + it "captures client report for error event" do + client.capture_event(event, scope) + sleep(0.2) + expect(client.transport).to have_recorded_lost_event(:network_error, 'error') + end + + it "captures client report for transaction event with span counts" do + client.capture_event(transaction_event, scope) + sleep(0.2) + expect(client.transport).to have_recorded_lost_event(:network_error, 'transaction') + expect(client.transport).to have_recorded_lost_event(:network_error, 'span', num: 6) + end end context "when config.async causes error" do before do - expect(subject).to receive(:send_event) + expect(client).to receive(:send_event) end it "swallows Redis related error and send the event synchronizely" do configuration.async = ->(_, _) { raise Redis::ConnectionError } - subject.capture_event(event, scope) + client.capture_event(event, scope) expect(string_io.string).to match(/Async event sending failed: Redis::ConnectionError/) end @@ -413,7 +472,7 @@ it "swallows and logs the exception" do configuration.async = ->(_, _) { raise TypeError } - subject.capture_event(event, scope) + client.capture_event(event, scope) expect(string_io.string).to match(/Async event sending failed: TypeError/) end @@ -424,7 +483,7 @@ context "error happens when sending the event" do it "raises the error" do expect do - subject.send_event(event) + client.send_event(event) end.to raise_error(Sentry::ExternalError) expect(string_io.string).to match(/Event sending failed: Failed to open TCP connection/) @@ -440,7 +499,7 @@ it "raises the error" do expect do - subject.send_event(event) + client.send_event(event) end.to raise_error(TypeError) expect(string_io.string).to match(/Event sending failed: TypeError/) @@ -453,7 +512,7 @@ it "logs the error with backtrace" do expect do - subject.send_event(event) + client.send_event(event) end.to raise_error(TypeError) expect(string_io.string).to match(/Event sending failed: TypeError/) @@ -469,9 +528,9 @@ end end - it "records lost event" do - subject.send_event(event) - expect(subject.transport).to have_recorded_lost_event(:before_send, 'error') + it "records lost error event" do + client.send_event(event) + expect(client.transport).to have_recorded_lost_event(:before_send, 'error') end end @@ -482,10 +541,24 @@ end end - it "records lost event" do - transaction_event = subject.event_from_transaction(Sentry::Transaction.new(hub: hub)) - subject.send_event(transaction_event) - expect(subject.transport).to have_recorded_lost_event(:before_send, 'transaction') + it "records lost transaction with span counts client reports" do + client.send_event(transaction_event) + expect(client.transport).to have_recorded_lost_event(:before_send, 'transaction') + expect(client.transport).to have_recorded_lost_event(:before_send, 'span', num: 6) + end + end + + context "before_send_transaction modifies spans" do + before do + configuration.before_send_transaction = lambda do |event, _hint| + 2.times { event.spans.pop } + event + end + end + + it "records lost span delta client reports" do + expect { client.send_event(transaction_event) }.to raise_error(Sentry::ExternalError) + expect(client.transport).to have_recorded_lost_event(:before_send, 'span', num: 2) end end end diff --git a/sentry-ruby/spec/sentry/configuration_spec.rb b/sentry-ruby/spec/sentry/configuration_spec.rb index 4ab21a101..92926836a 100644 --- a/sentry-ruby/spec/sentry/configuration_spec.rb +++ b/sentry-ruby/spec/sentry/configuration_spec.rb @@ -24,6 +24,18 @@ end end + describe "#background_worker_threads" do + it "sets to have of the processors count" do + allow_any_instance_of(Sentry::Configuration).to receive(:processor_count).and_return(8) + expect(subject.background_worker_threads).to eq(4) + end + + it "sets to 1 with only 1 processor" do + allow_any_instance_of(Sentry::Configuration).to receive(:processor_count).and_return(1) + expect(subject.background_worker_threads).to eq(1) + end + end + describe "#csp_report_uri" do it "returns nil if the dsn is not present" do expect(subject.csp_report_uri).to eq(nil) diff --git a/sentry-ruby/spec/sentry/envelope_spec.rb b/sentry-ruby/spec/sentry/envelope_spec.rb index c12ee3052..8936c7707 100644 --- a/sentry-ruby/spec/sentry/envelope_spec.rb +++ b/sentry-ruby/spec/sentry/envelope_spec.rb @@ -7,6 +7,7 @@ ['sessions', 'session'], ['attachment', 'attachment'], ['transaction', 'transaction'], + ['span', 'span'], ['profile', 'profile'], ['check_in', 'monitor'], ['statsd', 'metric_bucket'], diff --git a/sentry-ruby/spec/sentry/event_spec.rb b/sentry-ruby/spec/sentry/event_spec.rb index a5ca457e7..5eeb232f9 100644 --- a/sentry-ruby/spec/sentry/event_spec.rb +++ b/sentry-ruby/spec/sentry/event_spec.rb @@ -141,6 +141,12 @@ expect(event.to_hash[:user][:ip_address]).to eq("2.2.2.2") end + it "doesn't overwrite already set ip address" do + Sentry.set_user({ ip_address: "3.3.3.3" }) + Sentry.get_current_scope.apply_to_event(event) + expect(event.to_hash[:user][:ip_address]).to eq("3.3.3.3") + end + context "with config.trusted_proxies = [\"2.2.2.2\"]" do before do Sentry.configuration.trusted_proxies = ["2.2.2.2"] diff --git a/sentry-ruby/spec/sentry/faraday_spec.rb b/sentry-ruby/spec/sentry/faraday_spec.rb new file mode 100644 index 000000000..f4632032f --- /dev/null +++ b/sentry-ruby/spec/sentry/faraday_spec.rb @@ -0,0 +1,279 @@ +require "faraday" +require_relative "../spec_helper" + +RSpec.describe Sentry::Faraday do + before(:all) do + perform_basic_setup do |config| + config.enabled_patches << :faraday + config.traces_sample_rate = 1.0 + config.logger = ::Logger.new(StringIO.new) + end + end + + after(:all) do + Sentry.configuration.enabled_patches = Sentry::Configuration::DEFAULT_PATCHES + end + + context "with tracing enabled" do + let(:http) do + Faraday.new(url) do |f| + f.request :json + + f.adapter Faraday::Adapter::Test do |stub| + stub.get("/test") do + [200, { "Content-Type" => "text/html" }, "

hello world

"] + end + end + end + end + + let(:url) { "http://example.com/" } + + it "records the request's span" do + transaction = Sentry.start_transaction + Sentry.get_current_scope.set_span(transaction) + + _response = http.get("/test") + + request_span = transaction.span_recorder.spans.last + + expect(request_span.op).to eq("http.client") + expect(request_span.origin).to eq("auto.http.faraday") + expect(request_span.start_timestamp).not_to be_nil + expect(request_span.timestamp).not_to be_nil + expect(request_span.start_timestamp).not_to eq(request_span.timestamp) + expect(request_span.description).to eq("GET http://example.com/test") + + expect(request_span.data).to eq({ + "http.response.status_code" => 200, + "url" => "http://example.com/test", + "http.request.method" => "GET" + }) + end + end + + context "with config.send_default_pii = true" do + let(:http) do + Faraday.new(url) do |f| + f.adapter Faraday::Adapter::Test do |stub| + stub.get("/test") do + [200, { "Content-Type" => "text/html" }, "

hello world

"] + end + + stub.post("/test") do + [200, { "Content-Type" => "application/json" }, { hello: "world" }.to_json] + end + end + end + end + + let(:url) { "http://example.com/" } + + before do + Sentry.configuration.send_default_pii = true + Sentry.configuration.breadcrumbs_logger = [:http_logger] + end + + it "records the request's span with query string in data" do + transaction = Sentry.start_transaction + Sentry.get_current_scope.set_span(transaction) + + _response = http.get("/test?foo=bar") + + request_span = transaction.span_recorder.spans.last + + expect(request_span.description).to eq("GET http://example.com/test") + + expect(request_span.data).to eq({ + "http.response.status_code" => 200, + "url" => "http://example.com/test", + "http.request.method" => "GET", + "http.query" => "foo=bar" + }) + end + + it "records breadcrumbs" do + transaction = Sentry.start_transaction + Sentry.get_current_scope.set_span(transaction) + + _response = http.get("/test?foo=bar") + + transaction.span_recorder.spans.last + + crumb = Sentry.get_current_scope.breadcrumbs.peek + + expect(crumb.category).to eq("http") + expect(crumb.data[:status]).to eq(200) + expect(crumb.data[:method]).to eq("GET") + expect(crumb.data[:url]).to eq("http://example.com/test") + expect(crumb.data[:query]).to eq("foo=bar") + expect(crumb.data[:body]).to be(nil) + end + + it "records POST request body" do + transaction = Sentry.start_transaction + Sentry.get_current_scope.set_span(transaction) + + body = { foo: "bar" }.to_json + _response = http.post("/test?foo=bar", body, "Content-Type" => "application/json") + + request_span = transaction.span_recorder.spans.last + + expect(request_span.description).to eq("POST http://example.com/test") + + expect(request_span.data).to eq({ + "http.response.status_code" => 200, + "url" => "http://example.com/test", + "http.request.method" => "POST", + "http.query" => "foo=bar" + }) + + crumb = Sentry.get_current_scope.breadcrumbs.peek + + expect(crumb.data[:body]).to eq(body) + end + + context "with custom trace_propagation_targets" do + let(:http) do + Faraday.new(url) do |f| + f.adapter Faraday::Adapter::Test do |stub| + stub.get("/test") do + [200, { "Content-Type" => "text/html" }, "

hello world

"] + end + end + end + end + + before do + Sentry.configuration.trace_propagation_targets = ["example.com", /foobar.org\/api\/v2/] + end + + context "when the request is not to the same target" do + let(:url) { "http://another.site/" } + + it "doesn't add sentry headers to outgoing requests to different target" do + transaction = Sentry.start_transaction + Sentry.get_current_scope.set_span(transaction) + + response = http.get("/test") + + request_span = transaction.span_recorder.spans.last + + expect(request_span.description).to eq("GET #{url}/test") + + expect(request_span.data).to eq({ + "http.response.status_code" => 200, + "url" => "#{url}/test", + "http.request.method" => "GET" + }) + + expect(response.headers.key?("sentry-trace")).to eq(false) + expect(response.headers.key?("baggage")).to eq(false) + end + end + + context "when the request is to the same target" do + let(:url) { "http://example.com/" } + + before do + Sentry.configuration.trace_propagation_targets = ["example.com"] + end + + it "adds sentry headers to outgoing requests" do + transaction = Sentry.start_transaction + Sentry.get_current_scope.set_span(transaction) + + response = http.get("/test") + + request_span = transaction.span_recorder.spans.last + + expect(request_span.description).to eq("GET #{url}/test") + + expect(request_span.data).to eq({ + "http.response.status_code" => 200, + "url" => "#{url}/test", + "http.request.method" => "GET" + }) + + expect(response.env.request_headers.key?("sentry-trace")).to eq(true) + expect(response.env.request_headers.key?("baggage")).to eq(true) + end + end + + context "when the request's url configured target regexp" do + let(:url) { "http://example.com/" } + + before do + Sentry.configuration.trace_propagation_targets = [/example/] + end + + it "adds sentry headers to outgoing requests" do + transaction = Sentry.start_transaction + Sentry.get_current_scope.set_span(transaction) + + response = http.get("/test") + + request_span = transaction.span_recorder.spans.last + + expect(request_span.description).to eq("GET #{url}/test") + + expect(request_span.data).to eq({ + "http.response.status_code" => 200, + "url" => "#{url}/test", + "http.request.method" => "GET" + }) + + expect(response.env.request_headers.key?("sentry-trace")).to eq(true) + expect(response.env.request_headers.key?("baggage")).to eq(true) + end + end + end + end + + context "when adapter is net/http" do + let(:http) do + Faraday.new(url) do |f| + f.request :json + f.adapter :net_http + end + end + + let(:url) { "http://example.com/" } + + it "skips instrumentation" do + transaction = Sentry.start_transaction + Sentry.get_current_scope.set_span(transaction) + + _response = http.get("/test") + + request_span = transaction.span_recorder.spans.last + + expect(request_span.op).to eq("http.client") + expect(request_span.origin).to eq("auto.http.net_http") + + expect(transaction.span_recorder.spans.map(&:origin)).not_to include("auto.http.faraday") + end + end + + context "when Sentry is not initialized" do + let(:http) do + Faraday.new(url) do |f| + f.adapter Faraday::Adapter::Test do |stub| + stub.get("/test") do + [200, { "Content-Type" => "text/html" }, "

hello world

"] + end + end + end + end + + let(:url) { "http://example.com/" } + + it "skips instrumentation" do + allow(Sentry).to receive(:initialized?).and_return(false) + + response = http.get("/test") + + expect(response.status).to eq(200) + end + end +end diff --git a/sentry-ruby/spec/sentry/graphql_spec.rb b/sentry-ruby/spec/sentry/graphql_spec.rb new file mode 100644 index 000000000..5820b73d2 --- /dev/null +++ b/sentry-ruby/spec/sentry/graphql_spec.rb @@ -0,0 +1,78 @@ +require 'spec_helper' + +with_graphql = begin + require 'graphql' + true + rescue LoadError + false + end + +RSpec.describe 'GraphQL' do + it 'adds the graphql patch to registered patches' do + expect(Sentry.registered_patches.keys).to include(:graphql) + end + + context 'when patch enabled' do + if with_graphql + describe 'with graphql gem' do + class Thing < GraphQL::Schema::Object + field :str, String + def str; 'blah'; end + end + + class Query < GraphQL::Schema::Object + field :int, Integer, null: false + def int; 1; end + + field :thing, Thing + def thing; :thing; end + end + + class MySchema < GraphQL::Schema + query(Query) + end + + before do + perform_basic_setup do |config| + config.traces_sample_rate = 1.0 + config.enabled_patches << :graphql + end + end + + it 'enables the sentry tracer' do + expect(MySchema.trace_modules_for(:default)).to include(::GraphQL::Tracing::SentryTrace) + end + + it 'adds graphql spans to the transaction' do + transaction = Sentry.start_transaction + Sentry.get_current_scope.set_span(transaction) + MySchema.execute('query foobar { int thing { str } }') + transaction.finish + + expect(last_sentry_event.transaction).to eq('GraphQL/query.foobar') + + execute_span = last_sentry_event.spans.find { |s| s[:op] == 'graphql.execute' } + expect(execute_span[:description]).to eq('query foobar') + expect(execute_span[:data]).to eq({ + 'graphql.document'=>'query foobar { int thing { str } }', + 'graphql.operation.name'=>'foobar', + 'graphql.operation.type'=>'query' + }) + end + end + else + describe 'without graphql gem' do + it 'logs warning' do + string_io = StringIO.new + + perform_basic_setup do |config| + config.enabled_patches << :graphql + config.logger = Logger.new(string_io) + end + + expect(string_io.string).to include('WARN -- sentry: You tried to enable the GraphQL integration but no GraphQL gem was detected. Make sure you have the `graphql` gem (>= 2.2.6) in your Gemfile.') + end + end + end + end +end diff --git a/sentry-ruby/spec/sentry/hub_spec.rb b/sentry-ruby/spec/sentry/hub_spec.rb index bc942ca25..6d06595aa 100644 --- a/sentry-ruby/spec/sentry/hub_spec.rb +++ b/sentry-ruby/spec/sentry/hub_spec.rb @@ -342,6 +342,22 @@ end end + it "reminds users about unsupported options" do + expect do + subject.capture_event(event, unsupported: true) + end.not_to raise_error + + expect(string_io.string).to include("Options [:unsupported] are not supported and will not be applied to the event.") + end + + it "does not warn about unsupported options if all passed options are supported" do + expect do + subject.capture_event(event, level: 'DEBUG') + end.not_to raise_error + + expect(string_io.string).not_to include("Options [] are not supported and will not be applied to the event.") + end + context "when event is a transaction" do it "transaction.set_context merges and takes precedence over scope.set_context" do scope.set_context(:foo, { val: 42 }) diff --git a/sentry-ruby/spec/sentry/interfaces/request_interface_spec.rb b/sentry-ruby/spec/sentry/interfaces/request_interface_spec.rb index e9570f520..580a610d3 100644 --- a/sentry-ruby/spec/sentry/interfaces/request_interface_spec.rb +++ b/sentry-ruby/spec/sentry/interfaces/request_interface_spec.rb @@ -1,7 +1,7 @@ -return unless defined?(Rack) - require 'spec_helper' +return unless defined?(Rack) + RSpec.describe Sentry::RequestInterface do let(:env) { Rack::MockRequest.env_for("/test") } let(:send_default_pii) { false } @@ -44,14 +44,16 @@ let(:env) { Rack::MockRequest.env_for("/test", additional_headers) } it 'transforms headers to conform with the interface' do - expect(subject.headers).to eq("Content-Length" => "0", "Version" => "HTTP/1.1", "X-Request-Id" => "12345678") + expect(subject.headers).to include("Version" => "HTTP/1.1", "X-Request-Id" => "12345678") + expect(subject.headers).not_to include("Cookie") end context 'from Rails middleware' do let(:additional_headers) { { "action_dispatch.request_id" => "12345678" } } it 'transforms headers to conform with the interface' do - expect(subject.headers).to eq("Content-Length" => "0", "X-Request-Id" => "12345678") + expect(subject.headers).to include("X-Request-Id" => "12345678") + expect(subject.headers).not_to include("Cookie") end end @@ -61,7 +63,7 @@ it "doesn't cause any issue" do json = JSON.generate(subject.to_hash) - expect(JSON.parse(json)["headers"]).to eq({ "Content-Length"=>"0", "Foo"=>"Tekirda�" }) + expect(JSON.parse(json)["headers"]).to include("Foo"=>"Tekirda�") end end diff --git a/sentry-ruby/spec/sentry/metrics/aggregator_spec.rb b/sentry-ruby/spec/sentry/metrics/aggregator_spec.rb index f397d07d5..dcd78ab86 100644 --- a/sentry-ruby/spec/sentry/metrics/aggregator_spec.rb +++ b/sentry-ruby/spec/sentry/metrics/aggregator_spec.rb @@ -47,7 +47,7 @@ it 'logs error' do subject.add(:c, 'incr', 1) - expect(string_io.string).to match(/\[Metrics::Aggregator\] thread creation failed/) + expect(string_io.string).to include("[#{described_class.name}] thread creation failed") end end @@ -479,7 +479,7 @@ it 'logs message when killing the thread' do expect(subject.thread).to receive(:kill) subject.kill - expect(string_io.string).to match(/\[Metrics::Aggregator\] killing thread/) + expect(string_io.string).to include("[#{described_class.name}] thread killed") end end end diff --git a/sentry-ruby/spec/sentry/metrics_spec.rb b/sentry-ruby/spec/sentry/metrics_spec.rb index 42d9ad754..335c31e5a 100644 --- a/sentry-ruby/spec/sentry/metrics_spec.rb +++ b/sentry-ruby/spec/sentry/metrics_spec.rb @@ -122,7 +122,7 @@ end it 'starts a span' do - expect(Sentry).to receive(:with_child_span).with(op: Sentry::Metrics::OP_NAME, description: 'foo').and_call_original + expect(Sentry).to receive(:with_child_span).with(op: Sentry::Metrics::OP_NAME, description: 'foo', origin: Sentry::Metrics::SPAN_ORIGIN).and_call_original described_class.timing('foo') { sleep(0.1) } end diff --git a/sentry-ruby/spec/sentry/net/http_spec.rb b/sentry-ruby/spec/sentry/net/http_spec.rb index 4718b5e1f..064c4f36c 100644 --- a/sentry-ruby/spec/sentry/net/http_spec.rb +++ b/sentry-ruby/spec/sentry/net/http_spec.rb @@ -62,6 +62,7 @@ request_span = transaction.span_recorder.spans.last expect(request_span.op).to eq("http.client") + expect(request_span.origin).to eq("auto.http.net_http") expect(request_span.start_timestamp).not_to be_nil expect(request_span.timestamp).not_to be_nil expect(request_span.start_timestamp).not_to eq(request_span.timestamp) @@ -93,6 +94,7 @@ request_span = transaction.span_recorder.spans.last expect(request_span.op).to eq("http.client") + expect(request_span.origin).to eq("auto.http.net_http") expect(request_span.start_timestamp).not_to be_nil expect(request_span.timestamp).not_to be_nil expect(request_span.start_timestamp).not_to eq(request_span.timestamp) @@ -320,6 +322,7 @@ def verify_spans(transaction) request_span = transaction.span_recorder.spans[1] expect(request_span.op).to eq("http.client") + expect(request_span.origin).to eq("auto.http.net_http") expect(request_span.start_timestamp).not_to be_nil expect(request_span.timestamp).not_to be_nil expect(request_span.start_timestamp).not_to eq(request_span.timestamp) @@ -332,6 +335,7 @@ def verify_spans(transaction) request_span = transaction.span_recorder.spans[2] expect(request_span.op).to eq("http.client") + expect(request_span.origin).to eq("auto.http.net_http") expect(request_span.start_timestamp).not_to be_nil expect(request_span.timestamp).not_to be_nil expect(request_span.start_timestamp).not_to eq(request_span.timestamp) diff --git a/sentry-ruby/spec/sentry/rack/capture_exceptions_spec.rb b/sentry-ruby/spec/sentry/rack/capture_exceptions_spec.rb index c5751bba0..e03984cdf 100644 --- a/sentry-ruby/spec/sentry/rack/capture_exceptions_spec.rb +++ b/sentry-ruby/spec/sentry/rack/capture_exceptions_spec.rb @@ -79,7 +79,7 @@ event = last_sentry_event expect(event.transaction).to eq("/test") expect(event.to_hash.dig(:request, :url)).to eq("http://example.org/test") - expect(Sentry.get_current_scope.transaction_names).to be_empty + expect(Sentry.get_current_scope.transaction_name).to be_nil expect(Sentry.get_current_scope.rack_env).to eq({}) end @@ -264,6 +264,7 @@ def verify_transaction_attributes(transaction) expect(transaction.timestamp).not_to be_nil expect(transaction.contexts.dig(:trace, :status)).to eq("ok") expect(transaction.contexts.dig(:trace, :op)).to eq("http.server") + expect(transaction.contexts.dig(:trace, :origin)).to eq("auto.http.rack") expect(transaction.spans.count).to eq(0) end diff --git a/sentry-ruby/spec/sentry/rake_spec.rb b/sentry-ruby/spec/sentry/rake_spec.rb index bb77a3ee9..a1eb22998 100644 --- a/sentry-ruby/spec/sentry/rake_spec.rb +++ b/sentry-ruby/spec/sentry/rake_spec.rb @@ -22,7 +22,7 @@ message = `cd spec/support && bundle exec rake raise_exception_without_rake_integration 2>&1` end.join - expect(message).not_to match(/Sentry/) + expect(message).not_to match(/Sending envelope with items/) end it "run rake task with original arguments" do diff --git a/sentry-ruby/spec/sentry/redis_spec.rb b/sentry-ruby/spec/sentry/redis_spec.rb index ef2c4ce79..2722e0498 100644 --- a/sentry-ruby/spec/sentry/redis_spec.rb +++ b/sentry-ruby/spec/sentry/redis_spec.rb @@ -23,6 +23,7 @@ expect(result).to eq("OK") request_span = transaction.span_recorder.spans.last expect(request_span.op).to eq("db.redis") + expect(request_span.origin).to eq("auto.db.redis") expect(request_span.start_timestamp).not_to be_nil expect(request_span.timestamp).not_to be_nil expect(request_span.start_timestamp).not_to eq(request_span.timestamp) diff --git a/sentry-ruby/spec/sentry/scope/setters_spec.rb b/sentry-ruby/spec/sentry/scope/setters_spec.rb index 47d915980..3ca240488 100644 --- a/sentry-ruby/spec/sentry/scope/setters_spec.rb +++ b/sentry-ruby/spec/sentry/scope/setters_spec.rb @@ -206,7 +206,7 @@ end describe "#set_transaction_name" do - it "pushes the transaction_name to transaction_names stack" do + it "sets the transaction name" do subject.set_transaction_name("WelcomeController#home") expect(subject.transaction_name).to eq("WelcomeController#home") diff --git a/sentry-ruby/spec/sentry/scope_spec.rb b/sentry-ruby/spec/sentry/scope_spec.rb index c3799602a..5de4d8d6d 100644 --- a/sentry-ruby/spec/sentry/scope_spec.rb +++ b/sentry-ruby/spec/sentry/scope_spec.rb @@ -22,8 +22,8 @@ expect(subject.tags).to eq({}) expect(subject.user).to eq({}) expect(subject.fingerprint).to eq([]) - expect(subject.transaction_names).to eq([]) - expect(subject.transaction_sources).to eq([]) + expect(subject.transaction_name).to eq(nil) + expect(subject.transaction_source).to eq(nil) expect(subject.propagation_context).to be_a(Sentry::PropagationContext) end @@ -42,8 +42,7 @@ copy.extra.merge!(foo: "bar") copy.tags.merge!(foo: "bar") copy.user.merge!(foo: "bar") - copy.transaction_names << "foo" - copy.transaction_sources << :url + copy.set_transaction_name("foo", source: :url) copy.fingerprint << "bar" expect(subject.breadcrumbs.to_hash).to eq({ values: [] }) @@ -53,8 +52,8 @@ expect(subject.tags).to eq({}) expect(subject.user).to eq({}) expect(subject.fingerprint).to eq([]) - expect(subject.transaction_names).to eq([]) - expect(subject.transaction_sources).to eq([]) + expect(subject.transaction_name).to eq(nil) + expect(subject.transaction_source).to eq(nil) expect(subject.span).to eq(nil) end @@ -143,8 +142,8 @@ expect(subject.tags).to eq({}) expect(subject.user).to eq({}) expect(subject.fingerprint).to eq([]) - expect(subject.transaction_names).to eq([]) - expect(subject.transaction_sources).to eq([]) + expect(subject.transaction_name).to eq(nil) + expect(subject.transaction_source).to eq(nil) expect(subject.span).to eq(nil) end end @@ -202,6 +201,7 @@ scope.set_user({ id: 1 }) scope.set_transaction_name("WelcomeController#index", source: :view) scope.set_fingerprint(["foo"]) + scope.add_attachment(bytes: "file-data", filename: "test.txt") scope end @@ -220,6 +220,10 @@ expect(event.contexts).to include(:trace) expect(event.contexts[:os].keys).to match_array([:name, :version, :build, :kernel_version, :machine]) expect(event.contexts.dig(:runtime, :version)).to match(/ruby/) + + attachment = event.attachments.first + expect(attachment.filename).to eql("test.txt") + expect(attachment.bytes).to eql("file-data") end it "does not apply the contextual data to a check-in event" do @@ -336,4 +340,49 @@ subject.generate_propagation_context(env) end end + + describe '#update_from_options' do + it 'updates data from arguments' do + result = subject.update_from_options( + contexts: { context: 1 }, + extra: { foo: 42 }, + tags: { tag: 2 }, + user: { name: 'jane' }, + level: :info, + fingerprint: 'ABCD' + ) + + expect(subject.contexts).to include(context: 1) + expect(subject.extra).to eq({ foo: 42 }) + expect(subject.tags).to eq({ tag: 2 }) + expect(subject.user).to eq({ name: 'jane' }) + expect(subject.level).to eq(:info) + expect(subject.fingerprint).to eq('ABCD') + expect(result).to eq([]) + end + + it 'returns unsupported option keys' do + result = subject.update_from_options(foo: 42, bar: 43) + expect(result).to eq([:foo, :bar]) + end + end + + describe "#add_attachment" do + before { perform_basic_setup } + + let(:opts) do + { bytes: "file-data", filename: "test.txt" } + end + + subject do + described_class.new + end + + it "adds a new attachment" do + attachment = subject.add_attachment(**opts) + + expect(attachment.bytes).to eq("file-data") + expect(attachment.filename).to eq("test.txt") + end + end end diff --git a/sentry-ruby/spec/sentry/session_flusher_spec.rb b/sentry-ruby/spec/sentry/session_flusher_spec.rb index 5f9f53cdf..0c0fbb4eb 100644 --- a/sentry-ruby/spec/sentry/session_flusher_spec.rb +++ b/sentry-ruby/spec/sentry/session_flusher_spec.rb @@ -146,7 +146,7 @@ it "logs error" do subject.add_session(session) - expect(string_io.string).to match(/Session flusher thread creation failed/) + expect(string_io.string).to include("[#{described_class.name}] thread creation failed") end end @@ -173,7 +173,7 @@ describe "#kill" do it "logs message when killing the thread" do subject.kill - expect(string_io.string).to match(/Killing session flusher/) + expect(string_io.string).to include("[#{described_class.name}] thread killed") end end end diff --git a/sentry-ruby/spec/sentry/span_spec.rb b/sentry-ruby/spec/sentry/span_spec.rb index 3e61c8058..d890dfa25 100644 --- a/sentry-ruby/spec/sentry/span_spec.rb +++ b/sentry-ruby/spec/sentry/span_spec.rb @@ -28,11 +28,15 @@ it "returns correct context data" do context = subject.get_trace_context + subject.set_data(:foo, "bar") + expect(context[:op]).to eq("sql.query") expect(context[:description]).to eq("SELECT * FROM users;") expect(context[:status]).to eq("ok") expect(context[:trace_id].length).to eq(32) expect(context[:span_id].length).to eq(16) + expect(context[:origin]).to eq('manual') + expect(context[:data]).to eq(foo: "bar") end end @@ -69,6 +73,7 @@ expect(hash[:tags]).to eq({ "foo" => "bar" }) expect(hash[:trace_id].length).to eq(32) expect(hash[:span_id].length).to eq(16) + expect(hash[:origin]).to eq('manual') end it 'has metric summary if present' do @@ -296,4 +301,12 @@ expect(subject.tags).to eq({ foo: "bar" }) end end + + describe "#set_origin" do + it "sets origin" do + subject.set_origin('auto.http') + + expect(subject.origin).to eq('auto.http') + end + end end diff --git a/sentry-ruby/spec/sentry/test_helper_spec.rb b/sentry-ruby/spec/sentry/test_helper_spec.rb index 200356661..2753b9b64 100644 --- a/sentry-ruby/spec/sentry/test_helper_spec.rb +++ b/sentry-ruby/spec/sentry/test_helper_spec.rb @@ -127,6 +127,12 @@ expect(Sentry.get_current_scope.tags).to eq({}) end + it "clears global processors" do + Sentry.add_global_event_processor { |event| event } + teardown_sentry_test + expect(Sentry::Scope.global_event_processors).to eq([]) + end + context "when the configuration is mutated" do it "rolls back client changes" do Sentry.configuration.environment = "quack" diff --git a/sentry-ruby/spec/sentry/transaction_spec.rb b/sentry-ruby/spec/sentry/transaction_spec.rb index 6dafc1371..effb249f1 100644 --- a/sentry-ruby/spec/sentry/transaction_spec.rb +++ b/sentry-ruby/spec/sentry/transaction_spec.rb @@ -494,6 +494,7 @@ it "records lost event with reason sample_rate" do subject.finish expect(Sentry.get_current_client.transport).to have_recorded_lost_event(:sample_rate, 'transaction') + expect(Sentry.get_current_client.transport).to have_recorded_lost_event(:sample_rate, 'span') end end @@ -514,6 +515,7 @@ subject.finish expect(Sentry.get_current_client.transport).to have_recorded_lost_event(:backpressure, 'transaction') + expect(Sentry.get_current_client.transport).to have_recorded_lost_event(:backpressure, 'span') end end diff --git a/sentry-ruby/spec/sentry/transport_spec.rb b/sentry-ruby/spec/sentry/transport_spec.rb index 7c90f4be7..b78dc7878 100644 --- a/sentry-ruby/spec/sentry/transport_spec.rb +++ b/sentry-ruby/spec/sentry/transport_spec.rb @@ -153,6 +153,7 @@ before do 5.times { subject.record_lost_event(:ratelimit_backoff, 'error') } 3.times { subject.record_lost_event(:queue_overflow, 'transaction') } + 2.times { subject.record_lost_event(:network_error, 'span', num: 5) } end it "incudes client report in envelope" do @@ -170,7 +171,8 @@ timestamp: Time.now.utc.iso8601, discarded_events: [ { reason: :ratelimit_backoff, category: 'error', quantity: 5 }, - { reason: :queue_overflow, category: 'transaction', quantity: 3 } + { reason: :queue_overflow, category: 'transaction', quantity: 3 }, + { reason: :network_error, category: 'span', quantity: 10 } ] }.to_json ) @@ -433,6 +435,24 @@ end end end + + context "event with attachments" do + let(:event) { client.event_from_exception(ZeroDivisionError.new("divided by 0")) } + let(:envelope) { subject.envelope_from_event(event) } + + before do + event.attachments << Sentry::Attachment.new(filename: "test-1.txt", bytes: "test") + event.attachments << Sentry::Attachment.new(path: fixture_path("attachment.txt")) + end + + it "sends the event and logs the action" do + expect(subject).to receive(:send_data) + + subject.send_envelope(envelope) + + expect(io.string).to match(/Sending envelope with items \[event, attachment, attachment\]/) + end + end end describe "#send_event" do @@ -460,7 +480,7 @@ expect(subject.send_event(event)).to eq(event) expect(io.string).to match( - /INFO -- sentry: \[Transport\] Sending envelope with items \[event\] #{event.event_id} to Sentry/ + /DEBUG -- sentry: \[Transport\] Sending envelope with items \[event\] #{event.event_id} to Sentry/ ) end end diff --git a/sentry-ruby/spec/sentry_spec.rb b/sentry-ruby/spec/sentry_spec.rb index c3f12777a..dd3f85c93 100644 --- a/sentry-ruby/spec/sentry_spec.rb +++ b/sentry-ruby/spec/sentry_spec.rb @@ -713,6 +713,57 @@ end end + describe ".add_attachment" do + it "adds a new attachment to the current scope with provided filename and bytes" do + described_class.add_attachment(filename: "test.txt", bytes: "test") + + expect(described_class.get_current_scope.attachments.size).to be(1) + + attachment = described_class.get_current_scope.attachments.first + expect(attachment.filename).to eq("test.txt") + expect(attachment.bytes).to eq("test") + end + + it "adds a new attachment to the current scope with provided path to a file" do + described_class.add_attachment(path: fixture_path("attachment.txt")) + + expect(described_class.get_current_scope.attachments.size).to be(1) + + attachment = described_class.get_current_scope.attachments.first + expect(attachment.filename).to eq("attachment.txt") + expect(attachment.payload).to eq("hello world\n") + end + + it "adds a new attachment to the current scope favoring bytes over path" do + described_class.add_attachment(path: fixture_path("attachment.txt"), bytes: "test", content_type: "text/plain") + + expect(described_class.get_current_scope.attachments.size).to be(1) + + attachment = described_class.get_current_scope.attachments.first + expect(attachment.filename).to eq("attachment.txt") + expect(attachment.content_type).to eq("text/plain") + expect(attachment.payload).to eq("test") + end + + it "raises meaningful error when path is invalid" do + described_class.add_attachment(path: "/not-here/oops") + + expect(described_class.get_current_scope.attachments.size).to be(1) + + attachment = described_class.get_current_scope.attachments.first + + expect { attachment.payload } + .to raise_error( + Sentry::Attachment::PathNotFoundError, + "Failed to read attachment file, file not found: /not-here/oops" + ) + end + + it "requires either filename or path" do + expect { described_class.add_attachment(bytes: "test") }.to raise_error(ArgumentError, "filename or path is required") + end + end + describe ".csp_report_uri" do it "returns the csp_report_uri generated from the main Configuration" do expect(Sentry.configuration).to receive(:csp_report_uri).and_call_original @@ -782,6 +833,17 @@ end end + describe ".get_trace_propagation_meta" do + it "returns meta tags for sentry-trace and baggage" do + meta = <<~META + + + META + + expect(described_class.get_trace_propagation_meta).to eq(meta.chomp) + end + end + describe ".continue_trace" do context "without incoming sentry trace" do let(:env) { { "HTTP_FOO" => "bar" } } diff --git a/sentry-ruby/spec/spec_helper.rb b/sentry-ruby/spec/spec_helper.rb index 1d4a6ff15..611205163 100644 --- a/sentry-ruby/spec/spec_helper.rb +++ b/sentry-ruby/spec/spec_helper.rb @@ -50,13 +50,21 @@ skip("skip rack related tests") unless defined?(Rack) end - RSpec::Matchers.define :have_recorded_lost_event do |reason, data_category| + RSpec::Matchers.define :have_recorded_lost_event do |reason, data_category, num: 1| match do |transport| - expect(transport.discarded_events[[reason, data_category]]).to be > 0 + expect(transport.discarded_events[[reason, data_category]]).to eq(num) end end end +def fixtures_root + @fixtures_root ||= Pathname(__dir__).join("fixtures") +end + +def fixture_path(name) + fixtures_root.join(name).realpath +end + def build_exception_with_cause(cause = "exception a") begin raise cause diff --git a/sentry-ruby/spec/support/Rakefile.rb b/sentry-ruby/spec/support/Rakefile.rb index f7627051f..2304ba3a4 100644 --- a/sentry-ruby/spec/support/Rakefile.rb +++ b/sentry-ruby/spec/support/Rakefile.rb @@ -4,6 +4,7 @@ Sentry.init do |config| config.dsn = 'http://12345:67890@sentry.localdomain/sentry/42' config.background_worker_threads = 0 + config.logger.level = Logger::DEBUG end task :raise_exception do diff --git a/sentry-sidekiq/lib/sentry/sidekiq/sentry_context_middleware.rb b/sentry-sidekiq/lib/sentry/sidekiq/sentry_context_middleware.rb index b6cde1ea5..748ca0155 100644 --- a/sentry-sidekiq/lib/sentry/sidekiq/sentry_context_middleware.rb +++ b/sentry-sidekiq/lib/sentry/sidekiq/sentry_context_middleware.rb @@ -1,9 +1,12 @@ +# frozen_string_literal: true + require 'sentry/sidekiq/context_filter' module Sentry module Sidekiq class SentryContextServerMiddleware - OP_NAME = "queue.sidekiq".freeze + OP_NAME = "queue.sidekiq" + SPAN_ORIGIN = "auto.queue.sidekiq" def call(_worker, job, queue) return yield unless Sentry.initialized? @@ -40,7 +43,13 @@ def build_tags(tags) end def start_transaction(scope, env) - options = { name: scope.transaction_name, source: scope.transaction_source, op: OP_NAME } + options = { + name: scope.transaction_name, + source: scope.transaction_source, + op: OP_NAME, + origin: SPAN_ORIGIN + } + transaction = Sentry.continue_trace(env, **options) Sentry.start_transaction(transaction: transaction, **options) end diff --git a/sentry-sidekiq/lib/sentry/sidekiq/version.rb b/sentry-sidekiq/lib/sentry/sidekiq/version.rb index 7b62c57ec..b127d83ba 100644 --- a/sentry-sidekiq/lib/sentry/sidekiq/version.rb +++ b/sentry-sidekiq/lib/sentry/sidekiq/version.rb @@ -1,5 +1,5 @@ module Sentry module Sidekiq - VERSION = "5.17.3" + VERSION = "5.19.0" end end diff --git a/sentry-sidekiq/sentry-sidekiq.gemspec b/sentry-sidekiq/sentry-sidekiq.gemspec index 56c43a71c..56f737887 100644 --- a/sentry-sidekiq/sentry-sidekiq.gemspec +++ b/sentry-sidekiq/sentry-sidekiq.gemspec @@ -7,21 +7,27 @@ Gem::Specification.new do |spec| spec.description = spec.summary = "A gem that provides Sidekiq integration for the Sentry error logger" spec.email = "accounts@sentry.io" spec.license = 'MIT' - spec.homepage = "https://github.com/getsentry/sentry-ruby" spec.platform = Gem::Platform::RUBY spec.required_ruby_version = '>= 2.4' spec.extra_rdoc_files = ["README.md", "LICENSE.txt"] spec.files = `git ls-files | grep -Ev '^(spec|benchmarks|examples)'`.split("\n") - spec.metadata["homepage_uri"] = spec.homepage - spec.metadata["source_code_uri"] = spec.homepage - spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/master/CHANGELOG.md" + github_root_uri = 'https://github.com/getsentry/sentry-ruby' + spec.homepage = "#{github_root_uri}/tree/#{spec.version}/#{spec.name}" + + spec.metadata = { + "homepage_uri" => spec.homepage, + "source_code_uri" => spec.homepage, + "changelog_uri" => "#{github_root_uri}/blob/#{spec.version}/CHANGELOG.md", + "bug_tracker_uri" => "#{github_root_uri}/issues", + "documentation_uri" => "http://www.rubydoc.info/gems/#{spec.name}/#{spec.version}" + } spec.bindir = "exe" spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } spec.require_paths = ["lib"] - spec.add_dependency "sentry-ruby", "~> 5.17.3" + spec.add_dependency "sentry-ruby", "~> 5.19.0" spec.add_dependency "sidekiq", ">= 3.0" end diff --git a/sentry-sidekiq/spec/sentry/rails_spec.rb b/sentry-sidekiq/spec/sentry/rails_spec.rb index 903f8ab38..87518ea06 100644 --- a/sentry-sidekiq/spec/sentry/rails_spec.rb +++ b/sentry-sidekiq/spec/sentry/rails_spec.rb @@ -4,6 +4,8 @@ require "sentry-rails" require "spec_helper" +require "action_controller/railtie" + class TestApp < Rails::Application end diff --git a/sentry-sidekiq/spec/sentry/sidekiq/sentry_context_middleware_spec.rb b/sentry-sidekiq/spec/sentry/sidekiq/sentry_context_middleware_spec.rb index f7c1a21a3..8d28577e7 100644 --- a/sentry-sidekiq/spec/sentry/sidekiq/sentry_context_middleware_spec.rb +++ b/sentry-sidekiq/spec/sentry/sidekiq/sentry_context_middleware_spec.rb @@ -57,6 +57,12 @@ expect(event.tags.keys).to include(:"sidekiq.marvel", :"sidekiq.dc") end + it "has the correct origin" do + execute_worker(processor, TagsWorker) + transaction = transport.events.last + expect(transaction.contexts.dig(:trace, :origin)).to eq('auto.queue.sidekiq') + end + context "with trace_propagation_headers" do let(:parent_transaction) { Sentry.start_transaction(op: "sidekiq") }