diff --git a/sentry-delayed_job/lib/sentry/delayed_job/plugin.rb b/sentry-delayed_job/lib/sentry/delayed_job/plugin.rb index 66093f340..135c4a313 100644 --- a/sentry-delayed_job/lib/sentry/delayed_job/plugin.rb +++ b/sentry-delayed_job/lib/sentry/delayed_job/plugin.rb @@ -102,7 +102,7 @@ def self.start_transaction(scope, env, contexts) } transaction = Sentry.continue_trace(env, **options) - Sentry.start_transaction(transaction: transaction, custom_sampling_context: contexts, **options) + Sentry.start_transaction(transaction: transaction, custom_sampling_context: contexts) end def self.finish_transaction(transaction, status) diff --git a/sentry-good_job/.gitignore b/sentry-good_job/.gitignore new file mode 100644 index 000000000..7bbe4fbc0 --- /dev/null +++ b/sentry-good_job/.gitignore @@ -0,0 +1,81 @@ +/.bundle/ +/.yardoc +/_yardoc/ +/coverage/ +/doc/ +/pkg/ +/spec/reports/ +/tmp/ +*.gem +*.rbc +/.config +/coverage/ +/InstalledFiles +/pkg/ +/spec/reports/ +/spec/examples.txt +/test/tmp/ +/test/version_tmp/ +/tmp/ + +# Environment variables +.env +.env.local +.env.*.local + +# Logs +*.log + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Coverage directory used by tools like istanbul +coverage/ + +# nyc test coverage +.nyc_output + +# Dependency directories +node_modules/ + +# Optional npm cache directory +.npm + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variables file +.env + +# next.js build output +.next + +# Nuxt.js build output +.nuxt + +# vuepress build output +.vuepress/dist + +# Serverless directories +.serverless + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# RSpec status file +.rspec_status diff --git a/sentry-good_job/.rspec b/sentry-good_job/.rspec new file mode 100644 index 000000000..4e33a322b --- /dev/null +++ b/sentry-good_job/.rspec @@ -0,0 +1,3 @@ +--require spec_helper +--color +--format documentation diff --git a/sentry-good_job/.rubocop.yml b/sentry-good_job/.rubocop.yml new file mode 100644 index 000000000..fc3c84d79 --- /dev/null +++ b/sentry-good_job/.rubocop.yml @@ -0,0 +1,19 @@ +inherit_gem: + rubocop-rails-omakase: rubocop.yml + +Layout/SpaceInsideArrayLiteralBrackets: + Enabled: false + +Layout/EmptyLineAfterMagicComment: + Enabled: true + +Style/FrozenStringLiteralComment: + Enabled: true + +Style/RedundantFreeze: + Enabled: true + +AllCops: + Exclude: + - "tmp/**/*" + - "examples/**/*" diff --git a/sentry-good_job/CHANGELOG.md b/sentry-good_job/CHANGELOG.md new file mode 100644 index 000000000..4f841795d --- /dev/null +++ b/sentry-good_job/CHANGELOG.md @@ -0,0 +1,34 @@ +# Changelog + +Individual gem's changelog has been deprecated. Please check the [project changelog](https://github.com/getsentry/sentry-ruby/blob/master/CHANGELOG.md). + +## 5.28.0 + +### Features + +- Initial release of sentry-good_job integration +- Automatic error capture for ActiveJob workers using Good Job +- Performance monitoring for job execution +- Automatic cron monitoring setup for scheduled jobs +- Context preservation and trace propagation +- Configurable error reporting options +- Rails integration with automatic setup + +### Configuration Options + +#### Good Job Specific Options +- `enable_cron_monitors`: Enable cron monitoring for scheduled jobs + +#### ActiveJob Options (handled by sentry-rails) +- `config.rails.active_job_report_on_retry_error`: Only report errors after all retry attempts are exhausted +- `config.send_default_pii`: Include job arguments in error context + +**Note**: The Good Job integration now leverages sentry-rails for core ActiveJob functionality, including trace propagation, user context preservation, and error reporting. + +### Integration Features + +- Seamless integration with Rails applications +- Automatic setup when Good Job integration is enabled +- Support for both manual and automatic cron monitoring +- Respects ActiveJob retry configuration +- Comprehensive error context and performance metrics diff --git a/sentry-good_job/Gemfile b/sentry-good_job/Gemfile new file mode 100644 index 000000000..02091cca3 --- /dev/null +++ b/sentry-good_job/Gemfile @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +source "https://rubygems.org" + +# Specify your gem's dependencies in sentry-good_job.gemspec +gemspec + +gem "rake", "~> 13.0" + +group :development, :test do + gem "rspec", "~> 3.0" + gem "rubocop", "~> 1.0" + gem "rubocop-rails-omakase", "~> 1.0" + gem "simplecov", "~> 0.22" + gem "simplecov-cobertura", "~> 2.0" +end diff --git a/sentry-good_job/LICENSE.txt b/sentry-good_job/LICENSE.txt new file mode 100644 index 000000000..908c02ece --- /dev/null +++ b/sentry-good_job/LICENSE.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Sentry + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/sentry-good_job/Makefile b/sentry-good_job/Makefile new file mode 100644 index 000000000..8f0435497 --- /dev/null +++ b/sentry-good_job/Makefile @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +.PHONY: test +test: + bundle exec rspec + +.PHONY: lint +lint: + bundle exec rubocop + +.PHONY: install +install: + bundle install + +.PHONY: console +console: + bundle exec bin/console + +.PHONY: setup +setup: + bundle install + bundle exec bin/setup diff --git a/sentry-good_job/README.md b/sentry-good_job/README.md new file mode 100644 index 000000000..9b4172640 --- /dev/null +++ b/sentry-good_job/README.md @@ -0,0 +1,203 @@ +

+ + + +
+

+ +# sentry-good_job, the Good Job integration for Sentry's Ruby client + +--- + +[![Gem Version](https://img.shields.io/gem/v/sentry-good_job.svg)](https://rubygems.org/gems/sentry-good_job) +![Build Status](https://github.com/getsentry/sentry-ruby/actions/workflows/sentry_good_job_test.yml/badge.svg) +[![Coverage Status](https://img.shields.io/codecov/c/github/getsentry/sentry-ruby/master?logo=codecov)](https://codecov.io/gh/getsentry/sentry-ruby/branch/master) +[![Gem](https://img.shields.io/gem/dt/sentry-good_job.svg)](https://rubygems.org/gems/sentry-good_job/) +[![SemVer](https://api.dependabot.com/badges/compatibility_score?dependency-name=sentry-good_job&package-manager=bundler&version-scheme=semver)](https://dependabot.com/compatibility-score.html?dependency-name=sentry-good_job&package-manager=bundler&version-scheme=semver) + +[Documentation](https://docs.sentry.io/platforms/ruby/guides/good_job/) | [Bug Tracker](https://github.com/getsentry/sentry-ruby/issues) | [Forum](https://forum.sentry.io/) | IRC: irc.freenode.net, #sentry + +The official Ruby-language client and integration layer for the [Sentry](https://github.com/getsentry/sentry) error reporting API. + +## Getting Started + +### Install + +```ruby +gem "sentry-ruby" +gem "sentry-rails" +gem "good_job" +gem "sentry-good_job" +``` + +Then you're all set! `sentry-good_job` will automatically capture exceptions from your ActiveJob workers when using Good Job as the backend! + +## Features + +- **Automatic Error Capture**: Captures exceptions from ActiveJob workers using Good Job +- **Performance Monitoring**: Tracks job execution times and performance metrics +- **Cron Monitoring**: Automatic setup for scheduled jobs with cron monitoring +- **Context Preservation**: Maintains user context and trace propagation across job executions +- **Configurable Reporting**: Control when errors are reported (after retries, only dead jobs, etc.) +- **Rails Integration**: Seamless integration with Rails applications + +## Configuration + +The integration can be configured through Sentry's configuration: + +```ruby +Sentry.init do |config| + config.dsn = 'your-dsn-here' + + # Good Job specific configuration + config.good_job.enable_cron_monitors = true + + # ActiveJob configuration (handled by sentry-rails) + config.rails.active_job_report_on_retry_error = false + config.send_default_pii = false + + # Optional: Configure logging for debugging + config.sdk_logger = Rails.logger +end +``` + +### Configuration Options + +#### Good Job Specific Options + +- `enable_cron_monitors` (default: `true`): Enable cron monitoring for scheduled jobs + +#### ActiveJob Options (handled by sentry-rails) + +- `config.rails.active_job_report_on_retry_error` (default: `false`): Only report errors after all retry attempts are exhausted +- `config.send_default_pii` (default: `false`): Include job arguments in error context (be careful with sensitive data) +- `config.sdk_logger` (default: `nil`): Configure the SDK logger for custom logging needs (general Sentry configuration) + +**Note**: The Good Job integration now leverages sentry-rails for core ActiveJob functionality, including trace propagation, user context preservation, and error reporting. This provides better integration and reduces duplication. + +## Usage + +### Automatic Setup + +The integration works automatically once installed. It will: + +1. **Capture exceptions** from ActiveJob workers using sentry-rails +2. **Set up performance monitoring** for job execution with enhanced GoodJob-specific metrics +3. **Automatically configure cron monitoring** for scheduled jobs +4. **Preserve user context and trace propagation** across job executions +5. **Add GoodJob-specific context** including queue name, executions, priority, and latency + +### Cron Monitoring + +For scheduled jobs, cron monitoring is automatically set up based on your Good Job configuration: + +```ruby +# config/application.rb +config.good_job.cron = { + 'my_scheduled_job' => { + class: 'MyScheduledJob', + cron: '0 * * * *' # Every hour + } +} +``` + +You can also manually set up cron monitoring: + +```ruby +class MyScheduledJob < ApplicationJob + include Sentry::Cron::MonitorCheckIns + + sentry_monitor_check_ins( + slug: "my_scheduled_job", + monitor_config: Sentry::Cron::MonitorConfig.from_crontab("0 * * * *", timezone: "UTC") + ) +end +``` + +### Custom Error Handling + +The integration respects ActiveJob's retry configuration and will only report errors based on your settings: + +```ruby +class MyJob < ApplicationJob + retry_on StandardError, wait: :exponentially_longer, attempts: 3 + + def perform + # This will only be reported to Sentry after 3 attempts if active_job_report_on_retry_error is true + raise "Something went wrong" + end +end +``` + +### Debugging and Detailed Logging + +The integration uses the standard Sentry SDK logger (`Sentry.configuration.sdk_logger`) for all logging needs. You can configure this logger to get detailed information about what the integration is doing: + +```ruby +Sentry.init do |config| + config.dsn = 'your-dsn-here' + + # Configure the SDK logger for debugging + config.sdk_logger = Logger.new($stdout) + config.sdk_logger.level = Logger::DEBUG + + # Or use Rails logger with debug level + # config.sdk_logger = Rails.logger + # config.sdk_logger.level = Logger::DEBUG +end +``` + +#### Log Levels + +The integration logs at different levels: +- **INFO**: Integration setup, cron monitoring configuration, job monitoring setup +- **WARN**: Configuration issues, missing job classes, cron parsing errors +- **DEBUG**: Detailed execution flow (when debug level is enabled) + +#### What Gets Logged + +When logging is enabled, you'll see information about: +- Job execution start and completion +- Error capture and reporting decisions +- Cron monitoring setup and configuration +- Performance metrics collection +- GoodJob-specific context enhancement +- Integration initialization and setup + +## Performance Monitoring + +When performance monitoring is enabled, the integration will track: + +- Job execution time +- Queue latency (GoodJob-specific) +- Retry counts +- Job context and metadata +- GoodJob-specific metrics (queue name, executions, priority) + +## Error Context + +The integration automatically adds relevant context to error reports: + +- Job class name +- Job ID +- Queue name (GoodJob-specific) +- Execution count (GoodJob-specific) +- Priority (GoodJob-specific) +- Enqueued and scheduled timestamps +- Job arguments (if enabled via send_default_pii) +- Latency metrics (GoodJob-specific) + +## Compatibility + +- Ruby 2.4+ +- Rails 5.2+ +- Good Job 3.0+ +- Sentry Ruby SDK 5.28.0+ + +## Contributing + +We welcome contributions! Please see our [contributing guidelines](https://github.com/getsentry/sentry-ruby/blob/master/CONTRIBUTING.md) for details. + +## License + +This project is licensed under the MIT License. See the [LICENSE](LICENSE.txt) file for details. diff --git a/sentry-good_job/Rakefile b/sentry-good_job/Rakefile new file mode 100644 index 000000000..b6ae73410 --- /dev/null +++ b/sentry-good_job/Rakefile @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +require "bundler/gem_tasks" +require "rspec/core/rake_task" + +RSpec::Core::RakeTask.new(:spec) + +task default: :spec diff --git a/sentry-good_job/bin/console b/sentry-good_job/bin/console new file mode 100755 index 000000000..05657330f --- /dev/null +++ b/sentry-good_job/bin/console @@ -0,0 +1,14 @@ +#!/usr/bin/env ruby + +require "bundler/setup" +require "sentry-good_job" + +# You can add fixtures and/or initialization code here to make experimenting +# with your gem easier. You can also use a different console, if you like. + +# (If you use this, don't forget to add pry to your Gemfile!) +# require "pry" +# Pry.start + +require "irb" +IRB.start(__FILE__) diff --git a/sentry-good_job/bin/setup b/sentry-good_job/bin/setup new file mode 100755 index 000000000..bffaa8904 --- /dev/null +++ b/sentry-good_job/bin/setup @@ -0,0 +1,19 @@ +#!/usr/bin/env ruby +require "fileutils" + +# path to your application root. +APP_ROOT = File.expand_path("..", __dir__) + +def system!(*args) + system(*args) || abort("\n== Command #{args} failed ==") +end + +FileUtils.chdir APP_ROOT do + # This script is a way to set up or update your development environment automatically. + # This script is idempotent, so that you can run it at any time and get an expectable outcome. + # Add necessary setup steps to this file. + + puts "== Installing dependencies ==" + system! "gem install bundler --conservative" + system("bundle check") || system!("bundle install") +end diff --git a/sentry-good_job/example/Gemfile b/sentry-good_job/example/Gemfile new file mode 100644 index 000000000..c28cdcbcb --- /dev/null +++ b/sentry-good_job/example/Gemfile @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +source "https://rubygems.org" + +gem "sentry-ruby" +gem "sentry-good_job" +gem "good_job" +gem "activejob" diff --git a/sentry-good_job/example/app.rb b/sentry-good_job/example/app.rb new file mode 100644 index 000000000..811d1caf1 --- /dev/null +++ b/sentry-good_job/example/app.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +require "bundler/setup" +require "sentry-good_job" +require "active_job" +require "good_job" + +# Configure Sentry +Sentry.init do |config| + config.dsn = ENV["SENTRY_DSN"] || "http://12345:67890@sentry.localdomain/sentry/42" + config.environment = "development" + config.logger = Logger.new(STDOUT) + + # Good Job specific configuration + # Logging is now handled by the standard Sentry SDK logger + + # ActiveJob configuration (handled by sentry-rails) + config.rails.active_job_report_on_retry_error = false + config.send_default_pii = true # Include job arguments +end + +# Example job classes +class HappyJob < ActiveJob::Base + def perform(message) + puts "Happy job executed with message: #{message}" + Sentry.add_breadcrumb(message: "Happy job completed successfully") + end +end + +class SadJob < ActiveJob::Base + def perform(message) + puts "Sad job executed with message: #{message}" + raise "Something went wrong in the sad job!" + end +end + +class ScheduledJob < ActiveJob::Base + def perform + puts "Scheduled job executed at #{Time.now}" + end +end + +# Example usage +puts "Sentry Good Job Integration Example" +puts "==================================" + +# Enqueue some jobs +HappyJob.perform_later("Hello from happy job!") +SadJob.perform_later("Hello from sad job!") + +puts "\nJobs enqueued. Check your Sentry dashboard for error reports." +puts "The sad job will generate an error that should be captured by Sentry." diff --git a/sentry-good_job/lib/sentry-good_job.rb b/sentry-good_job/lib/sentry-good_job.rb new file mode 100644 index 000000000..3cfa7cd35 --- /dev/null +++ b/sentry-good_job/lib/sentry-good_job.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require "good_job" +require "sentry-ruby" +require "sentry/integrable" +require "sentry/good_job/version" +require "sentry/good_job/configuration" +require "sentry/good_job/context_helpers" +require "sentry/good_job/active_job_extensions" +require "sentry/good_job/cron_helpers" + +module Sentry + module GoodJob + extend Sentry::Integrable + + register_integration name: "good_job", version: Sentry::GoodJob::VERSION + + if defined?(::Rails::Railtie) + class Railtie < ::Rails::Railtie + config.after_initialize do + next unless Sentry.initialized? && defined?(::Sentry::Rails) + + # Automatic setup for Good Job when the integration is enabled + if Sentry.configuration.enabled_patches.include?(:good_job) + Sentry::GoodJob.setup_good_job_integration + end + end + end + end + + def self.setup_good_job_integration + # Enhance sentry-rails ActiveJob integration with GoodJob-specific context + Sentry::GoodJob::ActiveJobExtensions.setup + + # Set up cron monitoring for all scheduled jobs (automatically configured from Good Job config) + if Sentry.configuration.good_job.enable_cron_monitors + Sentry::GoodJob::CronHelpers::Integration.setup_monitoring_for_scheduled_jobs + end + + Sentry.configuration.sdk_logger.info "[sentry-good_job] Sentry Good Job integration initialized automatically" + end + + # Delegate capture_exception so internal components can be tested in isolation + def self.capture_exception(exception, **options) + ::Sentry.capture_exception(exception, **options) + end + end +end diff --git a/sentry-good_job/lib/sentry/good_job.rb b/sentry-good_job/lib/sentry/good_job.rb new file mode 100644 index 000000000..b1bbae0b3 --- /dev/null +++ b/sentry-good_job/lib/sentry/good_job.rb @@ -0,0 +1,4 @@ +# frozen_string_literal: true + +# This file is kept for backward compatibility and simply requires the main entry point +require "sentry-good_job" diff --git a/sentry-good_job/lib/sentry/good_job/active_job_extensions.rb b/sentry-good_job/lib/sentry/good_job/active_job_extensions.rb new file mode 100644 index 000000000..a4c3260c1 --- /dev/null +++ b/sentry-good_job/lib/sentry/good_job/active_job_extensions.rb @@ -0,0 +1,137 @@ +# frozen_string_literal: true + +# GoodJob-specific extensions to sentry-rails ActiveJob integration +# This module enhances sentry-rails ActiveJob with GoodJob-specific functionality: +# - GoodJob-specific context and tags +# - GoodJob-specific span data enhancements +module Sentry + module GoodJob + module ActiveJobExtensions + # Enhance sentry-rails ActiveJob context with GoodJob-specific data + def self.enhance_sentry_context(job, base_context) + return base_context unless job.respond_to?(:queue_name) && job.respond_to?(:executions) + + # Add GoodJob-specific context to the existing sentry-rails context + good_job_context = { + queue_name: job.queue_name, + executions: job.executions, + enqueued_at: job.enqueued_at, + priority: job.respond_to?(:priority) ? job.priority : nil + } + + # Merge with base context, preserving existing structure + base_context.merge(good_job: good_job_context) + end + + # Enhance sentry-rails ActiveJob tags with GoodJob-specific data + def self.enhance_sentry_tags(job, base_tags) + return base_tags unless job.respond_to?(:queue_name) && job.respond_to?(:executions) + + good_job_tags = { + queue_name: job.queue_name, + executions: job.executions + } + + # Add priority if available + if job.respond_to?(:priority) + good_job_tags[:priority] = job.priority + end + + base_tags.merge(good_job_tags) + end + + # Set up GoodJob-specific ActiveJob extensions + def self.setup + return unless defined?(::Rails) && ::Sentry.initialized? + + # Hook into sentry-rails ActiveJob integration + if defined?(::Sentry::Rails::ActiveJobExtensions::SentryReporter) + enhance_sentry_reporter + end + + # Set up GoodJob-specific ActiveJob extensions + setup_good_job_extensions + end + + private + + def self.enhance_sentry_reporter + # Enhance the sentry_context method in SentryReporter + ::Sentry::Rails::ActiveJobExtensions::SentryReporter.class_eval do + class << self + alias_method :original_sentry_context, :sentry_context + + def sentry_context(job) + base_context = original_sentry_context(job) + Sentry::GoodJob::ActiveJobExtensions.enhance_sentry_context(job, base_context) + end + end + end + end + + def self.setup_good_job_extensions + # Extend ActiveJob::Base with GoodJob-specific functionality + ActiveSupport.on_load(:active_job) do + # Add GoodJob-specific attributes and methods + include GoodJobExtensions + + # Ensure the sentry-rails integration is properly set up + # by checking if the ActiveJobExtensions module is already included + if defined?(::Sentry::Rails::ActiveJobExtensions) && !ancestors.include?(::Sentry::Rails::ActiveJobExtensions) + require "sentry/rails/active_job" + prepend ::Sentry::Rails::ActiveJobExtensions + end + end + end + + # GoodJob-specific extensions for ActiveJob + module GoodJobExtensions + extend ActiveSupport::Concern + + included do + # Set up around_enqueue hook for GoodJob-specific enqueue span + around_enqueue do |job, block| + next block.call unless ::Sentry.initialized? + + # Create enqueue span with GoodJob-specific data + ::Sentry.with_child_span(op: "queue.publish", description: job.class.name) do |span| + _sentry_set_span_data(span, job) + block.call + end + end + + # GoodJob-specific context is now handled through the enhanced sentry_context method + # The sentry-rails integration handles error capturing through SentryReporter.record + end + + private + + # Override _sentry_set_span_data to add GoodJob-specific functionality + def _sentry_set_span_data(span, job, retry_count: nil) + return unless span + + # Call the base implementation if it exists (from sentry-rails) + if respond_to?(:_sentry_set_span_data, true) && method(:_sentry_set_span_data).super_method + super(span, job, retry_count: retry_count) + else + # Fallback: implement base functionality directly + span.set_data("messaging.message.id", job.job_id) + span.set_data("messaging.destination.name", job.queue_name) if job.respond_to?(:queue_name) + span.set_data("messaging.message.retry.count", retry_count) if retry_count + end + + # Add GoodJob-specific span data (latency) + latency = calculate_job_latency(job) + span.set_data("messaging.message.receive.latency", latency) if latency + end + + # Calculate job latency in milliseconds (GoodJob-specific) + def calculate_job_latency(job) + return nil unless job.enqueued_at + + ((Time.now.utc - job.enqueued_at) * 1000).to_i + end + end + end + end +end diff --git a/sentry-good_job/lib/sentry/good_job/configuration.rb b/sentry-good_job/lib/sentry/good_job/configuration.rb new file mode 100644 index 000000000..1ccfaad59 --- /dev/null +++ b/sentry-good_job/lib/sentry/good_job/configuration.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Sentry + class Configuration + attr_reader :good_job + + add_post_initialization_callback do + @good_job = Sentry::GoodJob::Configuration.new + @excluded_exceptions = @excluded_exceptions.concat(Sentry::GoodJob::IGNORE_DEFAULT) + end + end + + module GoodJob + IGNORE_DEFAULT = [ + "ActiveJob::DeserializationError", + "ActiveJob::SerializationError" + ] + + class Configuration + # Whether to enable cron monitoring for all scheduled jobs + # This is GoodJob-specific functionality for monitoring scheduled tasks + attr_accessor :enable_cron_monitors + + def initialize + @enable_cron_monitors = true + end + end + end +end diff --git a/sentry-good_job/lib/sentry/good_job/context_helpers.rb b/sentry-good_job/lib/sentry/good_job/context_helpers.rb new file mode 100644 index 000000000..9bc1d7239 --- /dev/null +++ b/sentry-good_job/lib/sentry/good_job/context_helpers.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +# Helper methods for adding GoodJob-specific information to Sentry context +# This works WITH sentry-rails, not against it +module Sentry + module GoodJob + module ContextHelpers + # Add GoodJob-specific information to the existing Sentry Rails context + def self.add_context(job, base_context = {}) + return base_context unless job.respond_to?(:queue_name) && job.respond_to?(:executions) + + good_job_context = { + queue_name: job.queue_name, + executions: job.executions, + enqueued_at: job.enqueued_at, + priority: job.respond_to?(:priority) ? job.priority : nil + } + + # Note: Job arguments are handled by sentry-rails via send_default_pii configuration + # This is controlled by Sentry.configuration.send_default_pii, not GoodJob-specific config + + # Merge with base context + base_context.merge(good_job: good_job_context) + end + + # Add GoodJob-specific information to the existing Sentry Rails tags + def self.add_tags(job, base_tags = {}) + return base_tags unless job.respond_to?(:queue_name) && job.respond_to?(:executions) + + good_job_tags = { + queue_name: job.queue_name, + executions: job.executions + } + + # Add priority if available + if job.respond_to?(:priority) + good_job_tags[:priority] = job.priority + end + + base_tags.merge(good_job_tags) + end + + # Enhanced context that includes both ActiveJob and GoodJob-specific data + def self.enhanced_context(job) + # Start with sentry-rails ActiveJob context + active_job_context = { + active_job: job.class.name, + arguments: job.respond_to?(:arguments) ? job.arguments.map(&:inspect) : [], + scheduled_at: job.scheduled_at, + job_id: job.job_id, + provider_job_id: job.provider_job_id, + locale: job.locale + } + + # Add GoodJob-specific context + good_job_context = { + queue_name: job.queue_name, + executions: job.executions, + enqueued_at: job.enqueued_at, + priority: job.respond_to?(:priority) ? job.priority : nil + } + + { + active_job: active_job_context, + good_job: good_job_context + } + end + + # Enhanced tags that include both ActiveJob and GoodJob-specific data + def self.enhanced_tags(job) + base_tags = { + job_id: job.job_id, + provider_job_id: job.provider_job_id + } + + good_job_tags = { + queue_name: job.queue_name, + executions: job.executions + } + + if job.respond_to?(:priority) + good_job_tags[:priority] = job.priority + end + + base_tags.merge(good_job_tags) + end + end + end +end diff --git a/sentry-good_job/lib/sentry/good_job/cron_helpers.rb b/sentry-good_job/lib/sentry/good_job/cron_helpers.rb new file mode 100644 index 000000000..92727629a --- /dev/null +++ b/sentry-good_job/lib/sentry/good_job/cron_helpers.rb @@ -0,0 +1,195 @@ +# frozen_string_literal: true + +# Sentry Cron Monitoring for Active Job +# This module provides comprehensive cron monitoring for Active Job scheduled tasks +# It works with any Active Job adapter, including GoodJob +# Following Active Job's extension patterns and Sentry's integration guidelines +module Sentry + module GoodJob + module CronHelpers + # Utility methods for cron parsing and configuration + # These methods handle the conversion between Good Job cron expressions and Sentry monitor configs + module Helpers + # Parse cron expression and create Sentry monitor config + def self.monitor_config_from_cron(cron_expression, timezone: nil) + return nil unless cron_expression && !cron_expression.strip.empty? + + # Parse cron expression using fugit (same as Good Job) + parsed_cron = Fugit.parse_cron(cron_expression) + return nil unless parsed_cron + + # Convert to Sentry monitor config + if timezone && !timezone.strip.empty? + ::Sentry::Cron::MonitorConfig.from_crontab(cron_expression, timezone: timezone) + else + ::Sentry::Cron::MonitorConfig.from_crontab(cron_expression) + end + rescue => e + Sentry.configuration.sdk_logger.warn "[sentry-good_job] Failed to parse cron expression '#{cron_expression}': #{e.message}" + nil + end + + # Generate monitor slug from job name + def self.monitor_slug(job_name) + job_name.to_s.underscore.gsub(/_job$/, "") + end + + # Parse cron expression and extract timezone + def self.parse_cron_with_timezone(cron_expression) + return [cron_expression, nil] unless cron_expression && !cron_expression.strip.empty? + + parts = cron_expression.strip.split(" ") + return [cron_expression, nil] unless parts.length > 5 + + # Last part might be timezone + timezone = parts.last + # Comprehensive timezone validation that handles: + # - Standard timezone names (UTC, GMT) + # - IANA timezone identifiers (America/New_York, Europe/Stockholm) + # - Multi-level IANA timezones (America/Argentina/Buenos_Aires) + # - UTC offsets (UTC+2, UTC-5, GMT+1, GMT-8) + # - Numeric timezones (GMT-5, UTC+2) + if timezone.match?(/^[A-Za-z_]+$/) || # Simple timezone names (UTC, GMT, EST, etc.) + timezone.match?(/^[A-Za-z_]+\/[A-Za-z_]+$/) || # Single slash timezones (Europe/Stockholm) + timezone.match?(/^[A-Za-z_]+\/[A-Za-z_]+\/[A-Za-z_]+$/) || # Multi-slash timezones (America/Argentina/Buenos_Aires) + timezone.match?(/^[A-Za-z_]+[+-]\d+$/) || # UTC/GMT offsets (UTC+2, GMT-5) + timezone.match?(/^[A-Za-z_]+\/[A-Za-z_]+[+-]\d+$/) # IANA with offset (Europe/Stockholm+1) + cron_without_timezone = cron_expression.gsub(/\s+#{Regexp.escape(timezone)}$/, "") + [cron_without_timezone, timezone] + else + [cron_expression, nil] + end + end + end + + # Main integration class that handles all cron monitoring setup + # This class follows Good Job's integration patterns and Sentry's extension guidelines + class Integration + # Track whether setup has already been performed to prevent duplicates + @setup_completed = false + + # Set up monitoring for all scheduled jobs from Good Job configuration + def self.setup_monitoring_for_scheduled_jobs + return unless ::Sentry.initialized? + return unless ::Sentry.configuration.good_job.enable_cron_monitors + return if @setup_completed + + return unless defined?(::Rails) && ::Rails.respond_to?(:application) && ::Rails.application + cron_config = ::Rails.application.config.good_job.cron + return unless cron_config.present? + + added_jobs = [] + cron_config.each do |cron_key, job_config| + job_name = setup_monitoring_for_job(cron_key, job_config) + added_jobs << job_name if job_name + end + + @setup_completed = true + if added_jobs.any? + job_list = added_jobs.join(", ") + Sentry.configuration.sdk_logger.info "[sentry-good_job] Sentry cron monitoring setup for #{added_jobs.size} scheduled jobs: #{job_list}" + else + Sentry.configuration.sdk_logger.info "[sentry-good_job] Sentry cron monitoring setup for #{cron_config.keys.size} scheduled jobs" + end + end + + # Reset setup state (primarily for testing) + def self.reset_setup_state! + @setup_completed = false + end + + # Set up monitoring for a specific job + def self.setup_monitoring_for_job(cron_key, job_config) + job_class_name = job_config[:class] + cron_expression = job_config[:cron] + + return unless job_class_name && cron_expression + + # Defer job class constantization to avoid boot-time issues + # The job class will be constantized when the job is actually executed + # This prevents issues during development boot and circular dependencies + + # Store the monitoring configuration for later use + # We'll set up the monitoring when the job class is first loaded + deferred_setup = lambda do + job_class = begin + job_class_name.constantize + rescue NameError => e + Sentry.configuration.sdk_logger.warn "[sentry-good_job] Could not find job class '#{job_class_name}' for Sentry cron monitoring: #{e.message}" + return + end + + # Include Sentry::Cron::MonitorCheckIns module for cron monitoring + # only patch if not explicitly included in job by user + unless job_class.ancestors.include?(Sentry::Cron::MonitorCheckIns) + job_class.include(Sentry::Cron::MonitorCheckIns) + end + + # Parse cron expression and create monitor config + cron_without_tz, timezone = Sentry::GoodJob::CronHelpers::Helpers.parse_cron_with_timezone(cron_expression) + monitor_config = Sentry::GoodJob::CronHelpers::Helpers.monitor_config_from_cron(cron_without_tz, timezone: timezone) + + if monitor_config + # Configure Sentry cron monitoring - use cron_key as slug for consistency + monitor_slug = Sentry::GoodJob::CronHelpers::Helpers.monitor_slug(cron_key) + + job_class.sentry_monitor_check_ins( + slug: monitor_slug, + monitor_config: monitor_config + ) + + job_class_name + else + Sentry.configuration.sdk_logger.warn "[sentry-good_job] Could not create monitor config for #{job_class_name} with cron '#{cron_expression}'" + nil + end + end + + # Set up monitoring when the job class is first loaded + # This defers constantization until the job is actually needed + if defined?(::Rails) && ::Rails.respond_to?(:application) && ::Rails.application + ::Rails.application.config.after_initialize do + deferred_setup.call + end + else + # Fallback for non-Rails environments + deferred_setup.call + end + + # Return the job name for logging purposes + job_class_name + end + + # Manually add cron monitoring to a specific job + def self.add_monitoring_to_job(job_class, slug: nil, cron_expression: nil, timezone: nil) + return unless ::Sentry.initialized? + + # Include Sentry::Cron::MonitorCheckIns module for cron monitoring + # only patch if not explicitly included in job by user + unless job_class.ancestors.include?(Sentry::Cron::MonitorCheckIns) + job_class.include(Sentry::Cron::MonitorCheckIns) + end + + # Create monitor config + monitor_config = if cron_expression + Sentry::GoodJob::CronHelpers::Helpers.monitor_config_from_cron(cron_expression, timezone: timezone) + else + # Default to hourly monitoring if no cron expression provided + ::Sentry::Cron::MonitorConfig.from_crontab("0 * * * *") + end + + if monitor_config + monitor_slug = slug || Sentry::GoodJob::CronHelpers::Helpers.monitor_slug(job_class.name) + + job_class.sentry_monitor_check_ins( + slug: monitor_slug, + monitor_config: monitor_config + ) + + Sentry.configuration.sdk_logger.info "[sentry-good_job] Added Sentry cron monitoring for #{job_class.name} (#{monitor_slug})" + end + end + end + end + end +end diff --git a/sentry-good_job/lib/sentry/good_job/version.rb b/sentry-good_job/lib/sentry/good_job/version.rb new file mode 100644 index 000000000..04f60d81c --- /dev/null +++ b/sentry-good_job/lib/sentry/good_job/version.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module Sentry + module GoodJob + VERSION = "5.28.0" + end +end diff --git a/sentry-good_job/sentry-good_job.gemspec b/sentry-good_job/sentry-good_job.gemspec new file mode 100644 index 000000000..86b7432bf --- /dev/null +++ b/sentry-good_job/sentry-good_job.gemspec @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require_relative "lib/sentry/good_job/version" + +Gem::Specification.new do |spec| + spec.name = "sentry-good_job" + spec.version = Sentry::GoodJob::VERSION + spec.authors = ["Sentry Team"] + spec.description = spec.summary = "A gem that provides Good Job integration for the Sentry error logger" + spec.email = "accounts@sentry.io" + spec.license = 'MIT' + + 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|\.rubocop\.yml)'`.split("\n") + + 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.28.0" + spec.add_dependency "good_job", ">= 3.0" +end diff --git a/sentry-good_job/spec/sentry/good_job/configuration_spec.rb b/sentry-good_job/spec/sentry/good_job/configuration_spec.rb new file mode 100644 index 000000000..c1c8b92c1 --- /dev/null +++ b/sentry-good_job/spec/sentry/good_job/configuration_spec.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe Sentry::GoodJob::Configuration do + before do + perform_basic_setup + end + + let(:config) { Sentry.configuration.good_job } + + describe "default values" do + it "sets default values correctly" do + expect(config.enable_cron_monitors).to eq(true) + end + end + + describe "IGNORE_DEFAULT" do + it "includes expected exceptions" do + expect(Sentry::GoodJob::IGNORE_DEFAULT).to include( + "ActiveJob::DeserializationError", + "ActiveJob::SerializationError" + ) + end + end + + describe "configuration attributes" do + # Removed configuration options that are now handled by sentry-rails: + # - report_after_job_retries (use sentry-rails active_job_report_on_retry_error) + # - report_only_discarded_jobs (handled by ActiveJob retry/discard logic) + # - propagate_traces (handled by sentry-rails) + # - include_job_arguments (use sentry-rails send_default_pii) + + it "allows setting enable_cron_monitors" do + config.enable_cron_monitors = false + expect(config.enable_cron_monitors).to eq(false) + end + end +end diff --git a/sentry-good_job/spec/sentry/good_job/cron_helpers_spec.rb b/sentry-good_job/spec/sentry/good_job/cron_helpers_spec.rb new file mode 100644 index 000000000..836e6cdf5 --- /dev/null +++ b/sentry-good_job/spec/sentry/good_job/cron_helpers_spec.rb @@ -0,0 +1,322 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe Sentry::GoodJob::CronHelpers do + before do + perform_basic_setup + end + + describe "Helpers" do + describe ".monitor_config_from_cron" do + it "returns nil for empty cron expression" do + expect(described_class::Helpers.monitor_config_from_cron("")).to be_nil + end + + it "returns nil for nil cron expression" do + expect(described_class::Helpers.monitor_config_from_cron(nil)).to be_nil + end + + it "creates monitor config for valid cron expression" do + config = described_class::Helpers.monitor_config_from_cron("0 * * * *") + expect(config).to be_a(Sentry::Cron::MonitorConfig) + end + + it "creates monitor config with timezone" do + config = described_class::Helpers.monitor_config_from_cron("0 * * * *", timezone: "UTC") + expect(config).to be_a(Sentry::Cron::MonitorConfig) + end + + it "handles parsing errors gracefully" do + allow(Fugit).to receive(:parse_cron).and_raise(StandardError.new("Invalid cron")) + allow(Sentry.configuration.sdk_logger).to receive(:warn) + + result = described_class::Helpers.monitor_config_from_cron("invalid") + + expect(result).to be_nil + expect(Sentry.configuration.sdk_logger).to have_received(:warn) + end + end + + describe ".monitor_slug" do + it "converts job name to slug" do + expect(described_class::Helpers.monitor_slug("TestJob")).to eq("test") + end + + it "removes _job suffix" do + expect(described_class::Helpers.monitor_slug("TestJob")).to eq("test") + end + + it "handles snake_case names" do + expect(described_class::Helpers.monitor_slug("test_job")).to eq("test") + end + end + + describe ".parse_cron_with_timezone" do + it "returns cron and nil timezone for simple cron" do + cron, timezone = described_class::Helpers.parse_cron_with_timezone("0 * * * *") + expect(cron).to eq("0 * * * *") + expect(timezone).to be_nil + end + + it "extracts timezone from cron with timezone" do + cron, timezone = described_class::Helpers.parse_cron_with_timezone("0 * * * * UTC") + expect(cron).to eq("0 * * * *") + expect(timezone).to eq("UTC") + end + + it "extracts complex timezone from cron" do + cron, timezone = described_class::Helpers.parse_cron_with_timezone("0 * * * * Europe/Stockholm") + expect(cron).to eq("0 * * * *") + expect(timezone).to eq("Europe/Stockholm") + end + + it "handles invalid timezone format" do + cron, timezone = described_class::Helpers.parse_cron_with_timezone("0 * * * * invalid@timezone") + expect(cron).to eq("0 * * * * invalid@timezone") + expect(timezone).to be_nil + end + + it "returns original cron for short expressions" do + cron, timezone = described_class::Helpers.parse_cron_with_timezone("0 * * *") + expect(cron).to eq("0 * * *") + expect(timezone).to be_nil + end + + it "handles multi-slash timezones" do + cron, timezone = described_class::Helpers.parse_cron_with_timezone("0 * * * * America/Argentina/Buenos_Aires") + expect(cron).to eq("0 * * * *") + expect(timezone).to eq("America/Argentina/Buenos_Aires") + end + + it "handles GMT offsets" do + cron, timezone = described_class::Helpers.parse_cron_with_timezone("0 * * * * GMT-5") + expect(cron).to eq("0 * * * *") + expect(timezone).to eq("GMT-5") + end + + it "handles UTC offsets" do + cron, timezone = described_class::Helpers.parse_cron_with_timezone("0 * * * * UTC+2") + expect(cron).to eq("0 * * * *") + expect(timezone).to eq("UTC+2") + end + + it "handles timezones with underscores" do + cron, timezone = described_class::Helpers.parse_cron_with_timezone("0 * * * * America/New_York") + expect(cron).to eq("0 * * * *") + expect(timezone).to eq("America/New_York") + end + + it "handles timezones with positive offsets" do + cron, timezone = described_class::Helpers.parse_cron_with_timezone("0 * * * * GMT+1") + expect(cron).to eq("0 * * * *") + expect(timezone).to eq("GMT+1") + end + + it "handles timezones with negative offsets" do + cron, timezone = described_class::Helpers.parse_cron_with_timezone("0 * * * * UTC-8") + expect(cron).to eq("0 * * * *") + expect(timezone).to eq("UTC-8") + end + end + end + + describe "Integration" do + let(:rails_app) { double("RailsApplication") } + let(:rails_config) { double("RailsConfig") } + let(:good_job_config) { double("GoodJobConfig") } + + before do + stub_const("Rails", double("Rails")) + allow(Rails).to receive(:application).and_return(rails_app) + allow(rails_app).to receive(:config).and_return(rails_config) + allow(rails_config).to receive(:good_job).and_return(good_job_config) + end + + describe ".setup_monitoring_for_scheduled_jobs" do + context "when Sentry is not initialized" do + before do + allow(Sentry).to receive(:initialized?).and_return(false) + end + + it "does not set up monitoring" do + expect(described_class::Integration).not_to receive(:setup_monitoring_for_job) + described_class::Integration.setup_monitoring_for_scheduled_jobs + end + end + + context "when enable_cron_monitors is disabled" do + before do + allow(Sentry).to receive(:initialized?).and_return(true) + Sentry.configuration.good_job.enable_cron_monitors = false + end + + it "does not set up monitoring" do + expect(described_class::Integration).not_to receive(:setup_monitoring_for_job) + described_class::Integration.setup_monitoring_for_scheduled_jobs + end + end + + context "when cron config is not present" do + before do + allow(Sentry).to receive(:initialized?).and_return(true) + Sentry.configuration.good_job.enable_cron_monitors = true + allow(good_job_config).to receive(:cron).and_return(nil) + end + + it "does not set up monitoring" do + expect(described_class::Integration).not_to receive(:setup_monitoring_for_job) + described_class::Integration.setup_monitoring_for_scheduled_jobs + end + end + + context "when cron config is present" do + let(:cron_config) do + { + "test_job" => { class: "TestJob", cron: "0 * * * *" }, + "another_job" => { class: "AnotherJob", cron: "0 0 * * *" } + } + end + + before do + allow(Sentry).to receive(:initialized?).and_return(true) + Sentry.configuration.good_job.enable_cron_monitors = true + allow(good_job_config).to receive(:cron).and_return(cron_config) + end + + it "sets up monitoring for each job" do + described_class::Integration.reset_setup_state! + expect(described_class::Integration).to receive(:setup_monitoring_for_job).with("test_job", cron_config["test_job"]) + expect(described_class::Integration).to receive(:setup_monitoring_for_job).with("another_job", cron_config["another_job"]) + + described_class::Integration.setup_monitoring_for_scheduled_jobs + end + + it "logs the setup completion" do + described_class::Integration.reset_setup_state! + allow(described_class::Integration).to receive(:setup_monitoring_for_job).and_return("TestJob", "AnotherJob") + allow(Sentry.configuration.sdk_logger).to receive(:info) + + described_class::Integration.setup_monitoring_for_scheduled_jobs + + expect(Sentry.configuration.sdk_logger).to have_received(:info).with("[sentry-good_job] Sentry cron monitoring setup for 2 scheduled jobs: TestJob, AnotherJob") + end + end + end + + describe ".setup_monitoring_for_job" do + let(:job_class) { Class.new(ActiveJob::Base) } + + before do + allow(Sentry).to receive(:initialized?).and_return(true) + stub_const("TestJob", job_class) + end + + context "when job class is missing" do + let(:job_config) { { class: "NonExistentJob", cron: "0 * * * *" } } + + it "logs a warning and returns" do + allow(Sentry.configuration.sdk_logger).to receive(:warn) + # Mock Rails.application.config.after_initialize to execute immediately + allow(Rails.application.config).to receive(:after_initialize).and_yield + + described_class::Integration.setup_monitoring_for_job("test_job", job_config) + + expect(Sentry.configuration.sdk_logger).to have_received(:warn).with(/Could not find job class/) + end + end + + context "when job config is missing class" do + let(:job_config) { { cron: "0 * * * *" } } + + it "does not set up monitoring" do + # No job monitoring setup needed since we removed JobMonitor + described_class::Integration.setup_monitoring_for_job("test_job", job_config) + end + end + + context "when job config is missing cron" do + let(:job_config) { { class: "TestJob" } } + + it "does not set up monitoring" do + # No job monitoring setup needed since we removed JobMonitor + described_class::Integration.setup_monitoring_for_job("test_job", job_config) + end + end + + context "when job config is complete" do + let(:job_config) { { class: "TestJob", cron: "0 * * * *" } } + + it "sets up cron monitoring" do + expect(job_class).to receive(:include).with(Sentry::Cron::MonitorCheckIns).at_least(:once) + expect(job_class).to receive(:sentry_monitor_check_ins) + # Mock Rails.application.config.after_initialize to execute immediately + allow(Rails.application.config).to receive(:after_initialize).and_yield + described_class::Integration.setup_monitoring_for_job("test_job", job_config) + end + + it "sets up cron monitoring with proper configuration" do + expect(job_class).to receive(:sentry_monitor_check_ins) + # Mock Rails.application.config.after_initialize to execute immediately + allow(Rails.application.config).to receive(:after_initialize).and_yield + described_class::Integration.setup_monitoring_for_job("test_job", job_config) + end + + it "returns the job name when setup is successful" do + allow(job_class).to receive(:sentry_monitor_check_ins) + # Mock Rails.application.config.after_initialize to execute immediately + allow(Rails.application.config).to receive(:after_initialize).and_yield + + result = described_class::Integration.setup_monitoring_for_job("test_job", job_config) + + expect(result).to eq("TestJob") + end + end + end + + describe ".add_monitoring_to_job" do + let(:job_class) { Class.new(ActiveJob::Base) } + + before do + allow(Sentry).to receive(:initialized?).and_return(true) + end + + it "sets up cron monitoring" do + expect(job_class).to receive(:include).with(Sentry::Cron::MonitorCheckIns).at_least(:once) + expect(job_class).to receive(:sentry_monitor_check_ins) + described_class::Integration.add_monitoring_to_job(job_class) + end + + it "sets up cron monitoring with default config" do + # JobMonitor removed - no setup needed + expect(job_class).to receive(:sentry_monitor_check_ins) + + described_class::Integration.add_monitoring_to_job(job_class) + end + + it "uses provided slug" do + # JobMonitor removed - no setup needed + expect(job_class).to receive(:sentry_monitor_check_ins).with(hash_including(slug: "custom_slug")) + + described_class::Integration.add_monitoring_to_job(job_class, slug: "custom_slug") + end + + it "uses provided cron expression" do + # JobMonitor removed - no setup needed + expect(job_class).to receive(:sentry_monitor_check_ins) + + described_class::Integration.add_monitoring_to_job(job_class, cron_expression: "0 0 * * *") + end + + it "logs the setup completion" do + # JobMonitor removed - no setup needed + allow(job_class).to receive(:sentry_monitor_check_ins) + allow(Sentry.configuration.sdk_logger).to receive(:info) + + described_class::Integration.add_monitoring_to_job(job_class) + + expect(Sentry.configuration.sdk_logger).to have_received(:info).with(/Added Sentry cron monitoring/) + end + end + end +end diff --git a/sentry-good_job/spec/sentry/good_job/integration_spec.rb b/sentry-good_job/spec/sentry/good_job/integration_spec.rb new file mode 100644 index 000000000..59bc5cce4 --- /dev/null +++ b/sentry-good_job/spec/sentry/good_job/integration_spec.rb @@ -0,0 +1,164 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe "Sentry GoodJob Integration", type: :job do + before do + perform_basic_setup + # Use :inline adapter to get real GoodJob behavior with immediate execution + # This provides actual GoodJob attributes like queue_name, executions, priority, etc. + ActiveJob::Base.queue_adapter = :inline + + # Set up the GoodJob integration + Sentry::GoodJob.setup_good_job_integration + end + + let(:transport) do + Sentry.get_current_client.transport + end + + # Test job classes that will be used in integration tests + class TestGoodJob < ActiveJob::Base + def perform(message) + "Processed: #{message}" + end + end + + class FailingGoodJob < ActiveJob::Base + def perform(message) + raise StandardError, "Job failed: #{message}" + end + end + + class GoodJobWithContext < ActiveJob::Base + def perform(user_id) + Sentry.set_user(id: user_id) + Sentry.set_tags(job_type: "context_test") + raise StandardError, "Context test error" + end + end + + describe "GoodJob extensions integration" do + it "successfully executes jobs without errors" do + result = TestGoodJob.perform_now("test message") + expect(result).to eq("Processed: test message") + # No events should be captured for successful jobs + expect(transport.events.size).to eq(0) + end + + it "verifies GoodJob extensions are properly included" do + # Test that the GoodJob extensions are working by checking that + # the GoodJobExtensions module is included in ActiveJob::Base + expect(ActiveJob::Base.ancestors).to include(Sentry::GoodJob::ActiveJobExtensions::GoodJobExtensions) + end + + it "verifies GoodJob-specific methods are available" do + # Test that the GoodJob-specific methods are available + job = TestGoodJob.new("test") + + # The GoodJob extensions should add the _sentry_set_span_data method + # Note: This method is private, so we test it indirectly + expect(job.class.private_method_defined?(:_sentry_set_span_data)).to be true + end + + it "verifies GoodJob context helpers work correctly" do + # Test that the GoodJob context helpers work correctly + job = TestGoodJob.new("test") + + # Test the enhance_sentry_context method + base_context = { "active_job" => "TestGoodJob" } + enhanced_context = Sentry::GoodJob::ActiveJobExtensions.enhance_sentry_context(job, base_context) + + # The enhanced context should include GoodJob-specific data + expect(enhanced_context).to include(:good_job) + expect(enhanced_context[:good_job]).to include(:queue_name, :executions) + end + + end + + describe "integration setup" do + it "sets up GoodJob extensions when integration is enabled" do + # This test verifies that the integration properly sets up the extensions + # by checking that the GoodJobExtensions module is included in ActiveJob::Base + expect(ActiveJob::Base.ancestors).to include(Sentry::GoodJob::ActiveJobExtensions::GoodJobExtensions) + end + + it "verifies GoodJob integration is properly configured" do + # Test that the GoodJob integration is properly configured + expect(Sentry.configuration.good_job).to be_present + expect(Sentry.configuration.good_job.enable_cron_monitors).to be true + end + end + + describe "GoodJob extensions functionality" do + # Test job classes that simulate real application jobs + class AppUserJob < ActiveJob::Base + def perform(user_id, action) + # Simulate user-related job processing + Sentry.set_user(id: user_id) + Sentry.set_tags(job_type: "user_processing", action: action) + + # Simulate some work + sleep(0.01) if action == "slow_processing" + + raise StandardError, "User processing failed: #{action}" if action == "fail" + + "User #{user_id} processed for #{action}" + end + end + + class AppDataJob < ActiveJob::Base + def perform(data, count) + # Simulate data processing job + Sentry.set_tags(job_type: "data_processing", data_size: data.length, count: count) + + # Simulate some work + sleep(0.01) if count > 2 + + raise StandardError, "Data processing failed: #{data}" if data == "fail" + + "Processed #{count} items of data: #{data}" + end + end + + it "verifies GoodJob extensions work with different job types" do + # Test that the GoodJob extensions work with different job types + user_job = AppUserJob.new("user1", "update") + data_job = AppDataJob.new("test", 3) + + # Both jobs should have the GoodJob extensions + expect(user_job.class.ancestors).to include(Sentry::GoodJob::ActiveJobExtensions::GoodJobExtensions) + expect(data_job.class.ancestors).to include(Sentry::GoodJob::ActiveJobExtensions::GoodJobExtensions) + end + + it "verifies GoodJob context enhancement works correctly" do + # Test that the GoodJob context enhancement works correctly + job = AppUserJob.new("user1", "update") + + # Test the enhance_sentry_context method + base_context = { "active_job" => "AppUserJob" } + enhanced_context = Sentry::GoodJob::ActiveJobExtensions.enhance_sentry_context(job, base_context) + + # The enhanced context should include GoodJob-specific data + expect(enhanced_context).to include(:good_job) + expect(enhanced_context[:good_job]).to include(:queue_name, :executions) + expect(enhanced_context[:good_job][:queue_name]).to eq("default") + expect(enhanced_context[:good_job][:executions]).to eq(0) + end + + it "verifies GoodJob span data methods work correctly" do + # Test that the GoodJob span data methods work correctly + job = AppUserJob.new("user1", "update") + + # Create a mock span that expects the set_data calls + span = double("span") + allow(span).to receive(:set_data) + + # Test that the private method exists and can be called via send + expect { job.send(:_sentry_set_span_data, span, job) }.not_to raise_error + + # Test that the method is properly defined + expect(job.class.private_method_defined?(:_sentry_set_span_data)).to be true + end + end +end diff --git a/sentry-good_job/spec/sentry/good_job_spec.rb b/sentry-good_job/spec/sentry/good_job_spec.rb new file mode 100644 index 000000000..928280536 --- /dev/null +++ b/sentry-good_job/spec/sentry/good_job_spec.rb @@ -0,0 +1,103 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe Sentry::GoodJob do + before do + perform_basic_setup + end + + let(:transport) do + Sentry.get_current_client.transport + end + + it "registers the integration" do + expect(Sentry.integrations).to have_key("good_job") + end + + it "has the correct version" do + expect(described_class::VERSION).to eq("5.28.0") + end + + describe "setup_good_job_integration" do + before do + # Mock Rails application configuration + rails_app = double("Rails::Application") + rails_config = double("Rails::Configuration") + good_job_config = double("GoodJob::Configuration") + + allow(::Rails).to receive(:application).and_return(rails_app) + allow(rails_app).to receive(:config).and_return(rails_config) + allow(rails_config).to receive(:good_job).and_return(good_job_config) + allow(good_job_config).to receive(:cron).and_return({}) + end + + it "does not automatically set up job monitoring for any specific job class" do + # The integration now only sets up cron monitoring, not custom job monitoring + expect(Sentry::GoodJob::CronHelpers::Integration).to receive(:setup_monitoring_for_scheduled_jobs) + + described_class.setup_good_job_integration + end + + it "sets up cron monitoring when enabled" do + expect(Sentry::GoodJob::CronHelpers::Integration).to receive(:setup_monitoring_for_scheduled_jobs) + + described_class.setup_good_job_integration + end + + context "when enable_cron_monitors is enabled" do + before do + Sentry.configuration.good_job.enable_cron_monitors = true + end + + it "sets up cron monitoring" do + expect(Sentry::GoodJob::CronHelpers::Integration).to receive(:setup_monitoring_for_scheduled_jobs) + + described_class.setup_good_job_integration + end + end + + context "when enable_cron_monitors is disabled" do + before do + Sentry.configuration.good_job.enable_cron_monitors = false + end + + it "does not set up cron monitoring" do + expect(Sentry::GoodJob::CronHelpers::Integration).not_to receive(:setup_monitoring_for_scheduled_jobs) + + described_class.setup_good_job_integration + end + end + end + + describe "capture_exception" do + it "delegates to Sentry.capture_exception" do + exception = build_exception + options = { hint: { background: true } } + + expect(Sentry).to receive(:capture_exception).with(exception, **options) + + described_class.capture_exception(exception, **options) + end + end + + describe "Rails integration" do + before do + # Mock Rails configuration + rails_config = double("Rails::Configuration") + allow(rails_config).to receive(:good_job).and_return(double("GoodJobConfig")) + + # Mock Sentry Rails configuration + sentry_rails_config = double("Sentry::Rails::Configuration") + allow(sentry_rails_config).to receive(:skippable_job_adapters).and_return([]) + + allow(Sentry.configuration).to receive(:rails).and_return(sentry_rails_config) + end + + it "adds GoodJobAdapter to skippable_job_adapters" do + # This test verifies that the integration would add the adapter to the skippable list + # In a real Rails environment, this would be done by the Railtie + expect(Sentry.configuration.rails.skippable_job_adapters).to be_an(Array) + end + end +end diff --git a/sentry-good_job/spec/spec_helper.rb b/sentry-good_job/spec/spec_helper.rb new file mode 100644 index 000000000..7cdb9f948 --- /dev/null +++ b/sentry-good_job/spec/spec_helper.rb @@ -0,0 +1,185 @@ +# frozen_string_literal: true + +require "bundler/setup" +begin + require "debug/prelude" +rescue LoadError +end + +require "active_job" +require "good_job" + +require "sentry-ruby" +require "sentry/test_helper" + +# Fixing crash: +# activesupport-6.1.7.10/lib/active_support/logger_thread_safe_level.rb:16:in +# . `': uninitialized constant ActiveSupport::LoggerThreadSafeLevel::Logger (NameError) +require "logger" + +require 'simplecov' + +SimpleCov.start do + project_name "sentry-good_job" + root File.join(__FILE__, "../../../") + coverage_dir File.join(__FILE__, "../../coverage") +end + +if ENV["CI"] + require 'simplecov-cobertura' + SimpleCov.formatter = SimpleCov::Formatter::CoberturaFormatter +end + +require "sentry-good_job" + +DUMMY_DSN = 'http://12345:67890@sentry.localdomain/sentry/42' + +RSpec.configure do |config| + # Enable flags like --only-failures and --next-failure + config.example_status_persistence_file_path = ".rspec_status" + + # Disable RSpec exposing methods globally on `Module` and `main` + config.disable_monkey_patching! + + config.expect_with :rspec do |c| + c.syntax = :expect + end + + config.before :suite do + puts "\n" + puts "*" * 100 + puts "Running with Good Job #{GoodJob::VERSION}" + puts "*" * 100 + puts "\n" + end + + config.before :each do + # Make sure we reset the env in case something leaks in + ENV.delete('SENTRY_DSN') + ENV.delete('SENTRY_CURRENT_ENV') + ENV.delete('SENTRY_ENVIRONMENT') + ENV.delete('SENTRY_RELEASE') + ENV.delete('RACK_ENV') + end + + config.include(Sentry::TestHelper) + + config.after :each do + reset_sentry_globals! + end +end + +def build_exception + 1 / 0 +rescue ZeroDivisionError => e + e +end + +def build_exception_with_cause(cause = "exception a") + begin + raise cause + rescue + raise "exception b" + end +rescue RuntimeError => e + e +end + +def build_exception_with_two_causes + begin + begin + raise "exception a" + rescue + raise "exception b" + end + rescue + raise "exception c" + end +rescue RuntimeError => e + e +end + +class HappyJob < ActiveJob::Base + def perform + crumb = Sentry::Breadcrumb.new(message: "I'm happy!") + Sentry.add_breadcrumb(crumb) + Sentry.set_tags mood: 'happy' + end +end + +class SadJob < ActiveJob::Base + def perform + crumb = Sentry::Breadcrumb.new(message: "I'm sad!") + Sentry.add_breadcrumb(crumb) + Sentry.set_tags mood: 'sad' + + raise "I'm sad!" + end +end + +class VerySadJob < ActiveJob::Base + def perform + crumb = Sentry::Breadcrumb.new(message: "I'm very sad!") + Sentry.add_breadcrumb(crumb) + Sentry.set_tags mood: 'very sad' + + raise "I'm very sad!" + end +end + +class ReportingJob < ActiveJob::Base + def perform + Sentry.capture_message("I have something to say!") + end +end + +class HappyJobWithCron < HappyJob + include Sentry::Cron::MonitorCheckIns + sentry_monitor_check_ins +end + +class SadJobWithCron < SadJob + include Sentry::Cron::MonitorCheckIns + sentry_monitor_check_ins slug: "failed_job", monitor_config: Sentry::Cron::MonitorConfig.from_crontab("5 * * * *") +end + +class WorkloadJob < ActiveJob::Base + def perform + # Create some CPU work that should show up in the profile + calculate_fibonacci(25) + sleep_and_sort + generate_strings + end + + private + + def calculate_fibonacci(n) + return n if n <= 1 + calculate_fibonacci(n - 1) + calculate_fibonacci(n - 2) + end + + def sleep_and_sort + # Mix of CPU and IO work + sleep(0.01) + array = (1..1000).to_a.shuffle + array.sort + end + + def generate_strings + # Memory and CPU work + 100.times do |i| + "test string #{i}" * 100 + Math.sqrt(i * 1000) + end + end +end + +def perform_basic_setup + Sentry.init do |config| + config.dsn = DUMMY_DSN + config.sdk_logger = ::Logger.new(nil) + config.background_worker_threads = 0 + config.transport.transport_class = Sentry::DummyTransport + yield config if block_given? + end +end diff --git a/sentry-rails/lib/sentry/rails/active_job.rb b/sentry-rails/lib/sentry/rails/active_job.rb index 2f5a134d0..b97ab0f0f 100644 --- a/sentry-rails/lib/sentry/rails/active_job.rb +++ b/sentry-rails/lib/sentry/rails/active_job.rb @@ -5,6 +5,9 @@ module Sentry module Rails module ActiveJobExtensions + # Add _sentry attribute for storing context and trace data + attr_accessor :_sentry + def perform_now if !Sentry.initialized? || already_supported_by_sentry_integration? super @@ -19,6 +22,76 @@ def already_supported_by_sentry_integration? Sentry.configuration.rails.skippable_job_adapters.include?(self.class.queue_adapter.class.to_s) end + # Enhanced enqueue method that captures context and trace data + def enqueue(options = {}) + self._sentry ||= {} + + # Capture user context + user = ::Sentry.get_current_scope&.user + self._sentry["user"] = user if user.present? + + # Capture trace propagation headers + self._sentry["trace_propagation_headers"] = ::Sentry.get_trace_propagation_headers + + super(options) + end + + # Enhanced serialize method that includes _sentry data + def serialize + super.tap do |job_data| + if _sentry + job_data["_sentry"] = _sentry.to_json + end + end + rescue JSON::GeneratorError, TypeError + # Swallow JSON serialization errors. Better to lose Sentry context than fail to serialize the job. + super + end + + # Enhanced deserialize method that restores _sentry data + def deserialize(job_data) + super(job_data) + + begin + self._sentry = JSON.parse(job_data["_sentry"]) if job_data["_sentry"] + rescue JSON::ParserError + # Swallow JSON parsing errors. Better to lose Sentry context than fail to deserialize the job. + end + end + + # Set span data with messaging semantics + def _sentry_set_span_data(span, job, retry_count: nil) + return unless span + + span.set_data("messaging.message.id", job.job_id) + span.set_data("messaging.destination.name", job.queue_name) if job.respond_to?(:queue_name) + span.set_data("messaging.message.retry.count", retry_count) if retry_count + end + + # Start transaction with trace propagation + def _sentry_start_transaction(scope, trace_headers) + options = { + name: scope.transaction_name, + source: scope.transaction_source, + op: "queue.process", + origin: "auto.queue.active_job" + } + + transaction = ::Sentry.continue_trace(trace_headers, **options) + ::Sentry.start_transaction(transaction: transaction) + end + + # Finish transaction with proper status + def _sentry_finish_transaction(transaction, status) + return unless transaction + + transaction.set_http_status(status) + transaction.finish + end + + private + + class SentryReporter OP_NAME = "queue.active_job" SPAN_ORIGIN = "auto.queue.active_job" @@ -32,20 +105,39 @@ def record(job, &block) Sentry.with_scope do |scope| begin scope.set_transaction_name(job.class.name, source: :task) - transaction = - if job.is_a?(::Sentry::SendEventJob) - nil + + # Restore user context if available + if job._sentry && (user = job._sentry["user"]) + scope.set_user(user) + end + + # Set up transaction with trace propagation + transaction = nil + unless job.is_a?(::Sentry::SendEventJob) + if job._sentry && job._sentry["trace_propagation_headers"] + transaction = job._sentry_start_transaction(scope, job._sentry["trace_propagation_headers"]) else - Sentry.start_transaction( + transaction = Sentry.start_transaction( name: scope.transaction_name, source: scope.transaction_source, op: OP_NAME, origin: SPAN_ORIGIN ) end + end scope.set_span(transaction) if transaction + # Add enhanced span data + if transaction + retry_count = if job.respond_to?(:executions) && job.executions.is_a?(Integer) + job.executions - 1 + else + 0 + end + job._sentry_set_span_data(transaction, job, retry_count: retry_count) + end + yield.tap do finish_sentry_transaction(transaction, 200) end diff --git a/sentry-rails/spec/sentry/rails/activejob_spec.rb b/sentry-rails/spec/sentry/rails/activejob_spec.rb index 8431bffff..e0904cae2 100644 --- a/sentry-rails/spec/sentry/rails/activejob_spec.rb +++ b/sentry-rails/spec/sentry/rails/activejob_spec.rb @@ -160,6 +160,7 @@ def post.to_global_id expect(Sentry.get_current_scope.extra).to eq({}) end + context "with tracing enabled" do before do make_basic_app do |config| diff --git a/sentry-rails/spec/sentry/send_event_job_spec.rb b/sentry-rails/spec/sentry/send_event_job_spec.rb index 00f89b2ed..bee75a522 100644 --- a/sentry-rails/spec/sentry/send_event_job_spec.rb +++ b/sentry-rails/spec/sentry/send_event_job_spec.rb @@ -61,6 +61,32 @@ expect(event.type).to eq("event") end + it "doesn't create a new transaction even with trace propagation headers" do + make_basic_app do |config| + config.traces_sample_rate = 1.0 + end + + # Start a transaction to create trace propagation headers + transaction = Sentry.start_transaction + Sentry.get_current_scope.set_span(transaction) + + # Create a SendEventJob with trace propagation headers + job = Sentry::SendEventJob.new + job._sentry = { + "trace_propagation_headers" => Sentry.get_trace_propagation_headers + } + + # Set the arguments for the job + job.arguments = [event, {}] + + # Perform the job + job.perform_now + + expect(transport.events.count).to eq(1) + event = transport.events.first + expect(event.type).to eq("event") + end + context "when ApplicationJob is not defined" do before do make_basic_app diff --git a/sentry-ruby/lib/sentry/rack/capture_exceptions.rb b/sentry-ruby/lib/sentry/rack/capture_exceptions.rb index 40cdfb4f8..b707693ff 100644 --- a/sentry-ruby/lib/sentry/rack/capture_exceptions.rb +++ b/sentry-ruby/lib/sentry/rack/capture_exceptions.rb @@ -72,7 +72,7 @@ def start_transaction(env, scope) } transaction = Sentry.continue_trace(env, **options) - Sentry.start_transaction(transaction: transaction, custom_sampling_context: { env: env }, **options) + Sentry.start_transaction(transaction: transaction, custom_sampling_context: { env: env }) end diff --git a/sentry-sidekiq/lib/sentry/sidekiq/sentry_context_middleware.rb b/sentry-sidekiq/lib/sentry/sidekiq/sentry_context_middleware.rb index c1fb4b6b1..f26a25cb9 100644 --- a/sentry-sidekiq/lib/sentry/sidekiq/sentry_context_middleware.rb +++ b/sentry-sidekiq/lib/sentry/sidekiq/sentry_context_middleware.rb @@ -91,7 +91,7 @@ def start_transaction(scope, env) } transaction = Sentry.continue_trace(env, **options) - Sentry.start_transaction(transaction: transaction, **options) + Sentry.start_transaction(transaction: transaction) end def finish_transaction(transaction, status)