Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Single Span Sampling] Add single span parser #2095

Merged
merged 4 commits into from Jun 30, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
3 changes: 3 additions & 0 deletions lib/datadog/tracing/sampling/rate_limiter.rb
Expand Up @@ -39,6 +39,9 @@ class TokenBucket < RateLimiter
def initialize(rate, max_tokens = rate)
super()

raise ArgumentError, "rate must be a number: #{rate}" unless rate.is_a?(Numeric)
raise ArgumentError, "max_tokens must be a number: #{max_tokens}" unless max_tokens.is_a?(Numeric)
marcotc marked this conversation as resolved.
Show resolved Hide resolved

@rate = rate
@max_tokens = max_tokens

Expand Down
9 changes: 9 additions & 0 deletions lib/datadog/tracing/sampling/span/matcher.rb
Expand Up @@ -6,6 +6,8 @@ module Sampling
module Span
# Checks if a span conforms to a matching criteria.
class Matcher
attr_reader :name, :service

# Pattern that matches any string
MATCH_ALL_PATTERN = '*'

Expand Down Expand Up @@ -54,6 +56,13 @@ def match?(span)
end
end

def ==(other)
return super unless other.is_a?(Matcher)

name == other.name &&
service == other.service
end
Comment on lines +59 to +64
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: Arguably this is not very duck-typing of us; perhaps we could check that other.respond_to?(:name) && other.respond_to?(:service)?

(Would apply to other similar changes in this PR)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you think this is needed, even with return super unless other.is_a?(Matcher) being checked?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll come back to this in the feature branch if you think this needs to be improved, merging this for now to keep me sane.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah! Yes, I wasn't clear about that part -- my suggestion would mean replacing the is_a?(Matcher) and relying only on the respond_to? instead. (But I marked this as minor, so definitely no need to hold anything back just for it)


private

# @param pattern [String]
Expand Down
8 changes: 8 additions & 0 deletions lib/datadog/tracing/sampling/span/rule.rb
Expand Up @@ -67,6 +67,14 @@ def sample!(span_op)
:rejected
end
end

def ==(other)
return super unless other.is_a?(Rule)

matcher == other.matcher &&
sample_rate == other.sample_rate &&
rate_limit == other.rate_limit
end
end
end
end
Expand Down
97 changes: 97 additions & 0 deletions lib/datadog/tracing/sampling/span/rule_parser.rb
@@ -0,0 +1,97 @@
# frozen_string_literal: true

require 'json'
require 'datadog/tracing/sampling/span/ext'
require 'datadog/tracing/sampling/span/matcher'
require 'datadog/tracing/sampling/span/rule'

module Datadog
module Tracing
module Sampling
module Span
# Converts user configuration into {Datadog::Tracing::Sampling::Span::Rule} objects,
# handling any parsing errors.
module RuleParser
class << self
# Parses the provided JSON string containing the Single Span
# Sampling configuration list.
# In case of parsing errors, `nil` is returned.
#
# @param rules [String] the JSON configuration rules to be parsed
# @return [Array<Datadog::Tracing::Sampling::Span::Rule>] a list of parsed rules
# @return [nil] if parsing failed
def parse_json(rules)
begin
list = JSON.parse(rules)
rescue => e
Datadog.logger.warn("Error parsing Span Sampling Rules `#{rules.inspect}`: "\
"#{e.class.name} #{e.message} at #{Array(e.backtrace).first}")
return nil
end

parse_list(list)
end

# Parses a list of Hashes containing the parsed JSON information
# for Single Span Sampling configuration.
# In case of parsing errors, `nil` is returned.
#
# @param rules [Array<String] the JSON configuration rules to be parsed
# @return [Array<Datadog::Tracing::Sampling::Span::Rule>] a list of parsed rules
# @return [nil] if parsing failed
def parse_list(rules)
unless rules.is_a?(Array)
Datadog.logger.warn("Span Sampling Rules are not an array: #{rules.inspect}")
return nil
end

parsed = rules.map do |hash|
unless hash.is_a?(Hash)
Datadog.logger.warn("Span Sampling Rule is not a key-value object: #{hash.inspect}")
return nil
end

begin
parse_rule(hash)
rescue => e
Datadog.logger.warn("Cannot parse Span Sampling Rule #{hash.inspect}: " \
"#{e.class.name} #{e} at #{Array(e.backtrace).first}")
nil
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This semantics is somewhat unexpected -- we seem to be quite strict with all other checks (and bail out from this method entirely if something is off), but if parse_rule fails then we just skip over that rule.

This seems deliberate (the tests have is_expected.to be_empty), but I'm curious why we're more strict in some cases and less in others?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is not spec'ed out but I liked the strict parsing, I've changed the PR accordingly.

I'll make sure the spec reflects it as well. (Or if there's disagreement at spec-level, I'll make changes to the feature branch accordingly)

end
end

parsed.compact!
parsed
end

private

def parse_rule(hash)
matcher_options = {}
if (name_pattern = hash['name'])
matcher_options[:name_pattern] = name_pattern
end

if (service_pattern = hash['service'])
matcher_options[:service_pattern] = service_pattern
end

matcher = Matcher.new(**matcher_options)

rule_options = {}
if (sample_rate = hash['sample_rate'])
rule_options[:sample_rate] = sample_rate
end

if (max_per_second = hash['max_per_second'])
rule_options[:rate_limit] = max_per_second
end

Rule.new(matcher, **rule_options)
end
end
end
end
end
end
end
16 changes: 16 additions & 0 deletions spec/datadog/tracing/sampling/rate_limiter_spec.rb
Expand Up @@ -18,6 +18,22 @@
it 'has all tokens available' do
expect(bucket.available_tokens).to eq(max_tokens)
end

context 'with invalid rate' do
let(:rate) { :bad }

it 'raises argument error' do
expect { bucket }.to raise_error(ArgumentError, /bad/)
end
end

context 'with invalid max_tokens' do
let(:max_tokens) { :bad }

it 'raises argument error' do
expect { bucket }.to raise_error(ArgumentError, /bad/)
end
end
end

describe '#allow?' do
Expand Down
154 changes: 154 additions & 0 deletions spec/datadog/tracing/sampling/span/rule_parser_spec.rb
@@ -0,0 +1,154 @@
require 'datadog/tracing/sampling/span/rule_parser'

RSpec.describe Datadog::Tracing::Sampling::Span::RuleParser do
describe '.parse_json' do
subject(:parse) { described_class.parse_json(rules_string) }
let(:rules_string) { JSON.dump(json_input) }
let(:json_input) { [] }

shared_examples 'does not modify span' do
it { expect { sample! }.to_not(change { span_op.send(:build_span).to_hash }) }
end
marcotc marked this conversation as resolved.
Show resolved Hide resolved

context 'invalid JSON' do
let(:rules_string) { '-not-json-' }

it 'warns and returns nil' do
expect(Datadog.logger).to receive(:warn).with(include(rules_string))
is_expected.to be(nil)
end
end

context 'valid JSON' do
context 'not a list' do
let(:json_input) { { 'not' => 'list' } }

it 'warns and returns nil' do
expect(Datadog.logger).to receive(:warn).with(include(json_input.inspect))
is_expected.to be(nil)
end
end

context 'a list' do
context 'without valid rules' do
let(:json_input) { ['not a hash'] }

it 'warns and returns nil' do
expect(Datadog.logger).to receive(:warn).with(include('not a hash'))
is_expected.to be(nil)
end
end

context 'with valid rules' do
let(:json_input) { [rule] }

let(:rule) do
{
name: name,
service: service,
sample_rate: sample_rate,
max_per_second: max_per_second,
}
end

let(:name) { nil }
let(:service) { nil }
let(:sample_rate) { nil }
let(:max_per_second) { nil }

context 'and default values' do
it 'delegates defaults to the rule and matcher' do
is_expected.to contain_exactly(
Datadog::Tracing::Sampling::Span::Rule.new(Datadog::Tracing::Sampling::Span::Matcher.new)
)
end
end

context 'with name' do
let(:name) { 'name' }

it 'sets the rule matcher name' do
is_expected.to contain_exactly(
Datadog::Tracing::Sampling::Span::Rule.new(
Datadog::Tracing::Sampling::Span::Matcher.new(name_pattern: name)
)
)
end

context 'with an invalid value' do
let(:name) { { 'bad' => 'name' } }

it 'warns and returns nil' do
expect(Datadog.logger).to receive(:warn).with(include(name.inspect) & include('Error'))
is_expected.to be_empty
end
end
end

context 'with service' do
let(:service) { 'service' }

it 'sets the rule matcher service' do
is_expected.to contain_exactly(
Datadog::Tracing::Sampling::Span::Rule.new(
Datadog::Tracing::Sampling::Span::Matcher.new(service_pattern: service)
)
)
end

context 'with an invalid value' do
let(:service) { { 'bad' => 'service' } }

it 'warns and returns nil' do
expect(Datadog.logger).to receive(:warn).with(include(service.inspect) & include('Error'))
is_expected.to be_empty
end
end
end

context 'with sample_rate' do
let(:sample_rate) { 1.0 }

it 'sets the rule matcher service' do
is_expected.to contain_exactly(
Datadog::Tracing::Sampling::Span::Rule.new(
Datadog::Tracing::Sampling::Span::Matcher.new, sample_rate: sample_rate
)
)
end

context 'with an invalid value' do
let(:sample_rate) { { 'bad' => 'sample_rate' } }

it 'warns and returns nil' do
expect(Datadog.logger).to receive(:warn).with(include(sample_rate.inspect) & include('Error'))
is_expected.to be_empty
end
end
end

context 'with max_per_second' do
let(:max_per_second) { 10 }

it 'sets the rule matcher service' do
is_expected.to contain_exactly(
Datadog::Tracing::Sampling::Span::Rule.new(
Datadog::Tracing::Sampling::Span::Matcher.new, rate_limit: max_per_second
)
)
end

context 'with an invalid value' do
let(:max_per_second) { { 'bad' => 'max_per_second' } }

it 'warns and returns nil' do
expect(Datadog.logger).to receive(:warn).with(include(max_per_second.inspect) & include('Error'))
is_expected.to be_empty
end
end
end
end
end
end
end
end