Skip to content

Commit

Permalink
Merge pull request #2180 from newrelic/stripe_instrumentation
Browse files Browse the repository at this point in the history
Add Stripe instrumentation
  • Loading branch information
hannahramadan authored Sep 8, 2023
2 parents 8afbd92 + c817989 commit 942607b
Show file tree
Hide file tree
Showing 7 changed files with 372 additions and 1 deletion.
5 changes: 4 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@

## dev

Version <dev> allows the agent to record additional response information on a transaction when middleware instrumentation is disabled, introduces new `:'sidekiq.args.include'` and `:'sidekiq.args.exclude:` configuration options to permit capturing only certain Sidekiq job arguments, and fixes a bug in `NewRelic::Rack::AgentHooks.needed?`.
Version <dev> introduces Stripe instrumentation, allows the agent to record additional response information on a transaction when middleware instrumentation is disabled, introduces new `:'sidekiq.args.include'` and `:'sidekiq.args.exclude:` configuration options to permit capturing only certain Sidekiq job arguments, and fixes a bug in `NewRelic::Rack::AgentHooks.needed?`.

- **Feature: Add Stripe instrumentation**
[Stripe](https://stripe.com/) calls are now automatically instrumented. Additionally, new `:'stripe.user_data.include'` and `:'stripe.user_data.exclude'` configuration options permit capturing custom `user_data` key-value pairs that can be stored in [Stripe events](https://github.com/stripe/stripe-ruby#instrumentation). No `user_data` key-value pairs are captured by default. The agent currently supports Stripe versions 5.38.0+. [PR#2180](https://github.com/newrelic/newrelic-ruby-agent/pull/2180)

- **Feature: Report transaction HTTP status codes when middleware instrumentation is disabled**
Previously, when `disable_middleware_instrumentation` was set to `true`, the agent would not record the value of the response code or content type on the transaction. This was due to the possibility that a middleware could alter the response, which would not be captured by the agent when the middleware instrumentation was disabled. However, based on customer feedback, the agent will now report the HTTP status code and content type on a transaction when middleware instrumentation is disabled. [PR#2175](https://github.com/newrelic/newrelic-ruby-agent/pull/2175)
Expand Down
35 changes: 35 additions & 0 deletions lib/new_relic/agent/configuration/default_source.rb
Original file line number Diff line number Diff line change
Expand Up @@ -1602,6 +1602,41 @@ def self.enforce_fallback(allowed_values: nil, fallback: nil)
:allowed_from_server => false,
:description => 'Controls auto-instrumentation of Sinatra at start up. May be one of: `auto`, `prepend`, `chain`, `disabled`.'
},
:'instrumentation.stripe' => {
:default => 'enabled',
:public => true,
:type => String,
:allowed_from_server => false,
:description => 'Controls auto-instrumentation of Stripe at startup. May be one of: `enabled`, `disabled`.'
},
:'stripe.user_data.include' => {
default: NewRelic::EMPTY_ARRAY,
public: true,
type: Array,
dynamic_name: true,
allowed_from_server: false,
:transform => DefaultSource.method(:convert_to_list),
:description => <<~DESCRIPTION
An array of strings to specify which keys inside a Stripe event's `user_data` hash should be reported
to New Relic. Each string in this array will be turned into a regular expression via `Regexp.new` to
permit advanced matching. Setting the value to `["."]` will report all `user_data`.
DESCRIPTION
},
:'stripe.user_data.exclude' => {
default: NewRelic::EMPTY_ARRAY,
public: true,
type: Array,
dynamic_name: true,
allowed_from_server: false,
:transform => DefaultSource.method(:convert_to_list),
:description => <<~DESCRIPTION
An array of strings to specify which keys and/or values inside a Stripe event's `user_data` hash should
not be reported to New Relic. Each string in this array will be turned into a regular expression via
`Regexp.new` to permit advanced matching. For each hash pair, if either the key or value is matched the
pair will not be reported. By default, no `user_data` is reported, so this option should only be used if
the `stripe.user_data.include` option is being used.
DESCRIPTION
},
:'instrumentation.thread' => {
:default => 'auto',
:public => true,
Expand Down
28 changes: 28 additions & 0 deletions lib/new_relic/agent/instrumentation/stripe.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# 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 'new_relic/agent/instrumentation/stripe_subscriber'

DependencyDetection.defer do
named :stripe

depends_on do
NewRelic::Agent.config[:'instrumentation.stripe'] == 'enabled'
end

depends_on do
defined?(Stripe) &&
Gem::Version.new(Stripe::VERSION) >= Gem::Version.new('5.38.0')
end

executes do
NewRelic::Agent.logger.info('Installing Stripe instrumentation')
end

executes do
newrelic_subscriber = NewRelic::Agent::Instrumentation::StripeSubscriber.new
Stripe::Instrumentation.subscribe(:request_begin) { |event| newrelic_subscriber.start_segment(event) }
Stripe::Instrumentation.subscribe(:request_end) { |event| newrelic_subscriber.finish_segment(event) }
end
end
77 changes: 77 additions & 0 deletions lib/new_relic/agent/instrumentation/stripe_subscriber.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# 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
module Agent
module Instrumentation
class StripeSubscriber
DEFAULT_DESTINATIONS = AttributeFilter::DST_SPAN_EVENTS
EVENT_ATTRIBUTES = %i[http_status method num_retries path request_id].freeze
ATTRIBUTE_NAMESPACE = 'stripe.user_data'
ATTRIBUTE_FILTER_TYPES = %i[include exclude].freeze

def start_segment(event)
return unless is_execution_traced?

segment = NewRelic::Agent::Tracer.start_segment(name: metric_name(event))
event.user_data[:newrelic_segment] = segment
rescue => e
NewRelic::Agent.logger.error("Error starting New Relic Stripe segment: #{e}")
end

def finish_segment(event)
return unless is_execution_traced?

segment = remove_and_return_nr_segment(event)
add_stripe_attributes(segment, event)
add_custom_attributes(segment, event)
rescue => e
NewRelic::Agent.logger.error("Error finishing New Relic Stripe segment: #{e}")
ensure
segment&.finish
end

private

def is_execution_traced?
NewRelic::Agent::Tracer.state.is_execution_traced?
end

def metric_name(event)
"Stripe#{event.path}/#{event.method}"
end

def add_stripe_attributes(segment, event)
EVENT_ATTRIBUTES.each do |attribute|
segment.add_agent_attribute("stripe_#{attribute}", event.send(attribute), DEFAULT_DESTINATIONS)
end
end

def add_custom_attributes(segment, event)
return if NewRelic::Agent.config[:'stripe.user_data.include'].empty?

filtered_attributes = NewRelic::Agent::AttributePreFiltering.pre_filter_hash(event.user_data, nr_attribute_options)
filtered_attributes.each do |key, value|
segment.add_agent_attribute("stripe_user_data_#{key}", value, DEFAULT_DESTINATIONS)
end
end

def nr_attribute_options
ATTRIBUTE_FILTER_TYPES.each_with_object({}) do |type, opts|
pattern =
NewRelic::Agent::AttributePreFiltering.formulate_regexp_union(:"#{ATTRIBUTE_NAMESPACE}.#{type}")
opts[type] = pattern if pattern
end
end

def remove_and_return_nr_segment(event)
segment = event.user_data[:newrelic_segment]
event.user_data.delete(:newrelic_segment)

segment
end
end
end
end
end
18 changes: 18 additions & 0 deletions test/multiverse/suites/stripe/Envfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# 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

# While Stripe instrumentation doesn't do any monkey patching, we need to
# include an instrumentation method for multiverse to run the tests
instrumentation_methods :chain

STRIPE_VERSIONS = [
[nil, 2.4],
['5.38.0', 2.4]
]

def gem_list(stripe_version = nil)
"gem 'stripe'#{stripe_version}"
end

create_gemfiles(STRIPE_VERSIONS)
19 changes: 19 additions & 0 deletions test/multiverse/suites/stripe/config/newrelic.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
development:
error_collector:
enabled: true
apdex_t: 0.5
agent_enabled: true
monitor_mode: true
license_key: bootstrap_newrelic_admin_license_key_000
app_name: test
host: localhost
api_host: localhost
port: <%= $collector && $collector.port %>
transaction_tracer:
record_sql: obfuscated
enabled: true
stack_trace_threshold: 0.5
transaction_threshold: 1.0
capture_params: false
disable_serialization: false
191 changes: 191 additions & 0 deletions test/multiverse/suites/stripe/stripe_instrumentation_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
# 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 'json'
require 'stripe'
require 'net/http'

class StripeInstrumentation < Minitest::Test
API_KEY = '123456789'
STRIPE_URL = 'Stripe/v1/customers/get'

def setup
Stripe.api_key = API_KEY
# Creating a new connection and response, which both get stubbed
# later, helps us get around needing to provide a valid API key
@connection = Stripe::ConnectionManager.new
@response = Net::HTTPResponse.new('1.1', '200', 'OK')
# Bypass #stream_check ("attempt to read body out of block")
@response.instance_variable_set(:@read, true)
@response.body = {
object: 'list',
data: [{'id': '12134'}],
has_more: false,
url: STRIPE_URL
}.to_json
end

def test_version_supported
assert(Gem::Version.new(Stripe::VERSION) >= Gem::Version.new('5.38.0'))
end

def test_subscribed_request_begin
subcribers = Stripe::Instrumentation.send(:subscribers)
newrelic_begin_subscriber = subcribers[:request_begin].detect { |_k, v| v.to_s.include?('instrumentation/stripe') }

assert(newrelic_begin_subscriber)
end

def test_subscribed_request_end
subcribers = Stripe::Instrumentation.send(:subscribers)
newrelic_begin_subscriber = subcribers[:request_end].detect { |_k, v| v.to_s.include?('instrumentation/stripe') }

assert(newrelic_begin_subscriber)
end

def test_newrelic_segment
with_stubbed_connection_manager do
in_transaction do |txn|
start_stripe_event
stripe_segment = stripe_segment_from_transaction(txn)

assert(stripe_segment)
end
end
end

def test_agent_collects_user_data_attributes_when_configured
Stripe::Instrumentation.subscribe(:request_begin) do |events|
events.user_data[:cat] = 'meow'
events.user_data[:dog] = 'woof'
end

with_config(:'stripe.user_data.include' => '.') do
with_stubbed_connection_manager do
in_transaction do |txn|
start_stripe_event
stripe_segment = stripe_segment_from_transaction(txn)

assert(stripe_segment)
stripe_attributes = stripe_segment.attributes.agent_attributes_for(NewRelic::Agent::AttributeFilter::DST_SPAN_EVENTS)

assert_equal('meow', stripe_attributes['stripe_user_data_cat'])
assert_equal('woof', stripe_attributes['stripe_user_data_dog'])
end
end
end
end

def test_agent_collects_select_user_data_attributes
Stripe::Instrumentation.subscribe(:request_begin) do |events|
events.user_data[:frog] = 'ribbit'
events.user_data[:sheep] = 'baa'
events.user_data[:cow] = 'moo'
end

with_config(:'stripe.user_data.include' => 'frog, sheep') do
with_stubbed_connection_manager do
in_transaction do |txn|
start_stripe_event
stripe_segment = stripe_segment_from_transaction(txn)

assert(stripe_segment)
stripe_attributes = stripe_segment.attributes.agent_attributes_for(NewRelic::Agent::AttributeFilter::DST_SPAN_EVENTS)

assert_equal('ribbit', stripe_attributes['stripe_user_data_frog'])
assert_equal('baa', stripe_attributes['stripe_user_data_sheep'])
assert_nil(stripe_attributes['stripe_user_data_cow'])
end
end
end
end

def test_agent_ignores_user_data_attributes
Stripe::Instrumentation.subscribe(:request_begin) do |events|
events.user_data[:bird] = 'tweet'
end

with_config('stripe.user_data.include': %w[.],
'stripe.user_data.exclude': %w[bird]) do
with_stubbed_connection_manager do
in_transaction do |txn|
start_stripe_event
stripe_segment = stripe_segment_from_transaction(txn)

assert(stripe_segment)
stripe_attributes = stripe_segment.attributes.agent_attributes_for(NewRelic::Agent::AttributeFilter::DST_SPAN_EVENTS)

assert_nil(stripe_attributes['stripe_user_data_bird'])
end
end
end
end

def test_agent_ignores_user_data_values
Stripe::Instrumentation.subscribe(:request_begin) do |events|
events.user_data[:contact_name] = 'Jenny'
events.user_data[:contact_phone] = '867-5309'
end

with_config('stripe.user_data.include': %w[.],
'stripe.user_data.exclude': ['^\d{3}-\d{4}$']) do
with_stubbed_connection_manager do
in_transaction do |txn|
start_stripe_event
stripe_segment = stripe_segment_from_transaction(txn)

assert(stripe_segment)
stripe_attributes = stripe_segment.attributes.agent_attributes_for(NewRelic::Agent::AttributeFilter::DST_SPAN_EVENTS)

assert(stripe_attributes['stripe_user_data_contact_name'])
assert_nil(stripe_attributes['stripe_user_data_contact_phone'])
end
end
end
end

def test_start_when_not_traced
with_stubbed_connection_manager do
NewRelic::Agent::Tracer.state.stub(:is_execution_traced?, false) do
in_transaction do |txn|
start_stripe_event
stripe_segment = stripe_segment_from_transaction(txn)

refute stripe_segment
end
end
end
end

def test_start_segment_records_error
NewRelic::Agent.stub(:logger, NewRelic::Agent::MemoryLogger.new) do
bad_event = OpenStruct.new(path: 'v1/charges', method: 'get')
NewRelic::Agent::Instrumentation::StripeSubscriber.new.start_segment(bad_event)

assert_logged(/Error starting New Relic Stripe segment/m)
end
end

def start_stripe_event
Stripe::Customer.list({limit: 3})
end

def stripe_segment_from_transaction(txn)
txn.segments.detect { |s| s.name == STRIPE_URL }
end

def with_stubbed_connection_manager(&block)
Stripe::StripeClient.stub(:default_connection_manager, @connection) do
@connection.stub(:execute_request, @response) do
yield
end
end
end

def assert_logged(expected)
found = NewRelic::Agent.logger.messages.any? { |m| m[1][0].match?(expected) }

assert(found, "Didn't see log message: '#{expected}'")
end
end

0 comments on commit 942607b

Please sign in to comment.