Skip to content

Commit

Permalink
Merge pull request #1402 from newrelic/instrumentation-generator
Browse files Browse the repository at this point in the history
Create instrumentation generator
  • Loading branch information
kaylareopelle authored Sep 9, 2022
2 parents ae00b33 + f633c2b commit bd33f07
Show file tree
Hide file tree
Showing 17 changed files with 360 additions and 4 deletions.
6 changes: 6 additions & 0 deletions Thorfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# encoding: utf-8
# This file is distributed under New Relic's license terms.
# See https://github.com/newrelic/newrelic-ruby-agent/blob/main/LICENSE for complete details.
# frozen_string_literal: true

Dir["./lib/tasks/**/*.thor"].sort.each { |f| load f }
5 changes: 5 additions & 0 deletions lib/new_relic/language_support.rb
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,11 @@ def constantize(const_name)
end
end

def camelize(string)
camelized = string.downcase
camelized.split(/\-|\_/).map(&:capitalize).join
end

def bundled_gem?(gem_name)
defined?(Bundler) && Bundler.rubygems.all_specs.map(&:name).include?(gem_name)
rescue => e
Expand Down
63 changes: 63 additions & 0 deletions lib/tasks/instrumentation_generator/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# Instrumentation Generator

## Usage

`thor list` can be passed to get a list of all available thor commands

### instrumentation:scaffold

This task requires one parameter by default: the name of the library or class you are instrumenting. This task generates the basic file structure needed to add new instrumentation to the Ruby agent.

Run it using:

`thor instrumentation:scaffold gem_name`

It accepts two optional parameters:

- `--method`: the name of a method to instrument. Only one method can be accepted at this time. Defaults to `method_to_instrument`
- `--args`: the arguments for the instrumented method. A comma-separated list can be given if there are multiple arguments. Defaults to `*args`.

With the optional parameters, the task can be run like so:

`thor instrumentation:scaffold gem_name --method=method_to_instrument --args=arg1,arg2,arg3`

### WIP: instrumentation:add_new_method

Are you trying add instrumentation for a method within library we already instrument? This task is for you! Instead of building the entire scaffold, this task inserts only the changes needed within the `instrumentation.rb` and test files to instrument the new method. It requires one argument, the name of the existing instrumentation to add the method to. If the instrumented library does not already exist, it will create a new scaffold for that library.

Run it using:

`thor instrumentation:scaffold gem_name`

It accepts two optional paramters:

- `--method`: the name of a method to instrument. Only one method can be accepted at this time. Defaults to `method_to_instrument`
- `--args`: the arguments for the instrumented method. A comma-separated list can be given if there are multiple arguments. Defaults to `*args`.

With the optional parameters, the task can be run like so:

`thor instrumentation:add_new_method gem_name --method=method_to_instrument --args=arg1,arg2,arg3`

## Idea

Create a CLI, similar to Rails’ generators and scaffold scripts, to create the required files and basic classes for instrumenting new libraries. The hope is that the parts of adding new instrumentation that is repetitive can be eliminated and/or reduced so that it takes less time to add new instrumentation and we can spend development time considering the best attributes to collect and interactions to measure. I also hope this project will reduce the overall toil related to adding new instrumentation.

The files we create with little variation besides library and method names are:

- Lib
- Dependency detection
- Chain
- Prepend
- Instrumentation
- Tests
- configuration/newrelic.yml
- Envfile
- Test file

Furthermore, we also create very similar snippets inside the default source configuration file that could be generated by this project as well.

## Outcome

A prototype outside the agent has been created that generates the required files to create new instrumentation. This prototype accepts three arguments: name (name of the library), method (method to instrument), args (arguments for the method).

The Ruby gem Thor, a toolkit for building powerful command-line interfaces used in Bundler, Vagrant, Rails and others powers this CLI.
33 changes: 33 additions & 0 deletions lib/tasks/instrumentation_generator/TODO.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# TODO

# - [X] Create instrumentation file

# - [X] Create chain file

# - [X] Create prepend file

# - [X] Create dependency detection file

# - [X] Add config to default source

# - [X] Create multiverse suite

# - [X] Create Envfile

# - [X] Create test file with examples

# - [X] Create option for method names to instrument

# - [X] Add entry to newrelic.yml

# - [ ] Append a new method to instrument to an existing instrumentation class (with tests?)

# - [ ] Documentation: examples of what to add in each gap

# - [ ] Good examples of tests, instrumentation, etc. as comments

# - [ ] Handle multi-word gem names (camel case for classes, handle hyphens, concurrent-ruby as example)

# - [ ] Make sure multiple arguments can be passed to the command line

# - [ ] Add tests
96 changes: 96 additions & 0 deletions lib/tasks/instrumentation_generator/instrumentation.thor
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
# encoding: utf-8
# This file is distributed under New Relic's license terms.
# See https://github.com/newrelic/newrelic-ruby-agent/blob/main/LICENSE for complete details.
# frozen_string_literal: true

require_relative '../../new_relic/language_support'
require 'thor'

class Instrumentation < Thor
include Thor::Actions

INSTRUMENTATION_ROOT = 'lib/new_relic/agent/instrumentation/'
MULTIVERSE_SUITE_ROOT = 'test/multiverse/suites/'
DEFAULT_SOURCE_LOCATION = 'lib/new_relic/agent/configuration/default_source.rb'

desc('scaffold NAME', 'Scaffold the required files for adding new instrumentation')
long_desc <<-LONGDESC
`instrumentation scaffold` requires one parameter by default: the name of the
library or class you are instrumenting. This task generates the basic
file structure needed to add new instrumentation to the Ruby agent.
LONGDESC

source_root(File.dirname(__FILE__))

option :method,
default: 'method_to_instrument',
desc: 'The method you would like to prepend or chain instrumentation onto'
option :args,
default: '*args',
desc: 'The arguments associated with the original method'

def scaffold(name)
@name = name
@method = options[:method] if options[:method]
@args = options[:args] if options[:args]
@class_name = ::NewRelic::LanguageSupport.camelize(name)
base_path = "#{INSTRUMENTATION_ROOT}#{name.downcase}"
empty_directory(base_path)

['chain', 'instrumentation', 'prepend'].each do |file|
template("templates/#{file}.tt", "#{base_path}/#{file}.rb")
end

template('templates/dependency_detection.tt', "#{base_path}.rb")
create_configuration(name)
create_tests(name)
end

desc 'add_new_method NAME', 'Inserts a new method into an existing piece of instrumentation'

option :method, required: true, desc: 'The name of the method to instrument'
option :args, default: '*args', desc: 'The arguments associated with the instrumented method'

def add_new_method(name, method_name)
# Verify that existing instrumentation exists
# if it doesn't, should we just call the #scaffold method instead since we have all the stuff
# otherwise, inject the new method into the instrumentation matching the first arg
# add to only chain, instrumentation, prepend
# move the method content to a partial
end

private

def create_tests(name)
@name = name
@instrumentation_method_global_erb_snippet = '<%= $instrumentation_method %>'
base_path = "#{MULTIVERSE_SUITE_ROOT}#{@name.downcase}"
empty_directory(base_path)
template('templates/Envfile.tt', "#{base_path}/Envfile")
template('templates/test.tt', "#{base_path}/#{@name.downcase}_instrumentation_test.rb")

empty_directory("#{base_path}/config")
template('templates/newrelic.yml.tt', "#{base_path}/config/newrelic.yml")
end

def create_configuration(name)
config = <<-CONFIG
:'instrumentation.#{name.downcase}' => {
:default => 'auto',
:public => true,
:type => String,
:dynamic_name => true,
:allowed_from_server => false,
:description => 'Controls auto-instrumentation of the #{name.downcase} library at start up. May be one of [auto|prepend|chain|disabled].'
},
CONFIG
insert_into_file(
DEFAULT_SOURCE_LOCATION,
config,
after: ":description => 'Controls auto-instrumentation of bunny at start up. May be one of [auto|prepend|chain|disabled].'
},\n"
)
end
end

Instrumentation.start(ARGV)
10 changes: 10 additions & 0 deletions lib/tasks/instrumentation_generator/templates/Envfile.tt
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# encoding: utf-8
# This file is distributed under New Relic's license terms.
# See https://github.com/newrelic/newrelic-ruby-agent/blob/main/LICENSE for complete details.
# frozen_string_literal: true

instrumentation_methods :chain, :prepend

gemfile <<-RB
gem '<%= @name.downcase %>'
RB
22 changes: 22 additions & 0 deletions lib/tasks/instrumentation_generator/templates/chain.tt
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# encoding: utf-8
# This file is distributed under New Relic's license terms.
# See https://github.com/newrelic/newrelic-ruby-agent/blob/main/LICENSE for complete details.
# frozen_string_literal: true

module NewRelic::Agent::Instrumentation
module <%= @class_name %>
def self.instrument!
::<%= @class_name %>.class_eval do
include NewRelic::Agent::Instrumentation::<%= @class_name %>

alias_method(:<%= @method.downcase %>_without_new_relic, :<%= @method.downcase %>)

def <%= @method.downcase %><%= "(#{@args})" unless @args.empty? %>
<%= @method.downcase %>_with_tracing<%= "(#{@args})" unless @args.empty? %> do
<%= @method.downcase %>_without_new_relic<%= "(#{@args})" unless @args.empty? %>
end
end
end
end
end
end
7 changes: 7 additions & 0 deletions lib/tasks/instrumentation_generator/templates/chain_method.tt
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
alias_method :<%= @method.downcase %>_without_new_relic, :<%= @method.downcase %>

def <%= @method.downcase %><%= "(#{@args})" unless @args.empty? %>
<%= @method.downcase %>_with_tracing<%= "(#{@args})" unless @args.empty? %> do
<%= @method.downcase %>_without_new_relic<%= "(#{@args})" unless @args.empty? %>
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# encoding: utf-8
# This file is distributed under New Relic's license terms.
# See https://github.com/newrelic/newrelic-ruby-agent/blob/main/LICENSE for complete details.
# frozen_string_literal: true

require_relative '<%= @name.downcase %>/instrumentation'
require_relative '<%= @name.downcase %>/chain'
require_relative '<%= @name.downcase %>/prepend'

DependencyDetection.defer do
named :<%= @name.match?(/\-|\_/) ? "'#{@name.downcase}'" : @name.downcase %>

depends_on do
# The class that needs to be defined to prepend/chain onto. This can be used
# to determine whether the library is installed.
defined?(::<%= @class_name %>)
# Add any additional requirements to verify whether this instrumentation
# should be installed
end

executes do
::NewRelic::Agent.logger.info('Installing <%= @name.downcase %> instrumentation')

if use_prepend?
prepend_instrument ::<%= @class_name %>, NewRelic::Agent::Instrumentation::<%= @class_name %>::Prepend
else
chain_instrument NewRelic::Agent::Instrumentation::<%= @class_name %>
end
end
end
13 changes: 13 additions & 0 deletions lib/tasks/instrumentation_generator/templates/instrumentation.tt
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# encoding: utf-8
# This file is distributed under New Relic's license terms.
# See https://github.com/newrelic/newrelic-ruby-agent/blob/main/LICENSE for complete details.
# frozen_string_literal: true

module NewRelic::Agent::Instrumentation
module <%= @class_name %>

def <%= @method.downcase %>_with_tracing<%= "(#{@args})" unless @args.empty? %>
# add instrumentation content here
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
def <%= @method.downcase %>_with_tracing<%= "(#{@args})" unless @args.empty? %>
# add instrumentation content here
end
19 changes: 19 additions & 0 deletions lib/tasks/instrumentation_generator/templates/newrelic.yml.tt
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
development:
error_collector:
enabled: true
apdex_t: 0.5
monitor_mode: true
license_key: bootstrap_newrelic_admin_license_key_000
instrumentation:
<%= @name.downcase %>: <%= @instrumentation_method_global_erb_snippet %>
app_name: test
log_level: debug
host: 127.0.0.1
api_host: 127.0.0.1
transaction_trace:
record_sql: obfuscated
enabled: true
stack_trace_threshold: 0.5
transaction_threshold: 1.0
capture_params: false
14 changes: 14 additions & 0 deletions lib/tasks/instrumentation_generator/templates/prepend.tt
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# encoding: utf-8
# This file is distributed under New Relic's license terms.
# See https://github.com/newrelic/newrelic-ruby-agent/blob/main/LICENSE for complete details.
# frozen_string_literal: true

module NewRelic::Agent::Instrumentation
module <%= @class_name %>::Prepend
include NewRelic::Agent::Instrumentation::<%= @class_name %>

def <%= @method.downcase %><%= "(#{@args})" unless @args.empty? %>
<%= @method.downcase %>_with_tracing<%= "(#{@args})" unless @args.empty? %> { super }
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
def <%= @method.downcase %><%= "(#{@args})" unless @args.empty? %>
<%= @method.downcase %>_with_tracing<%= "(#{@args})" unless @args.empty? %> { super }
end
16 changes: 16 additions & 0 deletions lib/tasks/instrumentation_generator/templates/test.tt
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# encoding: utf-8
# This file is distributed under New Relic's license terms.
# See https://github.com/newrelic/newrelic-ruby-agent/blob/main/LICENSE for complete details.
# frozen_string_literal: true

class <%= @class_name %>InstrumentationTest < Minitest::Test
def setup
@stats_engine = NewRelic::Agent.instance.stats_engine
end

def teardown
NewRelic::Agent.instance.stats_engine.clear_stats
end

# Add tests Here
end
9 changes: 5 additions & 4 deletions newrelic_rpm.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -47,18 +47,19 @@ https://github.com/newrelic/newrelic-ruby-agent/
s.homepage = "https://github.com/newrelic/rpm"
s.require_paths = ["lib"]
s.summary = "New Relic Ruby Agent"
s.add_development_dependency 'bundler'
s.add_development_dependency 'feedjira', '3.2.1' unless ENV['CI'] || RUBY_VERSION < '2.5' # for Gabby
s.add_development_dependency 'rake', '12.3.3'
s.add_development_dependency 'httparty' unless ENV['CI'] # for perf tests and Gabby
s.add_development_dependency 'minitest', '5.3.3'
s.add_development_dependency 'minitest-stub-const', '0.6'
s.add_development_dependency 'mocha', '~> 1.14.0'
s.add_development_dependency 'yard'
s.add_development_dependency 'pry' unless ENV['CI']
s.add_development_dependency 'bundler'
s.add_development_dependency 'rake', '12.3.3'
s.add_development_dependency 'rubocop'
s.add_development_dependency 'rubocop-minitest'
s.add_development_dependency 'rubocop-performance'
s.add_development_dependency 'rubocop-rake' if RUBY_VERSION >= '2.4.0'
s.add_development_dependency 'simplecov' if RUBY_VERSION >= '2.7.0'
s.add_development_dependency 'httparty' unless ENV['CI'] # for perf tests and Gabby
s.add_development_dependency 'thor'
s.add_development_dependency 'yard'
end
Loading

0 comments on commit bd33f07

Please sign in to comment.