├── test ├── helpers │ └── .keep ├── mailers │ └── .keep ├── models │ └── .keep ├── controllers │ └── .keep ├── dummy │ ├── log │ │ └── .keep │ ├── lib │ │ └── assets │ │ │ └── .keep │ ├── public │ │ ├── favicon.ico │ │ ├── apple-touch-icon.png │ │ ├── apple-touch-icon-precomposed.png │ │ ├── 500.html │ │ ├── 422.html │ │ └── 404.html │ ├── app │ │ ├── assets │ │ │ ├── images │ │ │ │ └── .keep │ │ │ ├── config │ │ │ │ └── manifest.js │ │ │ └── stylesheets │ │ │ │ └── application.css │ │ ├── models │ │ │ ├── concerns │ │ │ │ └── .keep │ │ │ ├── german.rb │ │ │ ├── english.rb │ │ │ ├── french.rb │ │ │ ├── application_record.rb │ │ │ └── salutation.rb │ │ ├── controllers │ │ │ ├── concerns │ │ │ │ └── .keep │ │ │ └── application_controller.rb │ │ ├── views │ │ │ ├── layouts │ │ │ │ ├── mailer.text.erb │ │ │ │ ├── mailer.html.erb │ │ │ │ └── application.html.erb │ │ │ └── application │ │ │ │ └── index.html.erb │ │ ├── helpers │ │ │ └── application_helper.rb │ │ ├── channels │ │ │ └── application_cable │ │ │ │ ├── channel.rb │ │ │ │ └── connection.rb │ │ ├── mailers │ │ │ └── application_mailer.rb │ │ └── jobs │ │ │ └── application_job.rb │ ├── bin │ │ ├── rake │ │ ├── rails │ │ └── setup │ ├── config │ │ ├── environment.rb │ │ ├── cable.yml │ │ ├── boot.rb │ │ ├── routes.rb │ │ ├── initializers │ │ │ ├── filter_parameter_logging.rb │ │ │ ├── permissions_policy.rb │ │ │ ├── assets.rb │ │ │ ├── inflections.rb │ │ │ └── content_security_policy.rb │ │ ├── database.yml │ │ ├── application.rb │ │ ├── locales │ │ │ └── en.yml │ │ ├── storage.yml │ │ ├── puma.rb │ │ └── environments │ │ │ ├── test.rb │ │ │ ├── development.rb │ │ │ └── production.rb │ ├── config.ru │ └── Rakefile ├── integration │ ├── .keep │ ├── navigation_test.rb │ └── thread_safety_test.rb ├── fixtures │ └── files │ │ └── .keep ├── checkpoint │ └── rails_test.rb ├── callstacking │ └── rails │ │ ├── settings_test.rb │ │ ├── setup_test.rb │ │ ├── exe_test.rb │ │ ├── trace_test.rb │ │ ├── cli_test.rb │ │ └── instrument_test.rb └── test_helper.rb ├── .ruby-version ├── app ├── models │ ├── concerns │ │ └── .keep │ └── callstacking │ │ └── rails │ │ └── application_record.rb ├── controllers │ ├── concerns │ │ └── .keep │ └── callstacking │ │ └── rails │ │ └── application_controller.rb ├── assets │ ├── images │ │ └── callstacking │ │ │ └── rails │ │ │ └── .keep │ ├── config │ │ └── callstacking_rails_manifest.js │ └── stylesheets │ │ └── callstacking │ │ └── rails │ │ └── application.css ├── helpers │ └── callstacking │ │ └── rails │ │ └── application_helper.rb ├── jobs │ └── callstacking │ │ └── rails │ │ └── application_job.rb └── mailers │ └── callstacking │ └── rails │ └── application_mailer.rb ├── yarn.lock ├── lib ├── callstacking │ ├── rails │ │ ├── version.rb │ │ ├── env.rb │ │ ├── client │ │ │ ├── authenticate.rb │ │ │ ├── trace.rb │ │ │ └── base.rb │ │ ├── logger.rb │ │ ├── time_based_uuid.rb │ │ ├── helpers │ │ │ ├── instrument_helper.rb │ │ │ └── heads_up_display_helper.rb │ │ ├── cli.rb │ │ ├── loader.rb │ │ ├── spans.rb │ │ ├── setup.rb │ │ ├── settings.rb │ │ ├── engine.rb │ │ ├── instrument.rb │ │ └── trace.rb │ └── rails.rb └── tasks │ └── checkpoint │ └── rails_tasks.rake ├── config └── routes.rb ├── .idea ├── encodings.xml ├── misc.xml ├── inspectionProfiles │ └── Project_Default.xml └── modules.xml ├── Rakefile ├── .gitignore ├── LICENSE ├── Gemfile ├── exe └── callstacking-rails ├── bin ├── setup └── rails ├── callstacking-rails.gemspec ├── .github └── workflows │ └── ci.yml ├── README.md ├── Gemfile.lock └── COMMERCIAL-LICENSE /test/helpers/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/mailers/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/models/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | 3.1.2 2 | -------------------------------------------------------------------------------- /test/controllers/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/dummy/log/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/integration/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/models/concerns/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/dummy/lib/assets/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/fixtures/files/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/controllers/concerns/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/dummy/public/favicon.ico: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/dummy/app/assets/images/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/dummy/app/models/concerns/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/dummy/public/apple-touch-icon.png: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/assets/images/callstacking/rails/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/dummy/app/controllers/concerns/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/dummy/public/apple-touch-icon-precomposed.png: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/dummy/app/views/layouts/mailer.text.erb: -------------------------------------------------------------------------------- 1 | <%= yield %> 2 | -------------------------------------------------------------------------------- /test/dummy/app/helpers/application_helper.rb: -------------------------------------------------------------------------------- 1 | module ApplicationHelper 2 | end 3 | -------------------------------------------------------------------------------- /test/dummy/app/models/german.rb: -------------------------------------------------------------------------------- 1 | class German 2 | def hallo 3 | puts "** Hallo called" 4 | end 5 | end -------------------------------------------------------------------------------- /app/assets/config/callstacking_rails_manifest.js: -------------------------------------------------------------------------------- 1 | //= link_directory ../stylesheets/callstacking/rails .css 2 | -------------------------------------------------------------------------------- /test/dummy/app/assets/config/manifest.js: -------------------------------------------------------------------------------- 1 | //= link_tree ../images 2 | //= link_directory ../stylesheets .css 3 | -------------------------------------------------------------------------------- /test/dummy/app/models/english.rb: -------------------------------------------------------------------------------- 1 | class English 2 | def hello 3 | puts '** Hello called' 4 | end 5 | end -------------------------------------------------------------------------------- /test/dummy/app/models/french.rb: -------------------------------------------------------------------------------- 1 | class French 2 | def bounjor 3 | puts '** Bounjour called' 4 | end 5 | end -------------------------------------------------------------------------------- /yarn.lock: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | 4 | 5 | -------------------------------------------------------------------------------- /lib/callstacking/rails/version.rb: -------------------------------------------------------------------------------- 1 | module Callstacking 2 | module Rails 3 | VERSION = "0.1.39" 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /config/routes.rb: -------------------------------------------------------------------------------- 1 | Callstacking::Rails::Engine.routes.draw do 2 | resources :traces 3 | root to: "traces#index" 4 | end 5 | -------------------------------------------------------------------------------- /test/dummy/bin/rake: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require_relative "../config/boot" 3 | require "rake" 4 | Rake.application.run 5 | -------------------------------------------------------------------------------- /test/dummy/app/models/application_record.rb: -------------------------------------------------------------------------------- 1 | class ApplicationRecord < ActiveRecord::Base 2 | primary_abstract_class 3 | end 4 | -------------------------------------------------------------------------------- /lib/tasks/checkpoint/rails_tasks.rake: -------------------------------------------------------------------------------- 1 | # desc "Explaining what the task does" 2 | # task :checkpoint_rails do 3 | # # Task goes here 4 | # end 5 | -------------------------------------------------------------------------------- /test/dummy/app/channels/application_cable/channel.rb: -------------------------------------------------------------------------------- 1 | module ApplicationCable 2 | class Channel < ActionCable::Channel::Base 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /app/helpers/callstacking/rails/application_helper.rb: -------------------------------------------------------------------------------- 1 | module Callstacking 2 | module Rails 3 | module ApplicationHelper 4 | end 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /test/dummy/app/channels/application_cable/connection.rb: -------------------------------------------------------------------------------- 1 | module ApplicationCable 2 | class Connection < ActionCable::Connection::Base 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /test/dummy/app/mailers/application_mailer.rb: -------------------------------------------------------------------------------- 1 | class ApplicationMailer < ActionMailer::Base 2 | default from: "from@example.com" 3 | layout "mailer" 4 | end 5 | -------------------------------------------------------------------------------- /app/jobs/callstacking/rails/application_job.rb: -------------------------------------------------------------------------------- 1 | module Callstacking 2 | module Rails 3 | class ApplicationJob < ActiveJob::Base 4 | end 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /lib/callstacking/rails.rb: -------------------------------------------------------------------------------- 1 | require "callstacking/rails/version" 2 | require "callstacking/rails/engine" 3 | 4 | module Callstacking 5 | module Rails 6 | end 7 | end -------------------------------------------------------------------------------- /test/dummy/bin/rails: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | APP_PATH = File.expand_path("../config/application", __dir__) 3 | require_relative "../config/boot" 4 | require "rails/commands" 5 | -------------------------------------------------------------------------------- /test/dummy/config/environment.rb: -------------------------------------------------------------------------------- 1 | # Load the Rails application. 2 | require_relative "application" 3 | 4 | # Initialize the Rails application. 5 | Rails.application.initialize! 6 | -------------------------------------------------------------------------------- /.idea/encodings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /app/controllers/callstacking/rails/application_controller.rb: -------------------------------------------------------------------------------- 1 | module Callstacking 2 | module Rails 3 | class ApplicationController < ActionController::Base 4 | end 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /test/dummy/config.ru: -------------------------------------------------------------------------------- 1 | # This file is used by Rack-based servers to start the application. 2 | 3 | require_relative "config/environment" 4 | 5 | run Rails.application 6 | Rails.application.load_server 7 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /app/models/callstacking/rails/application_record.rb: -------------------------------------------------------------------------------- 1 | module Callstacking 2 | module Rails 3 | class ApplicationRecord < ActiveRecord::Base 4 | self.abstract_class = true 5 | end 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /test/checkpoint/rails_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class Callstacking::RailsTest < ActiveSupport::TestCase 4 | test "it has a version number" do 5 | assert Callstacking::Rails::VERSION 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/setup" 2 | 3 | APP_RAKEFILE = File.expand_path("test/dummy/Rakefile", __dir__) 4 | load "rails/tasks/engine.rake" 5 | 6 | load "rails/tasks/statistics.rake" 7 | 8 | require "bundler/gem_tasks" 9 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/Project_Default.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | -------------------------------------------------------------------------------- /app/mailers/callstacking/rails/application_mailer.rb: -------------------------------------------------------------------------------- 1 | module Callstacking 2 | module Rails 3 | class ApplicationMailer < ActionMailer::Base 4 | default from: "from@example.com" 5 | layout "mailer" 6 | end 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /doc/ 3 | /log/*.log 4 | /pkg/ 5 | /tmp/ 6 | /test/dummy/db/*.sqlite3 7 | /test/dummy/db/*.sqlite3-* 8 | /test/dummy/log/*.log 9 | /test/dummy/storage/ 10 | /test/dummy/tmp/ 11 | /.idea/* 12 | *.gem 13 | .idea/checkpoint-rails.iml -------------------------------------------------------------------------------- /test/dummy/config/cable.yml: -------------------------------------------------------------------------------- 1 | development: 2 | adapter: async 3 | 4 | test: 5 | adapter: test 6 | 7 | production: 8 | adapter: redis 9 | url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %> 10 | channel_prefix: dummy_production 11 | -------------------------------------------------------------------------------- /test/dummy/Rakefile: -------------------------------------------------------------------------------- 1 | # Add your own tasks in files placed in lib/tasks ending in .rake, 2 | # for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. 3 | 4 | require_relative "config/application" 5 | 6 | Rails.application.load_tasks 7 | -------------------------------------------------------------------------------- /test/dummy/config/boot.rb: -------------------------------------------------------------------------------- 1 | # Set up gems listed in the Gemfile. 2 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../../Gemfile", __dir__) 3 | 4 | require "bundler/setup" if File.exist?(ENV["BUNDLE_GEMFILE"]) 5 | $LOAD_PATH.unshift File.expand_path("../../../lib", __dir__) 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) Blue Sage Data Systems, Inc. 2 | 3 | The Call Stacking library is licensed under the terms of the LGPLv3 license. 4 | http://www.gnu.org/licenses/lgpl-3.0.html 5 | 6 | Call Stacking Enterprise is commercial. You can find the license within COMMERCIAL-LICENSE. 7 | https://callstacking.com#plans -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /test/dummy/app/jobs/application_job.rb: -------------------------------------------------------------------------------- 1 | class ApplicationJob < ActiveJob::Base 2 | # Automatically retry jobs that encountered a deadlock 3 | # retry_on ActiveRecord::Deadlocked 4 | 5 | # Most jobs are safe to ignore if the underlying records are no longer available 6 | # discard_on ActiveJob::DeserializationError 7 | end 8 | -------------------------------------------------------------------------------- /test/dummy/app/views/layouts/mailer.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 8 | 9 | 10 | 11 | <%= yield %> 12 | 13 | 14 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | git_source(:github) { |repo| "https://github.com/#{repo}.git" } 3 | 4 | # Specify your gem's dependencies in callstacking-rails.gemspec. 5 | gemspec 6 | 7 | gem "sqlite3" 8 | 9 | gem "sprockets-rails" 10 | 11 | # Start debugger with binding.b [https://github.com/ruby/debug] 12 | # gem "debug", ">= 1.0.0" 13 | -------------------------------------------------------------------------------- /test/dummy/config/routes.rb: -------------------------------------------------------------------------------- 1 | Rails.application.routes.draw do 2 | mount Callstacking::Rails::Engine => "/callstacking-rails" 3 | 4 | resources :application, only: :index 5 | 6 | get '/hello', to: 'application#hello' 7 | get '/bounjor', to: 'application#bounjor' 8 | get '/hallo', to: 'application#hallo' 9 | 10 | root to: "application#index" 11 | end 12 | -------------------------------------------------------------------------------- /test/dummy/app/models/salutation.rb: -------------------------------------------------------------------------------- 1 | class Salutation 2 | def hello(name) 3 | "hello #{name}" 4 | end 5 | 6 | def self.hello(name) 7 | "hi #{name}" 8 | end 9 | 10 | def hi(first_name, last_name:) 11 | "hi #{first_name} #{last_name}" 12 | end 13 | 14 | def self.hi(first_name:, last_name:) 15 | "hi #{first_name} #{last_name}" 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /exe/callstacking-rails: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "bundler/setup" 4 | require "callstacking/rails" 5 | 6 | action = Callstacking::Rails::Cli.action(ARGV) 7 | settings = Callstacking::Rails::Settings.new 8 | 9 | if action.nil? 10 | Callstacking::Rails::Setup.instructions 11 | exit!(1) 12 | end 13 | 14 | cli = Callstacking::Rails::Cli.new(action, settings) 15 | cli.run -------------------------------------------------------------------------------- /test/dummy/app/views/layouts/application.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Dummy 5 | 6 | <%= csrf_meta_tags %> 7 | <%= csp_meta_tag %> 8 | 9 | <%= stylesheet_link_tag "application" %> 10 | 11 | 12 | 13 | <%= yield %> 14 | 15 | 16 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | 5 | # CI-specific setup 6 | if [ -n "$GITHUB_ACTIONS" ]; then 7 | bundle config path vendor/bundle 8 | bundle config jobs 4 9 | bundle config retry 3 10 | git config --global user.name 'GitHub Actions' 11 | git config --global user.email 'github-actions@example.com' 12 | fi 13 | 14 | gem install bundler --conservative 15 | bundle check || bundle install -------------------------------------------------------------------------------- /test/callstacking/rails/settings_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'minitest/autorun' 4 | 5 | module Callstacking 6 | module Rails 7 | class SettingsTest < Minitest::Test 8 | def setup 9 | @subject = Callstacking::Rails::Settings.new 10 | end 11 | 12 | def test_read_settings 13 | assert_equal @subject.url, Callstacking::Rails::Settings::PRODUCTION_URL 14 | end 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /test/dummy/config/initializers/filter_parameter_logging.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Configure parameters to be filtered from the log file. Use this to limit dissemination of 4 | # sensitive information. See the ActiveSupport::ParameterFilter documentation for supported 5 | # notations and behaviors. 6 | Rails.application.config.filter_parameters += [ 7 | :passw, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn 8 | ] 9 | -------------------------------------------------------------------------------- /test/dummy/config/initializers/permissions_policy.rb: -------------------------------------------------------------------------------- 1 | # Define an application-wide HTTP permissions policy. For further 2 | # information see https://developers.google.com/web/updates/2018/06/feature-policy 3 | # 4 | # Rails.application.config.permissions_policy do |f| 5 | # f.camera :none 6 | # f.gyroscope :none 7 | # f.microphone :none 8 | # f.usb :none 9 | # f.fullscreen :self 10 | # f.payment :self, "https://secure.example.com" 11 | # end 12 | -------------------------------------------------------------------------------- /lib/callstacking/rails/env.rb: -------------------------------------------------------------------------------- 1 | require "active_support/inflector" 2 | 3 | module Callstacking 4 | module Rails 5 | class Env 6 | DEFAULT_ENVIRONMENT = 'development'.freeze 7 | 8 | cattr_accessor :environment 9 | 10 | @@environment = (ENV['RAILS_ENV'] || DEFAULT_ENVIRONMENT).parameterize(separator: '_').to_sym 11 | 12 | def self.production? 13 | @@environment == DEFAULT_ENVIRONMENT.parameterize(separator: '_').to_sym 14 | end 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/callstacking/rails/client/authenticate.rb: -------------------------------------------------------------------------------- 1 | require 'json' 2 | 3 | module Callstacking 4 | module Rails 5 | module Client 6 | class Error < StandardError; end 7 | 8 | class Authenticate < Base 9 | URL = "/api/v1/auth.json" 10 | 11 | def login(email, password) 12 | resp = post(URL, email: email, password: password) 13 | 14 | raise Faraday::UnauthorizedError if resp&.body.nil? 15 | 16 | body = resp&.body || {} 17 | body["token"] 18 | end 19 | end 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/callstacking/rails/logger.rb: -------------------------------------------------------------------------------- 1 | module Callstacking 2 | module Rails 3 | class Logger 4 | def self.log(message) 5 | puts message 6 | 7 | if ENV['GITHUB_OUTPUT'].present? 8 | File.open(ENV['GITHUB_OUTPUT'], 'a') do |file| 9 | # Write your progress output to the file 10 | # This could be inside a loop or condition, depending on your needs 11 | file.puts "::set-output name=progress_output::#{message}" 12 | end 13 | end 14 | end 15 | end 16 | end 17 | end 18 | 19 | -------------------------------------------------------------------------------- /test/dummy/config/initializers/assets.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Version of your assets, change this if you want to expire all your assets. 4 | Rails.application.config.assets.version = "1.0" 5 | 6 | # Add additional assets to the asset load path. 7 | # Rails.application.config.assets.paths << Emoji.images_path 8 | 9 | # Precompile additional assets. 10 | # application.js, application.css, and all non-JS/CSS in the app/assets 11 | # folder are already added. 12 | # Rails.application.config.assets.precompile += %w( admin.js admin.css ) 13 | -------------------------------------------------------------------------------- /bin/rails: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # This command will automatically be run when you run "rails" with Rails gems 3 | # installed from the root of your application. 4 | 5 | ENGINE_ROOT = File.expand_path("..", __dir__) 6 | ENGINE_PATH = File.expand_path("../lib/callstacking/rails/engine", __dir__) 7 | APP_PATH = File.expand_path("../test/dummy/config/application", __dir__) 8 | 9 | # Set up gems listed in the Gemfile. 10 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) 11 | require "bundler/setup" if File.exist?(ENV["BUNDLE_GEMFILE"]) 12 | 13 | require "rails/all" 14 | require "rails/engine/commands" 15 | -------------------------------------------------------------------------------- /test/dummy/app/views/application/index.html.erb: -------------------------------------------------------------------------------- 1 | Salutation: <%= @salutation %> 2 | 3 | ::Rails.cache.read(CACHE_KEY): <%= ::Rails.cache.read(Callstacking::Rails::Settings::CACHE_KEY) %> 4 |
5 | ENV[ENV_KEY]: <%= ENV[Callstacking::Rails::Settings::ENV_KEY] %> 6 |
7 | settings.nil?: <%= @settings.settings.nil? %> 8 |
9 | Rails env: <%= Rails.env.to_s %> 10 |
11 | Callstacking env: <%= Callstacking::Rails::Env.environment %> 12 |
13 | Settings: <%= @settings.settings %> 14 |
15 | Enabled: <%= @settings.enabled? %> 16 |
17 | Instrumented klasses: <%= ::Callstacking::Rails::Engine.loader.klasses.to_a.inspect %> -------------------------------------------------------------------------------- /test/callstacking/rails/setup_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'minitest/autorun' 4 | require 'callstacking/rails/setup' 5 | 6 | module Callstacking 7 | module Rails 8 | class SetupTest < Minitest::Test 9 | def setup 10 | @subject = Callstacking::Rails::Setup.new 11 | Callstacking::Rails::Settings.any_instance.stubs(:save).returns(true) 12 | end 13 | 14 | def test_start 15 | @subject.stubs(:prompt).returns('value') 16 | 17 | assert_equal true, @subject.start 18 | end 19 | def test_instructions 20 | assert_equal :instructions, Callstacking::Rails::Setup.instructions 21 | end 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /test/callstacking/rails/exe_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'minitest/autorun' 4 | 5 | module Callstacking 6 | module Rails 7 | class ExeTest < Minitest::Test 8 | def test_register 9 | test_run('./exe/callstacking-rails register') 10 | end 11 | 12 | def test_setup 13 | # test_run('./exe/callstacking-rails setup') 14 | end 15 | 16 | def test_enable 17 | test_run('./exe/callstacking-rails enable') 18 | end 19 | 20 | def test_disable 21 | test_run('./exe/callstacking-rails disable') 22 | end 23 | 24 | private 25 | def test_run(command) 26 | refute_match /Error/, `#{command}` 27 | end 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /test/dummy/config/database.yml: -------------------------------------------------------------------------------- 1 | # SQLite. Versions 3.8.0 and up are supported. 2 | # gem install sqlite3 3 | # 4 | # Ensure the SQLite 3 gem is defined in your Gemfile 5 | # gem "sqlite3" 6 | # 7 | default: &default 8 | adapter: sqlite3 9 | pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> 10 | timeout: 5000 11 | 12 | development: 13 | <<: *default 14 | database: db/development.sqlite3 15 | 16 | # Warning: The database defined as "test" will be erased and 17 | # re-generated from your development database when you run "rake". 18 | # Do not set this db to the same as development or production. 19 | test: 20 | <<: *default 21 | database: db/test.sqlite3 22 | 23 | production: 24 | <<: *default 25 | database: db/production.sqlite3 26 | -------------------------------------------------------------------------------- /test/dummy/config/initializers/inflections.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Add new inflection rules using the following format. Inflections 4 | # are locale specific, and you may define rules for as many different 5 | # locales as you wish. All of these examples are active by default: 6 | # ActiveSupport::Inflector.inflections(:en) do |inflect| 7 | # inflect.plural /^(ox)$/i, "\\1en" 8 | # inflect.singular /^(ox)en/i, "\\1" 9 | # inflect.irregular "person", "people" 10 | # inflect.uncountable %w( fish sheep ) 11 | # end 12 | 13 | # These inflection rules are supported but not enabled by default: 14 | # ActiveSupport::Inflector.inflections(:en) do |inflect| 15 | # inflect.acronym "RESTful" 16 | # end 17 | -------------------------------------------------------------------------------- /test/integration/navigation_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class NavigationTest < ActionDispatch::IntegrationTest 4 | test "HUD is loaded" do 5 | get '/?debug=1' 6 | assert_match(/Hello/, @response.body) 7 | assert_match(/iframe/, @response.body) 8 | assert_match(/#{Callstacking::Rails::Trace::ICON}/, @response.body) 9 | assert_match(/#{Callstacking::Rails::Settings::PRODUCTION_URL}\/traces/, @response.body) 10 | end 11 | 12 | test "HUD is not loaded" do 13 | get '/' 14 | assert_match(/Hello/, @response.body) 15 | assert_no_match(/iframe/, @response.body) 16 | assert_no_match(/#{Callstacking::Rails::Trace::ICON}/, @response.body) 17 | assert_no_match(/#{Callstacking::Rails::Settings::PRODUCTION_URL}\/traces/, @response.body) 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /test/dummy/app/assets/stylesheets/application.css: -------------------------------------------------------------------------------- 1 | /* 2 | * This is a manifest file that'll be compiled into application.css, which will include all the files 3 | * listed below. 4 | * 5 | * Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets, 6 | * or any plugin's vendor/assets/stylesheets directory can be referenced here using a relative path. 7 | * 8 | * You're free to add application-wide styles to this file and they'll appear at the bottom of the 9 | * compiled file so the styles you add here take precedence over styles defined in any other CSS/SCSS 10 | * files in this directory. Styles in this file should be added after the last require_* statement. 11 | * It is generally better to create a new file per style scope. 12 | * 13 | *= require_tree . 14 | *= require_self 15 | */ 16 | -------------------------------------------------------------------------------- /app/assets/stylesheets/callstacking/rails/application.css: -------------------------------------------------------------------------------- 1 | /* 2 | * This is a manifest file that'll be compiled into application.css, which will include all the files 3 | * listed below. 4 | * 5 | * Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets, 6 | * or any plugin's vendor/assets/stylesheets directory can be referenced here using a relative path. 7 | * 8 | * You're free to add application-wide styles to this file and they'll appear at the bottom of the 9 | * compiled file so the styles you add here take precedence over styles defined in any other CSS/SCSS 10 | * files in this directory. Styles in this file should be added after the last require_* statement. 11 | * It is generally better to create a new file per style scope. 12 | * 13 | *= require_tree . 14 | *= require_self 15 | */ 16 | -------------------------------------------------------------------------------- /test/callstacking/rails/trace_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'minitest/autorun' 4 | require 'callstacking/rails/settings' 5 | 6 | module Callstacking 7 | module Rails 8 | class TraceTest < Minitest::Test 9 | def setup 10 | @spans = Spans.new 11 | @subject = Callstacking::Rails::Trace.new(@spans) 12 | end 13 | 14 | def test_do_not_track_request 15 | assert_equal true, 16 | @subject.send(:do_not_track_request?, 'http://localhost:3000/assets/application.css', 'text/css') 17 | assert_equal true, 18 | @subject.send(:do_not_track_request?, 'http://localhost:3000/health', '*/*') 19 | assert_equal false, 20 | @subject.send(:do_not_track_request?, 'http://localhost:3000/users', 'text/html') 21 | end 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/callstacking/rails/time_based_uuid.rb: -------------------------------------------------------------------------------- 1 | require 'securerandom' 2 | 3 | class TimeBasedUUID 4 | EPOCH_OFFSET = 1468418800000 # A custom epoch, it could be the UNIX timestamp when the application was created (in milliseconds) 5 | MAX_INT8_VALUE = 9223372036854775807 6 | 7 | def self.generate 8 | # Get the current time in milliseconds 9 | current_time = (Time.now.to_f * 1000).to_i 10 | 11 | # Subtract the custom epoch to reduce the timestamp size 12 | timestamp = current_time - EPOCH_OFFSET 13 | 14 | # Generate a random 64-bit number using SecureRandom 15 | random_bits = SecureRandom.random_number(1 << 64) 16 | 17 | # Combine the timestamp and the random bits 18 | uuid = (timestamp << 64) | random_bits 19 | 20 | # Ensure the UUID fits into a PostgreSQL int8 column 21 | uuid = uuid % MAX_INT8_VALUE if uuid > MAX_INT8_VALUE 22 | 23 | uuid 24 | end 25 | end -------------------------------------------------------------------------------- /test/dummy/config/application.rb: -------------------------------------------------------------------------------- 1 | require_relative "boot" 2 | 3 | require "rails/all" 4 | 5 | # Require the gems listed in Gemfile, including any gems 6 | # you've limited to :test, :development, or :production. 7 | Bundler.require(*Rails.groups) 8 | require "callstacking/rails" 9 | 10 | module Dummy 11 | class Application < Rails::Application 12 | config.load_defaults Rails::VERSION::STRING.to_f 13 | 14 | # For compatibility with applications that use this config 15 | config.action_controller.include_all_helpers = false 16 | 17 | # Configuration for the application, engines, and railties goes here. 18 | # 19 | # These settings can be overridden in specific environments using the files 20 | # in config/environments, which are processed later. 21 | # 22 | # config.time_zone = "Central Time (US & Canada)" 23 | # config.eager_load_paths << Rails.root.join("extras") 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /test/dummy/config/locales/en.yml: -------------------------------------------------------------------------------- 1 | # Files in the config/locales directory are used for internationalization 2 | # and are automatically loaded by Rails. If you want to use locales other 3 | # than English, add the necessary files in this directory. 4 | # 5 | # To use the locales, use `I18n.t`: 6 | # 7 | # I18n.t "hello" 8 | # 9 | # In views, this is aliased to just `t`: 10 | # 11 | # <%= t("hello") %> 12 | # 13 | # To use a different locale, set it with `I18n.locale`: 14 | # 15 | # I18n.locale = :es 16 | # 17 | # This would use the information in config/locales/es.yml. 18 | # 19 | # The following keys must be escaped otherwise they will not be retrieved by 20 | # the default I18n backend: 21 | # 22 | # true, false, on, off, yes, no 23 | # 24 | # Instead, surround them with single quotes. 25 | # 26 | # en: 27 | # "true": "foo" 28 | # 29 | # To learn more, please read the Rails Internationalization guide 30 | # available at https://guides.rubyonrails.org/i18n.html. 31 | 32 | en: 33 | hello: "Hello world" 34 | -------------------------------------------------------------------------------- /test/dummy/app/controllers/application_controller.rb: -------------------------------------------------------------------------------- 1 | class ApplicationController < ActionController::Base 2 | include Callstacking::Rails::Helpers::InstrumentHelper 3 | 4 | before_action :include_settings 5 | 6 | prepend_around_action :callstacking_setup, if: -> { params[:debug] == '1' } 7 | 8 | def index 9 | @salutation = 'Hello from index' 10 | end 11 | 12 | def hello 13 | @salutation = 'Hello from hello' 14 | 15 | 10.times do 16 | English.new.hello 17 | sleep_rand 18 | end 19 | 20 | render :index 21 | end 22 | 23 | def bounjor 24 | @salutation = 'Bounjour de bounjour' 25 | 26 | 10.times do 27 | French.new.bounjor 28 | sleep_rand 29 | end 30 | 31 | render :index 32 | end 33 | 34 | def hallo 35 | @salutation = 'Hallo von hallo' 36 | 37 | 10.times do 38 | German.new.hallo 39 | sleep_rand 40 | end 41 | 42 | render :index 43 | end 44 | 45 | private 46 | def include_settings 47 | @settings = Callstacking::Rails::Settings.new 48 | @settings.url 49 | end 50 | 51 | def sleep_rand 52 | sleep rand(0.0..2.0) 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /test/dummy/bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require "fileutils" 3 | 4 | # path to your application root. 5 | APP_ROOT = File.expand_path("..", __dir__) 6 | 7 | def system!(*args) 8 | system(*args) || abort("\n== Command #{args} failed ==") 9 | end 10 | 11 | FileUtils.chdir APP_ROOT do 12 | # This script is a way to set up or update your development environment automatically. 13 | # This script is idempotent, so that you can run it at any time and get an expectable outcome. 14 | # Add necessary setup steps to this file. 15 | 16 | puts "== Installing dependencies ==" 17 | system! "gem install bundler --conservative" 18 | system("bundle check") || system!("bundle install") 19 | 20 | # puts "\n== Copying sample files ==" 21 | # unless File.exist?("config/database.yml") 22 | # FileUtils.cp "config/database.yml.sample", "config/database.yml" 23 | # end 24 | 25 | puts "\n== Preparing database ==" 26 | system! "bin/rails db:prepare" 27 | 28 | puts "\n== Removing old logs and tempfiles ==" 29 | system! "bin/rails log:clear tmp:clear" 30 | 31 | puts "\n== Restarting application server ==" 32 | system! "bin/rails restart" 33 | end 34 | -------------------------------------------------------------------------------- /test/dummy/config/initializers/content_security_policy.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Define an application-wide content security policy. 4 | # See the Securing Rails Applications Guide for more information: 5 | # https://guides.rubyonrails.org/security.html#content-security-policy-header 6 | 7 | # Rails.application.configure do 8 | # config.content_security_policy do |policy| 9 | # policy.default_src :self, :https 10 | # policy.font_src :self, :https, :data 11 | # policy.img_src :self, :https, :data 12 | # policy.object_src :none 13 | # policy.script_src :self, :https 14 | # policy.style_src :self, :https 15 | # # Specify URI for violation reports 16 | # # policy.report_uri "/csp-violation-report-endpoint" 17 | # end 18 | # 19 | # # Generate session nonces for permitted importmap and inline scripts 20 | # config.content_security_policy_nonce_generator = ->(request) { request.session.id.to_s } 21 | # config.content_security_policy_nonce_directives = %w(script-src) 22 | # 23 | # # Report violations without enforcing the policy. 24 | # # config.content_security_policy_report_only = true 25 | # end 26 | -------------------------------------------------------------------------------- /lib/callstacking/rails/helpers/instrument_helper.rb: -------------------------------------------------------------------------------- 1 | module Callstacking 2 | module Rails 3 | module Helpers 4 | module InstrumentHelper 5 | extend ActiveSupport::Concern 6 | def callstacking_setup 7 | exception = nil 8 | @last_callstacking_sample = Time.now.utc 9 | Callstacking::Rails::Engine.start_tracing(self) 10 | 11 | yield 12 | rescue Exception => e 13 | @last_callstacking_exception = Time.now.utc 14 | exception = e 15 | raise e 16 | ensure 17 | Callstacking::Rails::Engine.stop_tracing(self, exception) 18 | end 19 | end 20 | 21 | def callstcking_sample_trace? 22 | if @last_callstacking_exception.present? && @last_callstacking_exception < 1.minute.ago 23 | @last_callstacking_exception = nil 24 | return true 25 | end 26 | 27 | false 28 | end 29 | 30 | def callstacking_followup_exception_trace? 31 | if @last_callstacking_sample.present? && @last_callstacking_sample < 1.hour.ago 32 | @last_callstacking_exception = nil 33 | return true 34 | end 35 | 36 | false 37 | end 38 | end 39 | end 40 | end -------------------------------------------------------------------------------- /test/callstacking/rails/cli_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'minitest/autorun' 4 | 5 | module Callstacking 6 | module Rails 7 | class CliTest < Minitest::Test 8 | 9 | def setup 10 | @settings = Callstacking::Rails::Settings.new 11 | Callstacking::Rails::Setup.any_instance.stubs(:start).returns(true) 12 | end 13 | 14 | def test_register 15 | @subject = Callstacking::Rails::Cli.new(Callstacking::Rails::Cli::REGISTER, @settings) 16 | assert_equal @subject.run, Callstacking::Rails::Cli::REGISTER 17 | end 18 | 19 | def test_setup 20 | @subject = Callstacking::Rails::Cli.new(Callstacking::Rails::Cli::SETUP, @settings) 21 | assert_equal @subject.run, Callstacking::Rails::Cli::SETUP 22 | end 23 | 24 | def test_enable 25 | @subject = Callstacking::Rails::Cli.new(Callstacking::Rails::Cli::ENABLE, @settings) 26 | assert_equal @subject.run, Callstacking::Rails::Cli::ENABLE 27 | end 28 | 29 | def test_disable 30 | @subject = Callstacking::Rails::Cli.new(Callstacking::Rails::Cli::DISABLE, @settings) 31 | assert_equal @subject.run, Callstacking::Rails::Cli::DISABLE 32 | end 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /test/dummy/config/storage.yml: -------------------------------------------------------------------------------- 1 | test: 2 | service: Disk 3 | root: <%= Rails.root.join("tmp/storage") %> 4 | 5 | local: 6 | service: Disk 7 | root: <%= Rails.root.join("storage") %> 8 | 9 | # Use bin/rails credentials:edit to set the AWS secrets (as aws:access_key_id|secret_access_key) 10 | # amazon: 11 | # service: S3 12 | # access_key_id: <%= Rails.application.credentials.dig(:aws, :access_key_id) %> 13 | # secret_access_key: <%= Rails.application.credentials.dig(:aws, :secret_access_key) %> 14 | # region: us-east-1 15 | # bucket: your_own_bucket-<%= Rails.env %> 16 | 17 | # Remember not to checkin your GCS keyfile to a repository 18 | # google: 19 | # service: GCS 20 | # project: your_project 21 | # credentials: <%= Rails.root.join("path/to/gcs.keyfile") %> 22 | # bucket: your_own_bucket-<%= Rails.env %> 23 | 24 | # Use bin/rails credentials:edit to set the Azure Storage secret (as azure_storage:storage_access_key) 25 | # microsoft: 26 | # service: AzureStorage 27 | # storage_account_name: your_account_name 28 | # storage_access_key: <%= Rails.application.credentials.dig(:azure_storage, :storage_access_key) %> 29 | # container: your_container_name-<%= Rails.env %> 30 | 31 | # mirror: 32 | # service: Mirror 33 | # primary: local 34 | # mirrors: [ amazon, google, microsoft ] 35 | -------------------------------------------------------------------------------- /lib/callstacking/rails/cli.rb: -------------------------------------------------------------------------------- 1 | module Callstacking 2 | module Rails 3 | class Cli 4 | REGISTER = 'register' 5 | SETUP = 'setup' 6 | ENABLE = 'enable' 7 | DISABLE = 'disable' 8 | 9 | attr_reader :action, :settings 10 | 11 | def initialize(action, settings) 12 | @action = action 13 | @settings = settings 14 | end 15 | 16 | def run 17 | parse_options 18 | end 19 | 20 | def self.action(args) 21 | args[0]&.downcase&.strip 22 | end 23 | 24 | private 25 | 26 | def parse_options 27 | case action 28 | when REGISTER 29 | puts "Open the following URL to register:\n\n" 30 | puts " #{settings.url}/users/sign_up\n\n" 31 | REGISTER 32 | 33 | when SETUP 34 | Callstacking::Rails::Setup.new.start 35 | SETUP 36 | 37 | when ENABLE 38 | settings.enable_disable 39 | puts "Call Stacking tracing enabled (#{Callstacking::Rails::Env.environment})" 40 | ENABLE 41 | 42 | when DISABLE 43 | settings.enable_disable(enabled: false) 44 | puts "Call Stacking tracing disabled (#{Callstacking::Rails::Env.environment})" 45 | DISABLE 46 | end 47 | end 48 | end 49 | end 50 | end -------------------------------------------------------------------------------- /callstacking-rails.gemspec: -------------------------------------------------------------------------------- 1 | require_relative "lib/callstacking/rails/version" 2 | 3 | Gem::Specification.new do |spec| 4 | spec.name = "callstacking-rails" 5 | spec.version = Callstacking::Rails::VERSION 6 | spec.authors = ["Jim Jones"] 7 | spec.email = ["jim.jones1@gmail.com"] 8 | spec.homepage = "https://github.com/callstacking/callstacking-rails" 9 | spec.summary = "Quickly visualize which methods call which, their parameters, and return values." 10 | spec.description = "Quickly visualize which methods call which, their parameters, and return values." 11 | spec.license = "GPL-3.0-or-later" 12 | spec.bindir = "exe" 13 | 14 | spec.metadata["homepage_uri"] = spec.homepage 15 | spec.metadata["source_code_uri"] = "https://github.com/callstacking/callstacking-rails" 16 | 17 | spec.files = Dir.chdir(File.expand_path(__dir__)) do 18 | Dir["{app,config,db,lib,exe}/**/*", "LICENSE", "Rakefile", "README.md"] 19 | end 20 | 21 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } 22 | 23 | spec.add_dependency "rails", ">= 4" 24 | spec.add_dependency "faraday", '>= 1.10.3' 25 | spec.add_dependency 'faraday-follow_redirects' 26 | spec.add_dependency 'method_source' 27 | spec.add_development_dependency 'mocha' 28 | spec.add_development_dependency 'minitest-silence' 29 | end 30 | -------------------------------------------------------------------------------- /lib/callstacking/rails/client/trace.rb: -------------------------------------------------------------------------------- 1 | require "callstacking/rails/client/base" 2 | 3 | module Callstacking 4 | module Rails 5 | module Client 6 | class Trace < Base 7 | CREATE_URL = "/api/v1/traces.json" 8 | UPDATE_URL = "/api/v1/traces/:id.json" 9 | SHOW_URL = "/api/v1/traces/:id.json" 10 | 11 | def initialize(url, auth_token) 12 | super 13 | 14 | # All requests for trace and trace entry creation are async 15 | # join by the client side generated tuid 16 | @async = true 17 | end 18 | 19 | def create(request_id, tuid, method_name, klass, action_name, format_name, root_path, url, headers, params) 20 | post(CREATE_URL, 21 | {}, 22 | { 23 | request_id: request_id, 24 | tuid: tuid, 25 | method_name: method_name, 26 | klass: klass, 27 | action_name: action_name, 28 | format_name: format_name, 29 | root_path: root_path, 30 | url: url, 31 | h: headers.to_h, 32 | p: params.to_h, 33 | }) 34 | 35 | nil 36 | end 37 | 38 | def upsert(trace_id, traces) 39 | patch(UPDATE_URL.gsub(':id', trace_id), {}, traces) 40 | end 41 | 42 | def show(trace_id, params = {}) 43 | get(SHOW_URL.gsub(':id', trace_id), params) 44 | end 45 | end 46 | end 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | # https://github.com/scenic-views/scenic/blob/main/.github/workflows/ci.yml 2 | 3 | name: CI 4 | 5 | on: 6 | push: 7 | branches: main 8 | pull_request: 9 | branches: "*" 10 | 11 | jobs: 12 | build: 13 | name: Ruby ${{ matrix.ruby }}, Rails ${{ matrix.rails }} 14 | 15 | strategy: 16 | fail-fast: false 17 | matrix: 18 | ruby: ["2.7", "3.0", "3.1", "3.2"] 19 | rails: ["6.1", "7.0"] 20 | continue-on-error: [false] 21 | 22 | runs-on: ubuntu-latest 23 | 24 | steps: 25 | - name: Checkout 26 | uses: actions/checkout@v3 27 | 28 | - name: Install Ruby ${{ matrix.ruby }} 29 | uses: ruby/setup-ruby@v1 30 | with: 31 | ruby-version: ${{ matrix.ruby }} 32 | 33 | - name: Generate lockfile 34 | run: bundle lock 35 | 36 | - name: Cache dependencies 37 | uses: actions/cache@v3 38 | with: 39 | path: vendor/bundle 40 | key: bundle-${{ hashFiles('Gemfile.lock') }} 41 | 42 | - name: Set up Call Stacking client 43 | run: bin/setup 44 | 45 | - name: Run tests 46 | run: bundle exec rake app:test:all 47 | continue-on-error: ${{ matrix.continue-on-error }} 48 | env: 49 | GITHUB_OUTPUT: ${{ github.action_path }}/output.txt 50 | CALLSTACKING_API_TOKEN: ${{ secrets.CALLSTACKING_API_TOKEN }} 51 | 52 | - name: Display progress 53 | run: echo "Application logging ${{ steps.run_script.outputs.progress_output }}" 54 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | # Configure Rails Environment 2 | require 'callstacking/rails/settings' 3 | require 'callstacking/rails/env' 4 | require 'callstacking/rails/client/base' 5 | require 'callstacking/rails/client/authenticate' 6 | require "callstacking/rails/logger" 7 | 8 | ENV["RAILS_ENV"] = "test" 9 | 10 | # https://github.com/Shopify/minitest-silence 11 | ENV["CI"] = "true" 12 | 13 | Callstacking::Rails::Settings.new.save('test@test.com', 14 | 'testing123', 15 | Callstacking::Rails::Settings::PRODUCTION_URL) 16 | 17 | # ENV[Callstacking::Rails::Settings::ENV_KEY] = 'false' 18 | 19 | 20 | require_relative "../test/dummy/config/environment" 21 | ActiveRecord::Migrator.migrations_paths = [File.expand_path("../test/dummy/db/migrate", __dir__)] 22 | ActiveRecord::Migrator.migrations_paths << File.expand_path("../db/migrate", __dir__) 23 | require "rails/test_help" 24 | 25 | # Load fixtures from the engine 26 | if ActiveSupport::TestCase.respond_to?(:fixture_path=) 27 | ActiveSupport::TestCase.fixture_path = File.expand_path("fixtures", __dir__) 28 | ActionDispatch::IntegrationTest.fixture_path = ActiveSupport::TestCase.fixture_path 29 | ActiveSupport::TestCase.file_fixture_path = ActiveSupport::TestCase.fixture_path + "/files" 30 | ActiveSupport::TestCase.fixtures :all 31 | end 32 | 33 | def module_and_method_exist?(module_name, method_name) 34 | Object.const_defined?(module_name.to_sym) && 35 | module_name.constantize.method_defined?(method_name.to_sym) 36 | end 37 | 38 | require 'mocha/minitest' -------------------------------------------------------------------------------- /lib/callstacking/rails/loader.rb: -------------------------------------------------------------------------------- 1 | require "rails" 2 | require "callstacking/rails/logger" 3 | 4 | module Callstacking 5 | module Rails 6 | class Loader 7 | attr_accessor :instrumenter, :klasses, :excluded, :settings 8 | def initialize(instrumenter, excluded: []) 9 | @excluded = excluded 10 | @instrumenter = instrumenter 11 | @klasses = Set.new 12 | @settings = Callstacking::Rails::Settings.new 13 | 14 | preloaded_klasses 15 | end 16 | 17 | def preloaded_klasses 18 | ObjectSpace.each_object(Module){|ob| filter_klass(ob, (Object.const_source_location(ob.to_s)&.first rescue nil))} 19 | end 20 | 21 | def on_load 22 | trace = TracePoint.new(:end) do |tp| 23 | klass = tp.self 24 | path = tp.path 25 | 26 | filter_klass(klass, path) 27 | end 28 | 29 | trace.enable 30 | end 31 | 32 | def reset! 33 | instrumenter.instrument_method(ActionView::PartialRenderer, :render, application_level: false) 34 | instrumenter.instrument_method(ActionView::TemplateRenderer, :render, application_level: false) 35 | end 36 | 37 | private 38 | def filter_klass(klass, path) 39 | return if klass.nil? || path.nil? 40 | return if path == false 41 | 42 | excluded_klass = excluded.any? { |ex| path =~ /#{ex}/ } 43 | 44 | if path =~ /#{::Rails.root.to_s}/ && 45 | !klasses.include?(klass) && 46 | !excluded_klass 47 | instrumenter.instrument_klass(klass) if settings.enabled? 48 | klasses << klass 49 | end 50 | end 51 | end 52 | end 53 | end -------------------------------------------------------------------------------- /lib/callstacking/rails/spans.rb: -------------------------------------------------------------------------------- 1 | module Callstacking 2 | module Rails 3 | class Spans 4 | attr_accessor :order_num, :nesting_level, :previous_entry 5 | attr_accessor :call_entry_callback, :call_return_callback 6 | 7 | def initialize 8 | reset 9 | end 10 | 11 | def increment_order_num 12 | @order_num+=1 13 | @order_num 14 | end 15 | 16 | def increment_nesting_level 17 | @nesting_level+=1 18 | @nesting_level 19 | end 20 | 21 | def call_entry(klass, method_name, arguments, path, line_no, method_source) 22 | @nesting_level+=1 23 | @previous_entry = previous_event(klass, method_name) 24 | @call_entry_callback.call(@nesting_level, increment_order_num, klass, method_name, arguments, path, line_no, method_source) 25 | end 26 | 27 | def call_return(klass, method_name, path, line_no, return_val, method_source) 28 | @call_return_callback.call(coupled_callee(klass, method_name), @nesting_level, 29 | increment_order_num, klass, method_name, path, line_no, return_val, method_source) 30 | @nesting_level-=1 31 | end 32 | 33 | def on_call_entry(&block) 34 | @call_entry_callback = block 35 | end 36 | 37 | def on_call_return(&block) 38 | @call_return_callback = block 39 | end 40 | 41 | def reset 42 | @nesting_level = -1 43 | @order_num = -1 44 | @previous_entry = nil 45 | end 46 | 47 | private 48 | def previous_event(klass, method_name) 49 | "#{klass}:#{method_name}" 50 | end 51 | 52 | def coupled_callee(klass, method_name) 53 | previous_entry == previous_event(klass, method_name) 54 | end 55 | end 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /test/dummy/public/500.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | We're sorry, but something went wrong (500) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
60 |
61 |

We're sorry, but something went wrong.

62 |
63 |

If you are the application owner check the logs for more information.

64 |
65 | 66 | 67 | -------------------------------------------------------------------------------- /test/dummy/config/puma.rb: -------------------------------------------------------------------------------- 1 | # Puma can serve each request in a thread from an internal thread pool. 2 | # The `threads` method setting takes two numbers: a minimum and maximum. 3 | # Any libraries that use thread pools should be configured to match 4 | # the maximum value specified for Puma. Default is set to 5 threads for minimum 5 | # and maximum; this matches the default thread size of Active Record. 6 | # 7 | max_threads_count = ENV.fetch("RAILS_MAX_THREADS") { 5 } 8 | min_threads_count = ENV.fetch("RAILS_MIN_THREADS") { max_threads_count } 9 | threads min_threads_count, max_threads_count 10 | 11 | # Specifies the `worker_timeout` threshold that Puma will use to wait before 12 | # terminating a worker in development environments. 13 | # 14 | worker_timeout 3600 if ENV.fetch("RAILS_ENV", "development") == "development" 15 | 16 | # Specifies the `port` that Puma will listen on to receive requests; default is 3000. 17 | # 18 | port ENV.fetch("PORT") { 3000 } 19 | 20 | # Specifies the `environment` that Puma will run in. 21 | # 22 | environment ENV.fetch("RAILS_ENV") { "development" } 23 | 24 | # Specifies the `pidfile` that Puma will use. 25 | pidfile ENV.fetch("PIDFILE") { "tmp/pids/server.pid" } 26 | 27 | # Specifies the number of `workers` to boot in clustered mode. 28 | # Workers are forked web server processes. If using threads and workers together 29 | # the concurrency of the application would be max `threads` * `workers`. 30 | # Workers do not work on JRuby or Windows (both of which do not support 31 | # processes). 32 | # 33 | # workers ENV.fetch("WEB_CONCURRENCY") { 2 } 34 | 35 | # Use the `preload_app!` method when specifying a `workers` number. 36 | # This directive tells Puma to first boot the application and load code 37 | # before forking the application. This takes advantage of Copy On Write 38 | # process behavior so workers use less memory. 39 | # 40 | # preload_app! 41 | 42 | # Allow puma to be restarted by `bin/rails restart` command. 43 | plugin :tmp_restart 44 | -------------------------------------------------------------------------------- /test/dummy/public/422.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | The change you wanted was rejected (422) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
60 |
61 |

The change you wanted was rejected.

62 |

Maybe you tried to change something you didn't have access to.

63 |
64 |

If you are the application owner check the logs for more information.

65 |
66 | 67 | 68 | -------------------------------------------------------------------------------- /test/dummy/public/404.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | The page you were looking for doesn't exist (404) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
60 |
61 |

The page you were looking for doesn't exist.

62 |

You may have mistyped the address or the page may have moved.

63 |
64 |

If you are the application owner check the logs for more information.

65 |
66 | 67 | 68 | -------------------------------------------------------------------------------- /test/integration/thread_safety_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | # CALLSTACKING_API_TOKEN required for these integration tests. Full end-to-end. 4 | # https://github.com/callstacking/callstacking-rails/settings/secrets/actions 5 | class ThreadSafetyTest < ActionDispatch::IntegrationTest 6 | TEST_URL = "http://www.example.com" 7 | MAX_RETRIES = 50 8 | 9 | # Test initiates multiple http requests and makes multiple method calls in parallel for each of the requests. 10 | # The results are validated against the Call Stacking server, ensuring that none of 11 | # the trace values are intermixed. 12 | test "concurrent tracing" do 13 | ::Callstacking::Rails::Trace.trace_log_clear 14 | 15 | urls = {'/hello?debug=1' => 'English', 16 | '/bounjor?debug=1' => 'French', 17 | '/hallo?debug=1' => 'German'} 18 | 19 | settings = Callstacking::Rails::Settings.new 20 | client = Callstacking::Rails::Client::Trace.new(settings.url, settings.auth_token) 21 | client.async = false # Since we'll already be running in a thread 22 | 23 | threads = urls.keys.collect do |url| 24 | Thread.new do 25 | get url 26 | end 27 | end 28 | threads.each(&:join) 29 | 30 | ::Callstacking::Rails::Trace.trace_log.each do |trace_id, url| 31 | params = url.gsub(TEST_URL, '') 32 | 33 | # Retry until the trace is available. 34 | retry_count = 0 35 | json = {'trace_entries' => []} 36 | while json['trace_entries'].empty? && retry_count <= MAX_RETRIES 37 | response = client.show(trace_id) 38 | json = response.body 39 | 40 | sleep 1 41 | retry_count+=1 42 | end 43 | 44 | ::Callstacking::Rails::Logger.log "url: #{url} -- json: #{json.inspect}" 45 | 46 | sleep 10 47 | 48 | entry_classes = json['trace_entries'].find_all do |trace_entry| 49 | urls.values.include?(trace_entry['klass']) 50 | end 51 | 52 | entry_classes.each do |trace_entry| 53 | assert_equal urls[params], trace_entry['klass'] 54 | end 55 | end 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /lib/callstacking/rails/client/base.rb: -------------------------------------------------------------------------------- 1 | require 'faraday' 2 | require 'faraday/follow_redirects' 3 | 4 | module Callstacking 5 | module Rails 6 | module Client 7 | class Error < StandardError; end 8 | 9 | class Base 10 | attr_accessor :async, :threads 11 | attr_reader :url, :auth_token 12 | 13 | def initialize(url, auth_token) 14 | @url = url 15 | @auth_token = auth_token 16 | 17 | @threads = [] 18 | @async = false 19 | end 20 | def connection 21 | # https://github.com/lostisland/awesome-faraday 22 | @connection ||= Faraday.new(url) do |c| 23 | c.response :json 24 | c.response :follow_redirects 25 | c.use Faraday::Response::RaiseError # raise exceptions on 40x, 50x responses 26 | c.request :json # This will set the "Content-Type" header to application/json and call .to_json on the body 27 | c.adapter Faraday.default_adapter 28 | c.options.timeout = 120 29 | 30 | if auth_token.present? 31 | c.request :authorization, :Bearer, auth_token 32 | end 33 | end 34 | end 35 | 36 | def get(url, params = {}, headers = {}) 37 | if async 38 | threads << Thread.new do 39 | connection.get(url, params, headers) 40 | end 41 | else 42 | connection.get(url, params, headers) 43 | end 44 | ensure 45 | Faraday.default_connection.close if async 46 | end 47 | 48 | def post(url, params = {}, body = {}, headers_ext = {}) 49 | r(:post, url, params, body, headers_ext) 50 | end 51 | 52 | def patch(url, params = {}, body = {}, headers_ext = {}) 53 | r(:patch, url, params, body, headers_ext) 54 | end 55 | 56 | def r(action, url, params = {}, body = {}, _headers_ext = {}) 57 | if async 58 | threads << Thread.new do 59 | connection.send(action, url) do |req| 60 | req.params.merge!(params) 61 | req.body = body 62 | end 63 | end 64 | else 65 | connection.send(action, url) do |req| 66 | req.params.merge!(params) 67 | req.body = body 68 | end 69 | end 70 | ensure 71 | Faraday.default_connection.close if async 72 | end 73 | end 74 | end 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /test/dummy/config/environments/test.rb: -------------------------------------------------------------------------------- 1 | require "active_support/core_ext/integer/time" 2 | 3 | # The test environment is used exclusively to run your application's 4 | # test suite. You never need to work with it otherwise. Remember that 5 | # your test database is "scratch space" for the test suite and is wiped 6 | # and recreated between test runs. Don't rely on the data there! 7 | 8 | Rails.application.configure do 9 | # Settings specified here will take precedence over those in config/application.rb. 10 | 11 | # Turn false under Spring and add config.action_view.cache_template_loading = true. 12 | config.cache_classes = true 13 | 14 | # Eager loading loads your whole application. When running a single test locally, 15 | # this probably isn't necessary. It's a good idea to do in a continuous integration 16 | # system, or in some way before deploying your code. 17 | config.eager_load = ENV["CI"].present? 18 | 19 | # Configure public file server for tests with Cache-Control for performance. 20 | config.public_file_server.enabled = true 21 | config.public_file_server.headers = { 22 | "Cache-Control" => "public, max-age=#{1.hour.to_i}" 23 | } 24 | 25 | # Show full error reports and disable caching. 26 | config.consider_all_requests_local = true 27 | config.action_controller.perform_caching = false 28 | config.cache_store = :null_store 29 | 30 | # Raise exceptions instead of rendering exception templates. 31 | config.action_dispatch.show_exceptions = false 32 | 33 | # Disable request forgery protection in test environment. 34 | config.action_controller.allow_forgery_protection = false 35 | 36 | # Store uploaded files on the local file system in a temporary directory. 37 | config.active_storage.service = :test 38 | 39 | config.action_mailer.perform_caching = false 40 | 41 | # Tell Action Mailer not to deliver emails to the real world. 42 | # The :test delivery method accumulates sent emails in the 43 | # ActionMailer::Base.deliveries array. 44 | config.action_mailer.delivery_method = :test 45 | 46 | # Print deprecation notices to the stderr. 47 | config.active_support.deprecation = :stderr 48 | 49 | # Raise exceptions for disallowed deprecations. 50 | config.active_support.disallowed_deprecation = :raise 51 | 52 | # Tell Active Support which deprecation messages to disallow. 53 | config.active_support.disallowed_deprecation_warnings = [] 54 | 55 | # Raises error for missing translations. 56 | # config.i18n.raise_on_missing_translations = true 57 | 58 | # Annotate rendered view with file names. 59 | # config.action_view.annotate_rendered_view_with_filenames = true 60 | end 61 | -------------------------------------------------------------------------------- /test/dummy/config/environments/development.rb: -------------------------------------------------------------------------------- 1 | require "active_support/core_ext/integer/time" 2 | 3 | Rails.application.configure do 4 | # Settings specified here will take precedence over those in config/application.rb. 5 | 6 | # In the development environment your application's code is reloaded any time 7 | # it changes. This slows down response time but is perfect for development 8 | # since you don't have to restart the web server when you make code changes. 9 | config.cache_classes = false 10 | 11 | # Do not eager load code on boot. 12 | config.eager_load = false 13 | 14 | # Show full error reports. 15 | config.consider_all_requests_local = true 16 | 17 | # Enable server timing 18 | config.server_timing = true 19 | 20 | # Enable/disable caching. By default caching is disabled. 21 | # Run rails dev:cache to toggle caching. 22 | if ::Rails.root.join("tmp/caching-dev.txt").exist? 23 | config.action_controller.perform_caching = true 24 | config.action_controller.enable_fragment_cache_logging = true 25 | 26 | config.cache_store = :memory_store 27 | config.public_file_server.headers = { 28 | "Cache-Control" => "public, max-age=#{2.days.to_i}" 29 | } 30 | else 31 | config.action_controller.perform_caching = false 32 | 33 | config.cache_store = :null_store 34 | end 35 | 36 | # Store uploaded files on the local file system (see config/storage.yml for options). 37 | config.active_storage.service = :local 38 | 39 | # Don't care if the mailer can't send. 40 | config.action_mailer.raise_delivery_errors = false 41 | 42 | config.action_mailer.perform_caching = false 43 | 44 | # Print deprecation notices to the Rails logger. 45 | config.active_support.deprecation = :log 46 | 47 | # Raise exceptions for disallowed deprecations. 48 | config.active_support.disallowed_deprecation = :raise 49 | 50 | # Tell Active Support which deprecation messages to disallow. 51 | config.active_support.disallowed_deprecation_warnings = [] 52 | 53 | # Raise an error on page load if there are pending migrations. 54 | config.active_record.migration_error = :page_load 55 | 56 | # Highlight code that triggered database queries in logs. 57 | config.active_record.verbose_query_logs = true 58 | 59 | # Suppress logger output for asset requests. 60 | config.assets.quiet = true 61 | 62 | # Raises error for missing translations. 63 | # config.i18n.raise_on_missing_translations = true 64 | 65 | # Annotate rendered view with file names. 66 | # config.action_view.annotate_rendered_view_with_filenames = true 67 | 68 | # Uncomment if you wish to allow Action Cable access from any origin. 69 | # config.action_cable.disable_request_forgery_protection = true 70 | end 71 | -------------------------------------------------------------------------------- /lib/callstacking/rails/helpers/heads_up_display_helper.rb: -------------------------------------------------------------------------------- 1 | require 'action_view/helpers' 2 | require "action_view/context.rb" 3 | 4 | module Callstacking 5 | module Rails 6 | module Helpers 7 | module HeadsUpDisplayHelper 8 | include ActionView::Helpers::TagHelper 9 | include ActionView::Helpers::JavaScriptHelper 10 | include ActionView::Context 11 | 12 | def hud(url) 13 | frame_url = "#{url || Callstacking::Rails::Settings::PRODUCTION_URL}/traces/#{Callstacking::Rails::Trace.current_trace_id}/print" 14 | 15 | body = [] 16 | body << (content_tag(:div, data: { turbo: false }, 17 | style: 'top: 50%; right: 10px; font-size: 24pt; :hover{text-shadow: 1px 1px 2px #000000}; 18 | padding: 0px; position: fixed; height: 50px; width: 40px; cursor: pointer;', 19 | onclick: 'document.getElementById("callstacking-debugger").style.display = "unset"; 20 | document.getElementById("callstacking-close").style.display = "unset";') do 21 | "
#{Callstacking::Rails::Trace::ICON}
".html_safe 22 | end) 23 | 24 | body << (content_tag(:iframe, src: frame_url, id: 'callstacking-debugger', data: { turbo: false }, 25 | style: "width: 50%; height: 100%; overflow: scroll; top: 20px; right: 20px; position: fixed; 26 | z-index: 99; opacity: 1.0; background-color: #FFF; color: #000; border: 1px solid; 27 | margin: 0; padding: 0; box-shadow: 5px 5px; display: none;") do 28 | end) 29 | 30 | body << (javascript_tag(' 31 | document.onkeyup = function(e) { 32 | // Mac - option-d Win - alt-d 33 | if (e.ctrlKey && e.which == 68) { 34 | if (document.getElementById("callstacking-debugger").style.display === "none") { 35 | document.getElementById("callstacking-debugger").style.display = "block"; 36 | document.getElementById("callstacking-debugger").contentDocument.location.reload(true); 37 | document.getElementById("callstacking-debugger").focus(); 38 | } else { 39 | document.getElementById("callstacking-debugger").style.display = "none"; 40 | } 41 | } 42 | };')) 43 | 44 | body.join 45 | end 46 | 47 | def inject_hud(settings, request, response) 48 | if request.format.html? 49 | response.body = response.body.sub(/<\/body>/i, "#{hud(settings.url)}") 50 | end 51 | end 52 | end 53 | end 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /lib/callstacking/rails/setup.rb: -------------------------------------------------------------------------------- 1 | require 'yaml' 2 | require 'io/console' 3 | 4 | module Callstacking 5 | module Rails 6 | class Setup 7 | attr_accessor :settings 8 | 9 | def initialize 10 | @settings = Callstacking::Rails::Settings.new 11 | end 12 | 13 | def start 14 | puts "Login to callstacking.com" 15 | puts 16 | 17 | email = prompt("Enter email:", echo: true) 18 | password = prompt("Enter password:", echo: false) 19 | 20 | settings.save(email, password, url) 21 | 22 | puts "Authentication successful." 23 | puts "Settings saved to #{Callstacking::Rails::Settings::SETTINGS_FILE}" 24 | true 25 | rescue StandardError => e 26 | puts "Error authenticating: #{e.message}" 27 | puts e.backtrace.join("\n") 28 | false 29 | end 30 | 31 | def prompt(label, echo: true) 32 | puts label 33 | 34 | value = echo ? STDIN.gets.chomp : STDIN.noecho(&:gets).chomp 35 | puts 36 | 37 | return nil if value == '' 38 | value 39 | end 40 | 41 | def self.instructions 42 | puts "loading environment #{Callstacking::Rails::Env.environment}" 43 | puts 44 | puts "Usage: " 45 | puts 46 | puts " > callstacking-rails register" 47 | puts 48 | puts " Opens a browser window to register as a callstacking.com user." 49 | puts 50 | puts " > callstacking-rails setup" 51 | puts 52 | puts " Interactively prompts you for your callstacking.com username/password." 53 | puts " Stores auth details in #{Callstacking::Rails::Settings::SETTINGS_FILE}" 54 | puts 55 | puts " > callstacking-rails enable" 56 | puts 57 | puts " Enables the callstacking tracing." 58 | puts 59 | puts " > callstacking-rails disable" 60 | puts 61 | puts " Disables the callstacking tracing." 62 | puts 63 | puts " You can have multiple environments." 64 | puts " The default is #{Callstacking::Rails::Env::DEFAULT_ENVIRONMENT}." 65 | puts 66 | puts " The #{Callstacking::Rails::Env.environment}: section in the #{Callstacking::Rails::Settings::SETTINGS_FILE} contains your credentials." 67 | puts " By setting the RAILS_ENV environment you can maintain multiple settings." 68 | puts 69 | puts "Questions? Create an issue: https://github.com/callstacking/callstacking-rails/issues" 70 | 71 | :instructions 72 | end 73 | 74 | private 75 | 76 | def url 77 | if (Callstacking::Rails::Env.production? || ::Rails.env.test?) && 78 | ENV['CALLSTACKING_RAILS_LOCAL_TEST'].nil? 79 | Callstacking::Rails::Settings::PRODUCTION_URL 80 | else 81 | prompt("Enter URL for #{Callstacking::Rails::Env.environment} API calls [#{Callstacking::Rails::Settings::PRODUCTION_URL}]:") || 82 | Callstacking::Rails::Settings::PRODUCTION_URL 83 | end 84 | end 85 | end 86 | end 87 | end -------------------------------------------------------------------------------- /lib/callstacking/rails/settings.rb: -------------------------------------------------------------------------------- 1 | require "active_support/cache" 2 | require "active_support/concern" 3 | require "active_support/core_ext/class/attribute_accessors" 4 | 5 | module Callstacking 6 | module Rails 7 | class Settings 8 | attr_accessor :settings 9 | attr_reader :client 10 | 11 | SETTINGS_FILE = "#{Dir.home}/.callstacking" 12 | PRODUCTION_URL = "https://callstacking.com" 13 | ENV_KEY = 'CALLSTACKING_ENABLED' 14 | CACHE_KEY = :callstacking_enabled 15 | 16 | def initialize 17 | read_settings 18 | @client = Callstacking::Rails::Client::Authenticate.new(url, auth_token) 19 | end 20 | 21 | def url 22 | settings[:url] || PRODUCTION_URL 23 | end 24 | 25 | def auth_token 26 | ENV['CALLSTACKING_API_TOKEN'] || settings[:auth_token] 27 | end 28 | 29 | def auth_token? 30 | !auth_token.nil? 31 | end 32 | 33 | def write_settings(new_settings) 34 | File.write(SETTINGS_FILE, new_settings.to_yaml) 35 | end 36 | 37 | def self.enable! 38 | Thread.current[CACHE_KEY] = true 39 | end 40 | def enable! 41 | self.class.enable! 42 | end 43 | 44 | def self.disable! 45 | Thread.current[CACHE_KEY] = false 46 | end 47 | 48 | def disable! 49 | self.class.disable! 50 | end 51 | 52 | def enabled? 53 | return ActiveRecord::Type::Boolean.new.cast(ENV[ENV_KEY]) if ENV[ENV_KEY].present? 54 | return Thread.current[CACHE_KEY] if Thread.current[CACHE_KEY].present? 55 | false 56 | end 57 | 58 | def excluded 59 | settings[:excluded] || [] 60 | end 61 | 62 | def disabled? 63 | !enabled? 64 | end 65 | 66 | def analyze_source? 67 | settings[:analyze_source] || false 68 | end 69 | 70 | def save(email, password, url) 71 | props = { auth_token: '', 72 | url: url, 73 | enabled: true, 74 | analyze_source: false, 75 | } 76 | 77 | props = { Callstacking::Rails::Env.environment => { 78 | settings: props 79 | } } 80 | 81 | write_settings(complete_settings.merge(props)) 82 | 83 | props[Callstacking::Rails::Env.environment][:settings][:auth_token] = token(email, password) 84 | 85 | write_settings(complete_settings.merge(props)) 86 | 87 | read_settings 88 | end 89 | 90 | def enable_disable(enabled: true) 91 | settings[:enabled] = enabled 92 | 93 | props = { Callstacking::Rails::Env.environment => { 94 | settings: settings 95 | } } 96 | 97 | write_settings(complete_settings.merge(props)) 98 | end 99 | 100 | private 101 | 102 | def token(email, password) 103 | client.login(email, password) 104 | end 105 | 106 | def read_settings 107 | @settings = complete_settings.dig(::Callstacking::Rails::Env.environment, :settings) || {} 108 | rescue StandardError => e 109 | puts e.full_message 110 | puts e.backtrace.join("\n") 111 | return {} 112 | end 113 | 114 | def complete_settings 115 | YAML.load(File.read(SETTINGS_FILE)) rescue {} 116 | end 117 | end 118 | end 119 | end -------------------------------------------------------------------------------- /lib/callstacking/rails/engine.rb: -------------------------------------------------------------------------------- 1 | require "rails" 2 | require "active_support/cache" 3 | require "callstacking/rails/env" 4 | require "callstacking/rails/trace" 5 | require "callstacking/rails/instrument" 6 | require 'callstacking/rails/spans' 7 | require "callstacking/rails/setup" 8 | require "callstacking/rails/settings" 9 | require "callstacking/rails/loader" 10 | require "callstacking/rails/client/base" 11 | require "callstacking/rails/client/authenticate" 12 | require "callstacking/rails/client/trace" 13 | require "callstacking/rails/cli" 14 | require "callstacking/rails/time_based_uuid" 15 | require "callstacking/rails/helpers/instrument_helper" 16 | require "callstacking/rails/logger" 17 | 18 | module Callstacking 19 | module Rails 20 | class Engine < ::Rails::Engine 21 | EXCLUDED_TEST_CLASSES = %w[test/dummy/app/models/salutation.rb 22 | test/dummy/app/controllers/application_controller.rb].freeze 23 | 24 | cattr_accessor :spans, :traces, :settings, :instrumenter, :loader, :lock 25 | 26 | isolate_namespace Callstacking::Rails 27 | 28 | @@spans||={} 29 | @@traces||={} 30 | @@lock||=Mutex.new 31 | @@instrumenter||=Instrument.new 32 | @@settings||=Callstacking::Rails::Settings.new 33 | 34 | initializer "engine_name.assets.precompile" do |app| 35 | app.config.assets.precompile << "checkpoint_rails_manifest.js" 36 | end 37 | 38 | config.after_initialize do 39 | Logger.log "Call Stacking loading (#{Callstacking::Rails::Env.environment})" 40 | 41 | spans[Thread.current.object_id]||=Spans.new 42 | instrumenter.add_span(spans[Thread.current.object_id]) 43 | 44 | @@loader = Callstacking::Rails::Loader.new(instrumenter, 45 | excluded: settings.excluded + EXCLUDED_TEST_CLASSES) 46 | loader.on_load 47 | end 48 | 49 | # Serialize all tracing requests for now. 50 | # Can enable parallel tracing later. 51 | def self.start_tracing(controller) 52 | Logger.log("Callstacking::Rails::Engine.start_tracing") 53 | 54 | settings.enable! 55 | 56 | lock.synchronize do 57 | spans[Thread.current.object_id]||=Spans.new 58 | span = spans[Thread.current.object_id] 59 | 60 | instrumenter.add_span(span) 61 | 62 | if instrumenter.instrumentation_required? 63 | loader.reset! 64 | instrumenter.enable!(loader.klasses.to_a) 65 | end 66 | 67 | traces[Thread.current.object_id] = Trace.new(span) 68 | trace = traces[Thread.current.object_id] 69 | 70 | trace.begin_trace(controller) 71 | end 72 | 73 | true 74 | end 75 | 76 | def self.stop_tracing(controller, exception) 77 | Logger.log("Callstacking::Rails::Engine.stop_tracing") 78 | 79 | settings.disable! 80 | 81 | trace = nil 82 | lock.synchronize do 83 | trace = traces.delete(Thread.current.object_id) 84 | if traces.empty? 85 | instrumenter.disable! 86 | end 87 | end 88 | 89 | trace&.end_trace(controller, exception) 90 | 91 | lock.synchronize do 92 | spans[Thread.current.object_id]&.reset 93 | end 94 | 95 | true 96 | end 97 | end 98 | end 99 | end 100 | -------------------------------------------------------------------------------- /test/callstacking/rails/instrument_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | require 'minitest/autorun' 5 | require 'active_support/dependencies.rb' 6 | 7 | module Callstacking 8 | module Rails 9 | class InstrumentTest < Minitest::Test 10 | TEST_MODULES = [:SalutationSpan, :ApplicationControllerSpan] 11 | 12 | def setup 13 | @spans = Callstacking::Rails::Spans.new 14 | @trace = Callstacking::Rails::Trace.new(@spans) 15 | @subject = Callstacking::Rails::Instrument.new 16 | @settings = Callstacking::Rails::Settings.new 17 | 18 | @subject.add_span(@spans) 19 | # The tests run in random order. 20 | # The above TEST_MODULES may or may not be globally defined. 21 | # Need to reset them for each test. 22 | modules = TEST_MODULES.collect do |m| 23 | m.to_s.constantize if Object.const_defined?(m) 24 | end.compact 25 | 26 | @subject.disable!(modules) 27 | @settings.enable! 28 | end 29 | 30 | def test_instrument_klass 31 | assert_match /app\/models\/salutation\.rb/, Salutation.instance_method(:hello).source_location.first 32 | 33 | Trace.any_instance.expects(:create_call_entry).never 34 | Trace.any_instance.expects(:create_call_return).never 35 | 36 | Salutation.new.hello('Jim') 37 | 38 | @subject.instrument_klass(Salutation, application_level: true) 39 | 40 | assert_equal 2, ::SalutationSpan.instance_methods(false).size 41 | 42 | assert_equal true, Salutation.ancestors.include?(::SalutationSpan) 43 | assert_match /instrument.rb/, Salutation.instance_method(:hello).source_location.first 44 | 45 | Trace.any_instance.expects(:create_call_entry) 46 | Trace.any_instance.expects(:create_call_return) 47 | 48 | Salutation.new.hello('Jim') 49 | 50 | @subject.disable!([::SalutationSpan]) 51 | end 52 | 53 | def test_application_level_instrumentation 54 | assert_equal false, Object.const_defined?(:ApplicationControllerSpan) 55 | 56 | @subject.instrument_klass(::ApplicationController, application_level: true) 57 | 58 | assert_equal true, Object.const_defined?(:ApplicationControllerSpan) 59 | assert_equal true, ::ApplicationController.ancestors.include?(::ApplicationControllerSpan) 60 | assert_equal true, ::ApplicationController.instance_methods.include?(:index) 61 | assert_equal true, ::ApplicationControllerSpan.instance_methods.include?(:index) 62 | assert_equal false, ::ApplicationControllerSpan.instance_methods.include?(:run_callbacks) 63 | 64 | @subject.instrument_klass(::ApplicationController, application_level: false) 65 | assert_equal true, ::ApplicationController.instance_methods.include?(:run_callbacks) 66 | assert_equal true, ::ApplicationControllerSpan.instance_methods.include?(:run_callbacks) 67 | @subject.disable!([::ApplicationControllerSpan]) 68 | end 69 | 70 | def test_enable_disable 71 | assert_equal false, module_and_method_exist?('SalutationSpan', :hello) 72 | 73 | @subject.enable!([Salutation]) 74 | 75 | assert_equal true, Salutation.ancestors.include?(::SalutationSpan) 76 | assert_equal true, module_and_method_exist?('SalutationSpan', :hello) 77 | 78 | assert_match /instrument.rb/, Salutation.instance_method(:hello).source_location.first 79 | assert_equal 2, ::SalutationSpan.instance_methods(false).size 80 | 81 | @subject.disable! 82 | assert_equal true, Object.const_defined?(:SalutationSpan) 83 | assert_equal true, Salutation.ancestors.include?(::SalutationSpan) 84 | 85 | # assert_match /app\/models\/salutation\.rb/, Salutation.instance_method(:hello).source_location.first 86 | assert_equal 0, ::SalutationSpan.instance_methods.size 87 | end 88 | end 89 | end 90 | end 91 | -------------------------------------------------------------------------------- /test/dummy/config/environments/production.rb: -------------------------------------------------------------------------------- 1 | require "active_support/core_ext/integer/time" 2 | 3 | Rails.application.configure do 4 | # Settings specified here will take precedence over those in config/application.rb. 5 | 6 | # Code is not reloaded between requests. 7 | config.cache_classes = true 8 | 9 | # Eager load code on boot. This eager loads most of Rails and 10 | # your application in memory, allowing both threaded web servers 11 | # and those relying on copy on write to perform better. 12 | # Rake tasks automatically ignore this option for performance. 13 | config.eager_load = true 14 | 15 | # Full error reports are disabled and caching is turned on. 16 | config.consider_all_requests_local = false 17 | config.action_controller.perform_caching = true 18 | 19 | # Ensures that a master key has been made available in either ENV["RAILS_MASTER_KEY"] 20 | # or in config/master.key. This key is used to decrypt credentials (and other encrypted files). 21 | # config.require_master_key = true 22 | 23 | # Disable serving static files from the `/public` folder by default since 24 | # Apache or NGINX already handles this. 25 | config.public_file_server.enabled = ENV["RAILS_SERVE_STATIC_FILES"].present? 26 | 27 | # Compress CSS using a preprocessor. 28 | # config.assets.css_compressor = :sass 29 | 30 | # Do not fallback to assets pipeline if a precompiled asset is missed. 31 | config.assets.compile = false 32 | 33 | # Enable serving of images, stylesheets, and JavaScripts from an asset server. 34 | # config.asset_host = "http://assets.example.com" 35 | 36 | # Specifies the header that your server uses for sending files. 37 | # config.action_dispatch.x_sendfile_header = "X-Sendfile" # for Apache 38 | # config.action_dispatch.x_sendfile_header = "X-Accel-Redirect" # for NGINX 39 | 40 | # Store uploaded files on the local file system (see config/storage.yml for options). 41 | config.active_storage.service = :local 42 | 43 | # Mount Action Cable outside main process or domain. 44 | # config.action_cable.mount_path = nil 45 | # config.action_cable.url = "wss://example.com/cable" 46 | # config.action_cable.allowed_request_origins = [ "http://example.com", /http:\/\/example.*/ ] 47 | 48 | # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies. 49 | # config.force_ssl = true 50 | 51 | # Include generic and useful information about system operation, but avoid logging too much 52 | # information to avoid inadvertent exposure of personally identifiable information (PII). 53 | config.log_level = :info 54 | 55 | # Prepend all log lines with the following tags. 56 | config.log_tags = [ :request_id ] 57 | 58 | # Use a different cache store in production. 59 | # config.cache_store = :mem_cache_store 60 | 61 | # Use a real queuing backend for Active Job (and separate queues per environment). 62 | # config.active_job.queue_adapter = :resque 63 | # config.active_job.queue_name_prefix = "dummy_production" 64 | 65 | config.action_mailer.perform_caching = false 66 | 67 | # Ignore bad email addresses and do not raise email delivery errors. 68 | # Set this to true and configure the email server for immediate delivery to raise delivery errors. 69 | # config.action_mailer.raise_delivery_errors = false 70 | 71 | # Enable locale fallbacks for I18n (makes lookups for any locale fall back to 72 | # the I18n.default_locale when a translation cannot be found). 73 | config.i18n.fallbacks = true 74 | 75 | # Don't log any deprecations. 76 | config.active_support.report_deprecations = false 77 | 78 | # Use default logging formatter so that PID and timestamp are not suppressed. 79 | config.log_formatter = ::Logger::Formatter.new 80 | 81 | # Use a different logger for distributed setups. 82 | # require "syslog/logger" 83 | # config.logger = ActiveSupport::TaggedLogging.new(Syslog::Logger.new "app-name") 84 | 85 | if ENV["RAILS_LOG_TO_STDOUT"].present? 86 | logger = ActiveSupport::Logger.new(STDOUT) 87 | logger.formatter = config.log_formatter 88 | config.logger = ActiveSupport::TaggedLogging.new(logger) 89 | end 90 | 91 | # Do not dump schema after migrations. 92 | config.active_record.dump_schema_after_migration = false 93 | end 94 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Callstacking::Rails 2 | [![Build Status](https://github.com/callstacking/callstacking-rails/actions/workflows/ci.yml/badge.svg)](https://github.com/callstacking/callstacking-rails/actions/workflows/ci.yml) 3 | 4 | Call Stacking is a rolling, checkpoint debugger for Rails. It records all of the critical method calls within your app, along with their important context (param/argument/return/local variable values). 5 | 6 | You no longer need to debug with `binding.pry` or `puts` statements, as the entire callstack for a given request is captured. 7 | 8 | Demo video: 9 | [![Watch the video](https://user-images.githubusercontent.com/4600/190929740-fc68e18f-9572-41be-9719-cc6a8077e97f.png)](https://www.youtube.com/watch?v=NGqnwcNWv_k) 10 | 11 | Class method calls are labeled. Return values for those calls are denoted with ↳ 12 | 13 | Arguments for a method will be listed along with their calling values. 14 | 15 | For method returns ↳, the final values of the local variables will be listed when you hover over the entry. 16 | 17 | CleanShot 2022-09-17 at 21 10 32@2x 18 | 19 | Subsequent calls within a method are visibly nested. 20 | 21 | Call Stacking is a Rails engine that you mount within your Rails app. 22 | 23 | Here's a sample debugging sessions recorded from a Jumpstart Rails based app I've been working on. This is a request for the main page ( https://smartk.id/ ). 24 | 25 | ![image](https://user-images.githubusercontent.com/4600/190882432-58092e38-7ee2-4138-b13a-f45ff2b09227.png) 26 | 27 | Call Stacking Rails records all of the critical method calls within your app, along with their important context (param/argument/return/local variable values). 28 | 29 | All in a rolling panel, so that you can debug your call chains from any point in the stack. 30 | 31 | You'll never have to debug with `puts` statements ever again. 32 | 33 | Calls are visibly nested so that it's easy to see which calls are issued from which parent methods. 34 | 35 | ## Installation 36 | Add this line to your application's Gemfile: 37 | 38 | ```ruby 39 | gem "callstacking-rails" 40 | ``` 41 | 42 | And then execute: 43 | ```bash 44 | $ bundle 45 | ``` 46 | 47 | ## CLI Setup 48 | 49 | *Step 1:* 50 | > bundle exec callstacking-rails register 51 | 52 | The above command will open a browser window and allow you to register an account at callstacking.com. 53 | 54 | *Step 2:* 55 | > bundle exec callstacking-rails setup 56 | 57 | This interactively prompts you for your callstacking.com username/password. 58 | 59 | The auth details are stored in `~/.callstacking`. 60 | 61 | ## Enabling Tracing 62 | 63 | Call Stacking provides a helper method `callstacking_setup` that you can use to enable tracing for a given request. 64 | 65 | You control how you enable tracing. 66 | 67 | Here's a sample setup to add tracing to your requests that both checks for the current user to be an admin 68 | and for a `debug` param to be set to `1`: 69 | 70 | ``` 71 | class ApplicationController < ActionController::Base 72 | include Callstacking::Rails::Helpers::InstrumentHelper 73 | 74 | prepend_around_action :callstacking_setup, if: -> { current_user&.admin? && params[:debug] == '1' } 75 | ``` 76 | 77 | For the above setup, you would you have to be authenticated as an admin and would append `?debug=1` 78 | to the URL you wish to trace. 79 | 80 | e.g. 81 | 82 | * http://localhost:3000/?debug=1 83 | * http://example.com/?debug=1 84 | 85 | The local Rails server log outputs the trace URL. 86 | 87 | screenshot of trace url output in Rails logs 88 | 89 | ## Production Environment 90 | 91 | For production, you can provide the auth token via the `CALLSTACKING_API_TOKEN` environment variable. 92 | 93 | Your API token values can be viewed at https://callstacking.com/api_tokens 94 | 95 | The traces are recorded at https://callstacking.com/traces 96 | 97 | ## Local Development: Heads Up Debugger 98 | 99 | For local HTML requests, once your page has rendered, you will see a `💥` icon on the right hand side. 100 | 101 | Click the icon and observe the trace. 102 | 103 | ### Headless API requests 104 | 105 | The trace URL is output via the Rails logs. https://callstacking.com/traces will updated with your latest trace. 106 | 107 | ## Tests 108 | `` 109 | rake app:test:all 110 | `` 111 | 112 | ## Questions/Bugs/Feature Requests 113 | 114 | Create an issue: https://github.com/callstacking/callstacking-rails/issues 115 | 116 | ## License 117 | The license can be viewed at https://github.com/callstacking/callstacking-rails/blob/main/LICENSE -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | callstacking-rails (0.1.38) 5 | faraday (>= 1.10.3) 6 | faraday-follow_redirects 7 | method_source 8 | rails (>= 4) 9 | 10 | GEM 11 | remote: https://rubygems.org/ 12 | specs: 13 | actioncable (7.0.4.3) 14 | actionpack (= 7.0.4.3) 15 | activesupport (= 7.0.4.3) 16 | nio4r (~> 2.0) 17 | websocket-driver (>= 0.6.1) 18 | actionmailbox (7.0.4.3) 19 | actionpack (= 7.0.4.3) 20 | activejob (= 7.0.4.3) 21 | activerecord (= 7.0.4.3) 22 | activestorage (= 7.0.4.3) 23 | activesupport (= 7.0.4.3) 24 | mail (>= 2.7.1) 25 | net-imap 26 | net-pop 27 | net-smtp 28 | actionmailer (7.0.4.3) 29 | actionpack (= 7.0.4.3) 30 | actionview (= 7.0.4.3) 31 | activejob (= 7.0.4.3) 32 | activesupport (= 7.0.4.3) 33 | mail (~> 2.5, >= 2.5.4) 34 | net-imap 35 | net-pop 36 | net-smtp 37 | rails-dom-testing (~> 2.0) 38 | actionpack (7.0.4.3) 39 | actionview (= 7.0.4.3) 40 | activesupport (= 7.0.4.3) 41 | rack (~> 2.0, >= 2.2.0) 42 | rack-test (>= 0.6.3) 43 | rails-dom-testing (~> 2.0) 44 | rails-html-sanitizer (~> 1.0, >= 1.2.0) 45 | actiontext (7.0.4.3) 46 | actionpack (= 7.0.4.3) 47 | activerecord (= 7.0.4.3) 48 | activestorage (= 7.0.4.3) 49 | activesupport (= 7.0.4.3) 50 | globalid (>= 0.6.0) 51 | nokogiri (>= 1.8.5) 52 | actionview (7.0.4.3) 53 | activesupport (= 7.0.4.3) 54 | builder (~> 3.1) 55 | erubi (~> 1.4) 56 | rails-dom-testing (~> 2.0) 57 | rails-html-sanitizer (~> 1.1, >= 1.2.0) 58 | activejob (7.0.4.3) 59 | activesupport (= 7.0.4.3) 60 | globalid (>= 0.3.6) 61 | activemodel (7.0.4.3) 62 | activesupport (= 7.0.4.3) 63 | activerecord (7.0.4.3) 64 | activemodel (= 7.0.4.3) 65 | activesupport (= 7.0.4.3) 66 | activestorage (7.0.4.3) 67 | actionpack (= 7.0.4.3) 68 | activejob (= 7.0.4.3) 69 | activerecord (= 7.0.4.3) 70 | activesupport (= 7.0.4.3) 71 | marcel (~> 1.0) 72 | mini_mime (>= 1.1.0) 73 | activesupport (7.0.4.3) 74 | concurrent-ruby (~> 1.0, >= 1.0.2) 75 | i18n (>= 1.6, < 2) 76 | minitest (>= 5.1) 77 | tzinfo (~> 2.0) 78 | base64 (0.2.0) 79 | builder (3.2.4) 80 | concurrent-ruby (1.2.2) 81 | crass (1.0.6) 82 | date (3.3.4) 83 | erubi (1.12.0) 84 | faraday (2.7.12) 85 | base64 86 | faraday-net_http (>= 2.0, < 3.1) 87 | ruby2_keywords (>= 0.0.4) 88 | faraday-follow_redirects (0.3.0) 89 | faraday (>= 1, < 3) 90 | faraday-net_http (3.0.2) 91 | globalid (1.2.1) 92 | activesupport (>= 6.1) 93 | i18n (1.12.0) 94 | concurrent-ruby (~> 1.0) 95 | loofah (2.20.0) 96 | crass (~> 1.0.2) 97 | nokogiri (>= 1.5.9) 98 | mail (2.8.1) 99 | mini_mime (>= 0.1.1) 100 | net-imap 101 | net-pop 102 | net-smtp 103 | marcel (1.0.2) 104 | method_source (1.0.0) 105 | mini_mime (1.1.5) 106 | minitest (5.18.0) 107 | minitest-silence (0.2.4) 108 | minitest (~> 5.12) 109 | mocha (2.0.2) 110 | ruby2_keywords (>= 0.0.5) 111 | net-imap (0.4.6) 112 | date 113 | net-protocol 114 | net-pop (0.1.2) 115 | net-protocol 116 | net-protocol (0.2.2) 117 | timeout 118 | net-smtp (0.4.0) 119 | net-protocol 120 | nio4r (2.6.1) 121 | nokogiri (1.14.2-arm64-darwin) 122 | racc (~> 1.4) 123 | racc (1.6.2) 124 | rack (2.2.6.4) 125 | rack-test (2.1.0) 126 | rack (>= 1.3) 127 | rails (7.0.4.3) 128 | actioncable (= 7.0.4.3) 129 | actionmailbox (= 7.0.4.3) 130 | actionmailer (= 7.0.4.3) 131 | actionpack (= 7.0.4.3) 132 | actiontext (= 7.0.4.3) 133 | actionview (= 7.0.4.3) 134 | activejob (= 7.0.4.3) 135 | activemodel (= 7.0.4.3) 136 | activerecord (= 7.0.4.3) 137 | activestorage (= 7.0.4.3) 138 | activesupport (= 7.0.4.3) 139 | bundler (>= 1.15.0) 140 | railties (= 7.0.4.3) 141 | rails-dom-testing (2.0.3) 142 | activesupport (>= 4.2.0) 143 | nokogiri (>= 1.6) 144 | rails-html-sanitizer (1.5.0) 145 | loofah (~> 2.19, >= 2.19.1) 146 | railties (7.0.4.3) 147 | actionpack (= 7.0.4.3) 148 | activesupport (= 7.0.4.3) 149 | method_source 150 | rake (>= 12.2) 151 | thor (~> 1.0) 152 | zeitwerk (~> 2.5) 153 | rake (13.1.0) 154 | ruby2_keywords (0.0.5) 155 | sprockets (4.2.0) 156 | concurrent-ruby (~> 1.0) 157 | rack (>= 2.2.4, < 4) 158 | sprockets-rails (3.4.2) 159 | actionpack (>= 5.2) 160 | activesupport (>= 5.2) 161 | sprockets (>= 3.0.0) 162 | sqlite3 (1.6.2-arm64-darwin) 163 | thor (1.3.0) 164 | timeout (0.4.1) 165 | tzinfo (2.0.6) 166 | concurrent-ruby (~> 1.0) 167 | websocket-driver (0.7.6) 168 | websocket-extensions (>= 0.1.0) 169 | websocket-extensions (0.1.5) 170 | zeitwerk (2.6.12) 171 | 172 | PLATFORMS 173 | arm64-darwin-21 174 | 175 | DEPENDENCIES 176 | callstacking-rails! 177 | minitest-silence 178 | mocha 179 | sprockets-rails 180 | sqlite3 181 | 182 | BUNDLED WITH 183 | 2.3.19 184 | -------------------------------------------------------------------------------- /lib/callstacking/rails/instrument.rb: -------------------------------------------------------------------------------- 1 | require 'rails' 2 | 3 | # https://stackoverflow.com/q/52932516 4 | module Callstacking 5 | module Rails 6 | class Instrument 7 | attr_accessor :spans 8 | attr_reader :settings, :span_modules 9 | 10 | def initialize 11 | @spans = {} 12 | @span_modules = Set.new 13 | @settings = Callstacking::Rails::Settings.new 14 | end 15 | 16 | def instrument_method(klass, method_name, application_level: true) 17 | method_path = (klass.instance_method(method_name).source_location.first rescue nil) || 18 | (klass.method(method_name).source_location.first rescue nil) 19 | 20 | # method was not defined in Ruby (i.e. native) 21 | return if method_path.nil? 22 | 23 | # Application level method definitions 24 | return if application_level && !(method_path =~ /#{::Rails.root.to_s}/) 25 | 26 | return if method_path =~ /initializer/i 27 | 28 | tmp_module = find_or_initialize_module(klass) 29 | 30 | return if tmp_module.nil? || 31 | tmp_module.instance_methods.include?(method_name) || 32 | tmp_module.singleton_methods.include?(method_name) 33 | 34 | new_method = nil 35 | if RUBY_VERSION < "2.7.8" 36 | new_method = tmp_module.define_method(method_name) do |*args, &block| 37 | settings = tmp_module.instance_variable_get(:@settings) 38 | return super(*args, &block) if settings.disabled? 39 | 40 | method_name = __method__ 41 | 42 | path = method(__method__).super_method.source_location&.first || '' 43 | line_no = method(__method__).super_method.source_location&.last || '' 44 | 45 | method_source = '' 46 | method_source = method(__method__).super_method.source if settings.analyze_source? 47 | 48 | p, l = caller.find { |c| c.to_s =~ /#{::Rails.root.to_s}/}&.split(':') 49 | 50 | spans = tmp_module.instance_variable_get(:@spans) 51 | span = spans[Thread.current.object_id] 52 | klass = tmp_module.instance_variable_get(:@klass) 53 | 54 | arguments = Callstacking::Rails::Instrument.arguments_for(method(__method__).super_method, args) 55 | 56 | span.call_entry(klass, method_name, arguments, p || path, l || line_no, method_source) 57 | return_val = super(*args, &block) 58 | span.call_return(klass, method_name, p || path, l || line_no, return_val, method_source) 59 | 60 | return_val 61 | end 62 | new_method.ruby2_keywords if new_method.respond_to?(:ruby2_keywords) 63 | else 64 | new_method = tmp_module.define_method(method_name) do |*args, **kwargs, &block| 65 | settings = tmp_module.instance_variable_get(:@settings) 66 | return super(*args, **kwargs, &block) if settings.disabled? 67 | 68 | method_name = __method__ 69 | 70 | path = method(__method__).super_method.source_location&.first || '' 71 | line_no = method(__method__).super_method.source_location&.last || '' 72 | 73 | method_source = '' 74 | method_source = method(__method__).super_method.source if settings.analyze_source? 75 | 76 | p, l = caller.find { |c| c.to_s =~ /#{::Rails.root.to_s}/}&.split(':') 77 | 78 | spans = tmp_module.instance_variable_get(:@spans) 79 | span = spans[Thread.current.object_id] 80 | klass = tmp_module.instance_variable_get(:@klass) 81 | 82 | arguments = Callstacking::Rails::Instrument.arguments_for(method(__method__).super_method, args) 83 | 84 | span.call_entry(klass, method_name, arguments, p || path, l || line_no, method_source) 85 | return_val = super(*args, **kwargs, &block) 86 | span.call_return(klass, method_name, p || path, l || line_no, return_val, method_source) 87 | 88 | return_val 89 | end 90 | 91 | end 92 | 93 | new_method 94 | end 95 | 96 | def enable!(klasses) 97 | Array.wrap(klasses).each do |klass| 98 | instrument_klass(klass, application_level: true) 99 | end 100 | end 101 | 102 | def disable!(modules = span_modules) 103 | modules.each do |mod| 104 | mod.instance_methods.each do |method_name| 105 | mod.remove_method(method_name) 106 | end 107 | end 108 | 109 | reset! 110 | end 111 | 112 | def instrumentation_required? 113 | span_modules.empty? 114 | end 115 | 116 | def reset! 117 | span_modules.clear 118 | end 119 | 120 | def instrument_klass(klass, application_level: true) 121 | relevant_methods = all_methods(klass) - filtered 122 | relevant_methods.each { |method| instrument_method(klass, method, application_level: application_level) } 123 | end 124 | 125 | def self.arguments_for(m, args) 126 | param_names = m.parameters&.map(&:last) 127 | return {} if param_names.nil? 128 | 129 | h = param_names.map.with_index do |param, index| 130 | next if [:&, :*, :**].include?(param) 131 | [param, args[index].inspect] 132 | end.compact.to_h 133 | 134 | filter = ::Rails.application.config.filter_parameters 135 | f = ActiveSupport::ParameterFilter.new filter 136 | f.filter h 137 | end 138 | 139 | def add_span(span) 140 | spans[Thread.current.object_id] ||= span 141 | end 142 | 143 | private 144 | def find_or_initialize_module(klass) 145 | name = klass&.name rescue nil 146 | return if name.nil? 147 | 148 | module_name = "#{klass.name.gsub('::', '')}Span" 149 | module_index = klass.ancestors.map(&:to_s).index(module_name) 150 | 151 | unless module_index 152 | # Development class reload - 153 | # ancestors are reset but module definition remains 154 | new_module = Object.const_get(module_name) rescue nil 155 | new_module||=Object.const_set(module_name, Module.new) 156 | span_modules << new_module 157 | 158 | new_module.instance_variable_set("@klass", klass) 159 | new_module.instance_variable_set("@spans", spans) 160 | new_module.instance_variable_set("@settings", settings) 161 | 162 | klass.prepend new_module 163 | klass.singleton_class.prepend new_module if klass.class == Module 164 | 165 | return find_or_initialize_module(klass) 166 | end 167 | 168 | span_modules << klass.ancestors[module_index] 169 | klass.ancestors[module_index] 170 | end 171 | 172 | def all_methods(klass) 173 | (klass.instance_methods + 174 | klass.private_instance_methods(false) + 175 | klass.protected_instance_methods(false) + 176 | klass.methods + 177 | klass.singleton_methods).uniq 178 | end 179 | 180 | def filtered 181 | @filtered ||= (Object.instance_methods + Object.private_instance_methods + 182 | Object.protected_instance_methods + Object.methods(false)).uniq 183 | end 184 | end 185 | end 186 | end 187 | -------------------------------------------------------------------------------- /lib/callstacking/rails/trace.rb: -------------------------------------------------------------------------------- 1 | require "rails" 2 | require 'active_support/core_ext/object/deep_dup' 3 | require "callstacking/rails/helpers/heads_up_display_helper" 4 | 5 | module Callstacking 6 | module Rails 7 | class Trace 8 | TRACE_CALL_ENTRY = 'TraceCallEntry' 9 | TRACE_CALL_RETURN = 'TraceCallReturn' 10 | TRACE_MESSAGE = 'TraceMessage' 11 | TRACE_EXCEPTION = 'TraceException' 12 | 13 | include Callstacking::Rails::Helpers::HeadsUpDisplayHelper 14 | 15 | attr_accessor :spans, :client, :lock, :traces 16 | attr_reader :settings 17 | cattr_accessor :current_trace_id 18 | cattr_accessor :current_tuid 19 | cattr_accessor :trace_log 20 | 21 | ICON = '💥' 22 | MAX_TRACE_ENTRIES = 3000 23 | 24 | @@trace_log||={} 25 | 26 | def initialize(spans) 27 | 28 | @traces = [] 29 | @spans = spans 30 | @settings = Callstacking::Rails::Settings.new 31 | 32 | @lock = Mutex.new 33 | @client = Callstacking::Rails::Client::Trace.new(settings.url, settings.auth_token) 34 | 35 | init_uuids(nil, nil) 36 | init_callbacks(nil) 37 | end 38 | 39 | def begin_trace(controller) 40 | @trace_id, @tuid = init_uuids(controller.request&.request_id || SecureRandom.uuid, TimeBasedUUID.generate) 41 | trace_log[@trace_id] = controller.request&.original_url 42 | 43 | init_callbacks(@tuid) 44 | 45 | start_request(@trace_id, @tuid, 46 | controller.action_name, controller.controller_name, 47 | controller.action_name, controller.request.format, ::Rails.root.to_s, 48 | controller.request&.original_url, 49 | controller.request.headers, controller.request.params, @traces) 50 | end 51 | 52 | def end_trace(controller, exception) 53 | return if @trace_id.nil? || @tuid.nil? 54 | 55 | complete_request(@trace_id, @tuid, exception, 56 | controller.action_name, controller.controller_name, 57 | controller.action_name, controller.request.format, 58 | controller.request&.original_url, 59 | @traces, MAX_TRACE_ENTRIES) 60 | 61 | inject_hud(@settings, controller.request, controller.response) if ::Rails.env.development? || ::Rails.env.test? 62 | end 63 | 64 | def self.trace_log_clear 65 | trace_log.clear 66 | end 67 | 68 | private 69 | 70 | def init_callbacks(tuid) 71 | @spans.on_call_entry do |nesting_level, order_num, klass, method_name, arguments, path, line_no, method_source| 72 | create_call_entry(tuid, nesting_level, order_num, klass, method_name, arguments, path, line_no, @traces, method_source) 73 | end 74 | 75 | @spans.on_call_return do |coupled_callee, nesting_level, order_num, klass, method_name, path, line_no, return_val, method_source| 76 | create_call_return(tuid, coupled_callee, nesting_level, order_num, klass, method_name, path, line_no, return_val, @traces, method_source) 77 | end 78 | end 79 | 80 | def init_uuids(trace_id, tuid) 81 | Callstacking::Rails::Trace.current_trace_id = trace_id 82 | Callstacking::Rails::Trace.current_tuid = tuid 83 | 84 | return trace_id, tuid 85 | end 86 | 87 | def completed_request_message(method, controller, action, format) 88 | "Completed request: #{method} #{controller}##{action} as #{format}" 89 | end 90 | 91 | def start_request_message(method, controller, action, format) 92 | "Started request: #{method} #{controller}##{action} as #{format}" 93 | end 94 | 95 | def exception_message(exception, method, controller, action, format) 96 | "#{exception.class} #{exception.message}

" \ 97 | "#{exception.backtrace[0]}".html_safe 98 | end 99 | 100 | def create_call_return(tuid, coupled_callee, nesting_level, order_num, klass, method_name, path, line_no, return_val, traces, method_source) 101 | lock.synchronize do 102 | traces << { tuid: tuid, 103 | type: TRACE_CALL_RETURN, 104 | order_num: order_num, 105 | nesting_level: nesting_level, 106 | local_variables: {}, 107 | args: {}, 108 | klass: klass_name(klass), 109 | line_number: line_no, 110 | path: path, 111 | method_name: method_name, 112 | return_value: return_value(return_val), 113 | coupled_callee: coupled_callee, 114 | message: nil, 115 | method_source: method_source, 116 | } 117 | end 118 | end 119 | 120 | def create_call_entry(tuid, nesting_level, order_num, klass, method_name, arguments, path, line_no, traces, method_source) 121 | lock.synchronize do 122 | traces << { tuid: tuid, 123 | type: TRACE_CALL_ENTRY, 124 | order_num: order_num, 125 | nesting_level: nesting_level, 126 | args: arguments, 127 | klass: klass_name(klass), 128 | line_number: line_no, 129 | path: path, 130 | method_name: method_name, 131 | return_value: nil, 132 | coupled_callee: nil, 133 | local_variables: {}, 134 | message: nil, 135 | method_source: method_source, 136 | } 137 | end 138 | end 139 | 140 | def create_message(tuid, message, order_num, traces, type = TRACE_MESSAGE) 141 | lock.synchronize do 142 | traces << { tuid: tuid, 143 | type: type, 144 | order_num: order_num, 145 | nesting_level: 0, 146 | message: message, 147 | args: {}, 148 | klass: nil, 149 | line_number: nil, 150 | path: nil, 151 | method_name: nil, 152 | return_value: nil, 153 | coupled_callee: false, 154 | local_variables: {}, 155 | method_source: '', 156 | } 157 | end 158 | end 159 | 160 | def send_traces!(trace_id, traces) 161 | lock.synchronize do 162 | return if traces.empty? 163 | 164 | client.upsert(trace_id, 165 | { trace_entries: traces.deep_dup }) 166 | traces.clear 167 | end 168 | end 169 | def start_request(trace_id, tuid, method, controller, action, format, path, original_url, headers, params, traces) 170 | lock.synchronize do 171 | traces.clear 172 | end 173 | 174 | return if do_not_track_request?(original_url, format) 175 | 176 | client.create(trace_id, tuid, 177 | method, controller, 178 | action, format, 179 | path, original_url, 180 | headers, params) 181 | 182 | print_trace_url(trace_id) 183 | 184 | create_message(tuid, start_request_message(method, controller, action, format), 185 | spans.increment_order_num, @traces) 186 | end 187 | 188 | def print_trace_url(trace_id) 189 | url = "#{settings.url}/traces/#{trace_id}" 190 | 191 | puts "*" * url.size 192 | puts url 193 | puts "*" * url.size 194 | 195 | url 196 | end 197 | 198 | def complete_request(trace_id, tuid, exception, method, controller, 199 | action, format, original_url, traces, max_trace_entries) 200 | return if do_not_track_request?(original_url, format) 201 | 202 | if exception.present? 203 | create_message(tuid, exception_message(exception, method, controller, action, format), 204 | spans.increment_order_num, traces, TRACE_EXCEPTION) 205 | else 206 | create_message(tuid, completed_request_message(method, controller, action, format), 207 | spans.increment_order_num, traces) 208 | end 209 | 210 | send_traces!(trace_id, traces[0..max_trace_entries]) 211 | end 212 | 213 | def track_request?(url) 214 | !(track_request?(url)) 215 | end 216 | 217 | def do_not_track_request?(url, _format) 218 | return true if %w[/health /health_check].any? {|u| url =~ /#{u}/i} 219 | 220 | (url =~ /(\/assets\/|\/stylesheets\/|\/javascripts\/|\/css\/|\/js\/|\.js|\.css)/i).present? 221 | end 222 | 223 | def return_value(return_val) 224 | return_val.inspect 225 | rescue ThreadError 226 | # deadlock; recursive locking (ThreadError) 227 | # Discourse overrides ActiveSupport::Inflector methods via lib/freedom_patches/inflector_backport.rb 228 | # when return_value.inspect is called, it triggers a subsequent call 229 | # to the instrumented inflector method, causing another call to mutex#synchronize 230 | # from the block of the first synchronize call 231 | 232 | return_val.to_s rescue "****" # Can't evaluate 233 | end 234 | 235 | def klass_name(klass) 236 | (klass.is_a?(Class) ? klass.name : klass.class.name) 237 | end 238 | end 239 | end 240 | end 241 | -------------------------------------------------------------------------------- /COMMERCIAL-LICENSE: -------------------------------------------------------------------------------- 1 | END USER LICENSE AND CONSULTING AGREEMENT 2 | 3 | This Commercial Library License Agreement (the "Agreement") is entered into as of the Effective Date between Blue Sage Data Systems, Inc., a corporation incorporated under the laws of Nebraska, with a principal place of business at 578 Blue Sage Blvd, Lincoln, NE ("Licensor"), and ("Licensee"). 4 | 5 | WHEREAS, Licensor is the owner of the proprietary software library known as "Call Stacking" (the "Licensed Software"), which provides a set of tools and functionalities for efficient management of telecommunication calls and associated data; 6 | 7 | WHEREAS, Licensee desires to obtain a license to use the Licensed Software in connection with the development, distribution, and sale of its products and services; 8 | 9 | NOW, THEREFORE, in consideration of the mutual covenants and promises contained herein, and other good and valuable consideration, the receipt and sufficiency of which are hereby acknowledged, the parties hereto agree as follows: 10 | 11 | DEFINITIONS 12 | 1.1. "Documentation" means any user manuals, training materials, specifications, or other materials provided by Licensor to Licensee in connection with the Licensed Software. 13 | 14 | 1.2. "Effective Date" means the date upon which the last party signs this Agreement. 15 | 16 | 1.3. "Intellectual Property Rights" means all intellectual property rights, including without limitation, copyrights, trademarks, trade secrets, patents, and any other proprietary rights, whether registered or unregistered, and all applications for such rights. 17 | 18 | 1.4. "Products" means Licensee's products and services that incorporate or make use of the Licensed Software. 19 | 20 | LICENSE GRANT 21 | 2.1. Subject to the terms and conditions of this Agreement, Licensor hereby grants to Licensee a non-exclusive, non-transferable, worldwide, royalty-bearing license to: 22 | 23 | (a) use the Licensed Software solely for the purpose of developing and distributing Products; 24 | 25 | (b) use and reproduce the Documentation in connection with the use and distribution of the Licensed Software. 26 | 27 | 2.2. Licensee shall not sublicense, rent, lease, or otherwise transfer any of its rights in the Licensed Software or Documentation to any third party without the prior written consent of Licensor. 28 | 29 | RESTRICTED USES 30 | 3.1 Licensee shall not use the software as part of a product or service that provides similar functionality to the software itself. 31 | 32 | 3.2 You must not (nor allow others to): (a) reverse engineer the Software (except when legally allowed); (b) distribute, sell, sublicense, rent, lease, or use the Software for unauthorized purposes; (c) redistribute the Software or Modifications without incorporating them into a substantially different product; (d) redistribute as part of a product, appliance, or virtual server; (e) redistribute on uncontrolled servers; (f) remove proprietary notices from the Software; (g) modify the Software, create derivative works (except as permitted in Section 4), or incorporate without written authorization; (h) publicly disclose performance information or analysis; (i) circumvent or remove copy protection; (j) develop competitive products; or (k) use or distribute unauthorized Source URLs or keycodes. Blue Sage may terminate access without notice if your unique Source library URL is published. 33 | 34 | INTELLECTUAL PROPERTY RIGHTS AND OWNERSHIP 35 | 4.1. Licensor retains all right, title, and interest in and to the Licensed Software, Documentation, and all Intellectual Property Rights therein. Nothing in this Agreement shall be construed to transfer any ownership rights in the Licensed Software, Documentation, or any related Intellectual Property Rights from Licensor to Licensee. 36 | 37 | 4.2. Licensee agrees to promptly notify Licensor in writing of any actual or suspected infringement of the Licensed Software, Documentation, or any related Intellectual Property Rights, and to reasonably cooperate with Licensor in the enforcement of such rights at Licensor's expense. 38 | 39 | CONFIDENTIALITY 40 | 5.1. Each party acknowledges that, in the course of performing its obligations under this Agreement, it may obtain information relating to the other party that is of a confidential and proprietary nature ("Confidential Information"). Confidential Information shall include, without limitation, the Licensed Software, Documentation, and any non-public technical, financial, or business information of either party. 41 | 42 | 5.2. Each party agrees to maintain the confidentiality of the other party's Confidential Information, using the same degree of care that it uses to protect its own confidential information of a similar nature, but in no event less than a reasonable degree of care, and not to use such Confidential Information for any purpose other than as necessary to fulfill its obligations under this Agreement. 43 | 44 | WARRANTY AND DISCLAIMER 45 | 6.1. Licensor warrants that it has the necessary rights and authority to enter into this Agreement and grant the license rights set forth herein. 46 | 47 | 6.2. EXCEPT AS EXPRESSLY SET FORTH IN THIS AGREEMENT, LICENSOR DISCLAIMS ALL WARRANTIES, EXPRESS, IMPLIED, STATUTORY, OR OTHERWISE, WITH RESPECT TO THE LICENSED SOFTWARE AND DOCUMENTATION, INCLUDING, WITHOUT LIMITATION, ANY IMPLIED WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, TITLE, AND NONINFRINGEMENT. LICENSOR DOES NOT WARRANT THAT THE LICENSED SOFTWARE WILL BE ERROR-FREE OR THAT ITS USE WILL BE UNINTERRUPTED. 48 | 49 | LIMITATION OF LIABILITY 50 | 7.1. IN NO EVENT SHALL EITHER PARTY BE LIABLE TO THE OTHER FOR ANY INDIRECT, INCIDENTAL, CONSEQUENTIAL, SPECIAL, EXEMPLARY, OR PUNITIVE DAMAGES, INCLUDING BUT NOT LIMITED TO LOSS OF PROFITS, LOSS OF DATA, OR BUSINESS INTERRUPTION, ARISING OUT OF OR IN CONNECTION WITH THIS AGREEMENT, THE USE OR INABILITY TO USE THE LICENSED SOFTWARE OR DOCUMENTATION, OR THE PERFORMANCE OR NON-PERFORMANCE OF ANY PRODUCTS, WHETHER BASED ON CONTRACT, TORT, OR ANY OTHER LEGAL THEORY, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. 51 | 52 | 7.2. EACH PARTY'S TOTAL LIABILITY TO THE OTHER UNDER THIS AGREEMENT SHALL NOT EXCEED THE AMOUNTS PAID OR PAYABLE BY LICENSEE TO LICENSOR FOR THE LICENSED SOFTWARE DURING THE ONE (1) MONTH IMMEDIATELY PRECEDING THE EVENT GIVING RISE TO SUCH LIABILITY. 53 | 54 | TERM AND TERMINATION 55 | 8.1. This Agreement shall commence on the Effective Date and continue for an initial term of [Number] years, unless earlier terminated in accordance with the provisions of this Section 8. Thereafter, this Agreement shall automatically renew for successive [Number]-year terms unless either party provides written notice of its intent not to renew at least [Number] days prior to the expiration of the then-current term. 56 | 57 | 8.2. Either party may terminate this Agreement upon written notice to the other party in the event that the other party materially breaches any term or condition of this Agreement and fails to cure such breach within [Number] days after receipt of written notice thereof. 58 | 59 | 8.3. Upon termination or expiration of this Agreement, Licensee shall immediately cease all use of the Licensed Software and Documentation and shall within [Number] days return or destroy all copies thereof in its possession or control. 60 | 61 | MISCELLANEOUS 62 | 9.1. This Agreement constitutes the entire agreement between the parties with respect to the subject matter hereof and supersedes all prior or contemporaneous agreements, understandings, and communications, whether oral or written. 63 | 64 | 9.2. This Agreement may be amended only by a written document signed by both parties. 65 | 66 | 9.3. This Agreement shall be governed by and construed in accordance with the laws of the [State], without regard to its conflicts of laws principles. 67 | 68 | 9.4. Any notices required or permitted under this Agreement shall be in writing and shall be deemed given when delivered personally, or three (3) days after being sent by certified or registered mail, postage prepaid, return receipt requested, to the addresses set forth above. 69 | 70 | 9.5. Neither party may assign or transfer its rights or obligations under this Agreement without the prior written consent of the other party, except that either party may assign this Agreement in connection with a merger, reorganization, or sale of all or substantially all of its assets, provided that the assignee assumes all of the assigning party's obligations hereunder. 71 | 72 | WHITE GLOVE CONSULTING SERVICES 73 | 10.1. Licensor may, upon request by Licensee and at Licensor's discretion, provide additional consulting services related to the Licensed Software, including but not limited to, customization, integration, training, and support (the "Consulting Services"). The Consulting Services shall be considered as "White Glove" services, meaning that Licensor will provide a high level of personalized attention and expertise to Licensee. 74 | 75 | 10.2. The Consulting Services shall be provided at a rate of $1,000 per hour, billed in increments of no less than one (1) hour, unless otherwise agreed upon by the parties in writing. Licensee shall pay all invoices for Consulting Services within 15 days of receipt. 76 | 77 | 10.3. Licensor's liability for the Consulting Services performed under this Agreement shall be limited as follows: 78 | 79 | (a) Licensor shall exercise commercially reasonable efforts to perform the Consulting Services in a professional and workmanlike manner, consistent with industry standards. 80 | 81 | (b) In the event of any errors, omissions, or other defects in the Consulting Services, Licensor's sole liability and Licensee's exclusive remedy shall be, at Licensor's option, either: (i) re-performance of the affected Consulting Services, or (ii) refund of the fees paid by Licensee for the affected Consulting Services. 82 | 83 | (c) IN NO EVENT SHALL LICENSOR BE LIABLE TO LICENSEE FOR ANY INDIRECT, INCIDENTAL, CONSEQUENTIAL, SPECIAL, EXEMPLARY, OR PUNITIVE DAMAGES ARISING OUT OF OR IN CONNECTION WITH THE CONSULTING SERVICES, INCLUDING BUT NOT LIMITED TO LOSS OF PROFITS, LOSS OF DATA, OR BUSINESS INTERRUPTION, WHETHER BASED ON CONTRACT, TORT, OR ANY OTHER LEGAL THEORY, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. 84 | 85 | (d) LICENSOR'S TOTAL LIABILITY TO LICENSEE FOR THE CONSULTING SERVICES PERFORMED UNDER THIS AGREEMENT SHALL NOT EXCEED THE AMOUNTS PAID OR PAYABLE BY LICENSEE TO LICENSOR FOR THE CONSULTING SERVICES DURING THE ONE (1) MONTH IMMEDIATELY PRECEDING THE EVENT GIVING RISE TO SUCH LIABILITY. 86 | 87 | 10.4. Licensee acknowledges and agrees that any advice, recommendations, or information provided by Licensor as part of the Consulting Services are intended solely for Licensee's internal use and shall not be construed as a guarantee of any specific outcome or result. Licensee remains solely responsible for making its own decisions and for taking any actions based on such advice, recommendations, or information. --------------------------------------------------------------------------------