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

Improve duration counting using monotonic clock #424

Merged
merged 4 commits into from
Sep 17, 2020
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
1 change: 1 addition & 0 deletions ddtrace.gemspec
Expand Up @@ -33,6 +33,7 @@ EOS
spec.require_paths = ['lib']

spec.add_dependency 'msgpack'
spec.add_dependency 'concurrent-ruby'

spec.add_development_dependency 'rake', '~> 10.5'
spec.add_development_dependency 'rubocop', '= 0.49.1' if RUBY_VERSION >= '2.1.0'
Expand Down
Expand Up @@ -73,8 +73,8 @@ def start_span(name, id, payload, start = nil)

# Start a trace
tracer.trace(@span_name, @options).tap do |span|
# Assign start time if provided
span.start_time = start unless start.nil?
# start span if time is provided
span.start(start) unless start.nil?
end
end

Expand Down
6 changes: 3 additions & 3 deletions lib/ddtrace/contrib/grape/endpoint.rb
Expand Up @@ -83,7 +83,7 @@ def self.endpoint_run(name, start, finish, id, payload)
span.set_tag('grape.route.endpoint', api_view)
span.set_tag('grape.route.path', path)
ensure
span.start_time = start
span.start(start)
span.finish(finish)
end
rescue StandardError => e
Expand Down Expand Up @@ -124,7 +124,7 @@ def self.endpoint_render(name, start, finish, id, payload)
begin
span.set_error(payload[:exception_object]) unless payload[:exception_object].nil?
ensure
span.start_time = start
span.start(start)
span.finish(finish)
end
rescue StandardError => e
Expand Down Expand Up @@ -152,7 +152,7 @@ def self.endpoint_run_filters(name, start, finish, id, payload)
span.set_error(payload[:exception_object]) unless payload[:exception_object].nil?
span.set_tag('grape.filter.type', type.to_s)
ensure
span.start_time = start
span.start(start)
span.finish(finish)
end
rescue StandardError => e
Expand Down
66 changes: 39 additions & 27 deletions lib/ddtrace/span.rb
@@ -1,8 +1,10 @@
require 'time'
require 'thread'
require 'forwardable'

require 'ddtrace/utils'
require 'ddtrace/ext/errors'
require 'ddtrace/span/duration'

module Datadog
# Represents a logical unit of work in the system. Each trace consists of one or more spans.
Expand All @@ -11,6 +13,8 @@ module Datadog
# within a larger operation. Spans can be nested within each other, and in those instances
# will have a parent-child relationship.
class Span
extend Forwardable
Copy link
Member

Choose a reason for hiding this comment

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

Forwardable has some performance that we'd like to avoid. It allocates a new object on every method call, which is not desirable. It is also slower, but I'm more concerned with the memory allocation for each call to the delegated methods:

require 'benchmark/ips'
require 'benchmark/memory'

require 'forwardable'

class Foo
  extend Forwardable

  def initialize
    @delegate = Bar.new
  end

  def_delegators :@delegate, :perform

  def def_perform
    @delegate.perform
  end
end

class Bar
  def perform; end
end

instance = Foo.new

Benchmark.memory do |x|
  x.report('forwardable') { instance.perform }
  x.report('def perform') { instance.def_perform }

  x.compare!
end

Benchmark.ips do |x|
  x.report('forwardable') { instance.perform }
  x.report('def perform') { instance.def_perform }

  x.compare!
end
Calculating -------------------------------------
         forwardable    40.000  memsize (     0.000  retained)
                         1.000  objects (     0.000  retained)
                         0.000  strings (     0.000  retained)
         def perform     0.000  memsize (     0.000  retained)
                         0.000  objects (     0.000  retained)
                         0.000  strings (     0.000  retained)

Comparison:
         def perform:          0 allocated
         forwardable:         40 allocated - Infx more
Warming up --------------------------------------
         forwardable   831.919k i/100ms
         def perform     1.807M i/100ms
Calculating -------------------------------------
         forwardable      7.722M (± 6.1%) i/s -     39.100M in   5.082914s
         def perform     18.442M (± 2.6%) i/s -     92.146M in   5.000256s

Comparison:
         def perform: 18441718.1 i/s
         forwardable:  7722268.6 i/s - 2.39x  (± 0.00) slower


# The max value for a \Span identifier.
# Span and trace identifiers should be strictly positive and strictly inferior to this limit.
#
Expand All @@ -19,13 +23,14 @@ class Span
MAX_ID = 2**63

attr_accessor :name, :service, :resource, :span_type,
:start_time, :end_time,
:span_id, :trace_id, :parent_id,
:status, :sampled,
:tracer, :context

attr_reader :parent

def_delegators :@duration, :start_time, :end_time, :started?, :finished?

# Create a new span linked to the given tracer. Call the \Tracer method <tt>start_span()</tt>
# and then <tt>finish()</tt> once the tracer operation is over.
#
Expand Down Expand Up @@ -56,8 +61,7 @@ def initialize(tracer, name, options = {})
@parent = nil
@sampled = true

@start_time = nil # set by Tracer.start_span
@end_time = nil # set by Span.finish
@duration = Duration.new
end

# Set the given key / value tag pair on the span. Keys and values
Expand Down Expand Up @@ -100,20 +104,36 @@ def set_error(e)
set_tag(Ext::Errors::STACK, e.backtrace) unless e.backtrace.empty?
end

# Mark the span started at the current time.
def start(start_time = nil)
# A span should not be started twice. Note that this is not thread-safe,
# start is called from multiple threads, a given span might be started
# several times. Again, one should not do this, so this test is more a
# fallback to avoid very bad things and protect you in most common cases.
return if started?
@duration.start(start_time)

self
end

# for backwards compatibility
def start_time=(time)
time.tap { start(time) }
end

# for backwards compatibility
def end_time=(time)
time.tap { @duration.finish(time) }
end

# Mark the span finished at the current time and submit it.
def finish(finish_time = nil)
# A span should not be finished twice. Note that this is not thread-safe,
# finish is called from multiple threads, a given span might be finished
# several times. Again, one should not do this, so this test is more a
# fallback to avoid very bad things and protect you in most common cases.
return if finished?

# Provide a default start_time if unset, but this should have been set by start_span.
# Using now here causes 0-duration spans, still, this is expected, as we never
# explicitely say when it started.
@start_time ||= Time.now.utc

@end_time = finish_time.nil? ? Time.now.utc : finish_time # finish this
@duration.finish(finish_time)

# Finish does not really do anything if the span is not bound to a tracer and a context.
return self if @tracer.nil? || @context.nil?
Expand All @@ -132,11 +152,6 @@ def finish(finish_time = nil)
self
end

# Return whether the span is finished or not.
def finished?
!@end_time.nil?
end

# Return a string representation of the span.
def to_s
"Span(name:#{@name},sid:#{@span_id},tid:#{@trace_id},pid:#{@parent_id})"
Expand Down Expand Up @@ -165,7 +180,7 @@ def parent=(parent)

# Return the hash representation of the current span.
def to_hash
h = {
{
span_id: @span_id,
parent_id: @parent_id,
trace_id: @trace_id,
Expand All @@ -176,21 +191,18 @@ def to_hash
meta: @meta,
metrics: @metrics,
error: @status
}

if !@start_time.nil? && !@end_time.nil?
h[:start] = (@start_time.to_f * 1e9).to_i
h[:duration] = ((@end_time - @start_time) * 1e9).to_i
}.tap do |h|
if @duration.complete?
h[:start] = (@duration.start_time.to_f * 1e9).to_i
h[:duration] = (@duration.to_f * 1e9).to_i
end
end

h
end

# Return a human readable version of the span
def pretty_print(q)
start_time = (@start_time.to_f * 1e9).to_i rescue '-'
end_time = (@end_time.to_f * 1e9).to_i rescue '-'
duration = ((@end_time - @start_time) * 1e9).to_i rescue 0
start_time = (@duration.start_time.to_f * 1e9).to_i rescue '-'
end_time = (@duration.end_time.to_f * 1e9).to_i rescue '-'
q.group 0 do
q.breakable
q.text "Name: #{@name}\n"
Expand All @@ -203,7 +215,7 @@ def pretty_print(q)
q.text "Error: #{@status}\n"
q.text "Start: #{start_time}\n"
q.text "End: #{end_time}\n"
q.text "Duration: #{duration}\n"
q.text "Duration: #{@duration.to_f}\n"
q.group(2, 'Tags: [', "]\n") do
q.breakable
q.seplist @meta.each do |key, value|
Expand Down
69 changes: 69 additions & 0 deletions lib/ddtrace/span/duration.rb
@@ -0,0 +1,69 @@
require 'concurrent/utility/monotonic_time'
module Datadog
class Span
# Stateful class used to track and calculate the duration of a Span.
class Duration
attr_reader :start_time, :end_time

def initialize
@start_time = nil
@duration_start = nil
@end_time = nil
@duration_end = nil
@wall_clock_duration = false
end

# Return whether the duration is started or not
def started?
!@start_time.nil?
end

# Return whether the duration is finished or not.
def finished?
!@end_time.nil?
end

def complete?
started? && finished?
end

def start(start_time)
if start_time
@start_time = start_time
@duration_start = start_time
@wall_clock_duration = true
else
@start_time = Time.now.utc
@duration_start = duration_marker
end
end

def finish(finish_time)
now = Time.now.utc

# Provide a default start_time if unset.
# Using `now` here causes duration to be 0; this is expected
# behavior when start_time is unknown.
start(finish_time || now) unless started?

if finish_time
@end_time = finish_time
@duration_start = @start_time
@duration_end = finish_time
@wall_clock_duration = true
else
@end_time = now
@duration_end = @wall_clock_duration ? now : duration_marker
end
end

def to_f
(@duration_end - @duration_start).to_f rescue 0.0
end

def duration_marker
Concurrent.monotonic_time
Copy link
Member

Choose a reason for hiding this comment

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

We can use the existing Datadog::Utils::Time.get_time monotonic time method.
With this, we can remove the dependency on concurrent-ruby.

end
end
end
end
4 changes: 2 additions & 2 deletions lib/ddtrace/tracer.rb
Expand Up @@ -203,7 +203,7 @@ def guess_context_and_parent(child_of)
# * +start_time+: when the span actually starts (defaults to \now)
# * +tags+: extra tags which should be added to the span.
def start_span(name, options = {})
start_time = options.fetch(:start_time, Time.now.utc)
start_time = options[:start_time]
tags = options.fetch(:tags, {})

opts = options.select do |k, _v|
Expand All @@ -230,7 +230,7 @@ def start_span(name, options = {})
end
tags.each { |k, v| span.set_tag(k, v) } unless tags.empty?
@tags.each { |k, v| span.set_tag(k, v) } unless @tags.empty?
span.start_time = start_time
span.start start_time

# this could at some point be optional (start_active_span vs start_manual_span)
ctx.add_span(span) unless ctx.nil?
Expand Down
Expand Up @@ -30,7 +30,7 @@

it do
expect(tracer).to receive(:trace).with(span_name, options).and_return(span).ordered
expect(span).to receive(:start_time=).with(start).and_return(span).ordered
expect(span).to receive(:start).with(start).and_return(span).ordered
expect(tracer).to receive(:active_span).and_return(span).ordered
expect(spy).to receive(:call).with(span, name, id, payload).ordered
expect(span).to receive(:finish).with(finish).and_return(span).ordered
Expand All @@ -48,7 +48,7 @@

it 'finishes tracing anyways' do
expect(tracer).to receive(:trace).with(span_name, options).and_return(span).ordered
expect(span).to receive(:start_time=).with(start).and_return(span).ordered
expect(span).to receive(:start).with(start).and_return(span).ordered
expect(tracer).to receive(:active_span).and_return(span).ordered
expect(span).to receive(:finish).with(finish).and_return(span).ordered
is_expected.to be(span)
Expand Down