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

Add metrics code locations #2263

Merged
merged 24 commits into from
Mar 12, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
be9422d
metrics wip
sl0thentr0py Feb 16, 2024
ee59473
aggregator add impl
sl0thentr0py Feb 21, 2024
675292b
config and apis
sl0thentr0py Feb 21, 2024
73e5e04
encode statsd format and sanitization
sl0thentr0py Feb 22, 2024
9569bf9
capture envelope
sl0thentr0py Feb 22, 2024
8efde86
fix set
sl0thentr0py Feb 26, 2024
512408c
add transaction name to tags
sl0thentr0py Feb 26, 2024
bdac066
Specs
sl0thentr0py Feb 27, 2024
3f51c15
changelog
sl0thentr0py Feb 27, 2024
c1217cd
metric specs
sl0thentr0py Feb 27, 2024
8f9dabb
incr -> increment
sl0thentr0py Feb 29, 2024
ff3814f
Move config to separate metrics obj
sl0thentr0py Mar 5, 2024
f7163ef
Use scope name/source, not transaction
sl0thentr0py Mar 5, 2024
ff9792f
Remove is_json for envelope and use string check
sl0thentr0py Mar 11, 2024
9ba1a4e
trigger ci
sl0thentr0py Mar 11, 2024
478a78b
trigger ci
sl0thentr0py Mar 11, 2024
0e87bab
remove io-console pin
sl0thentr0py Mar 11, 2024
e6bbb90
Add Sentry::Metrics.timing API to measure blocks
sl0thentr0py Mar 4, 2024
7567daa
Merge remote-tracking branch 'origin/master' into neel/metrics/timing
sl0thentr0py Mar 12, 2024
116318b
Metric summaries on span
sl0thentr0py Mar 5, 2024
0df891d
Merge remote-tracking branch 'origin/master' into neel/metrics/span-a…
sl0thentr0py Mar 12, 2024
da14eaf
Add config.metrics.before_emit callback
sl0thentr0py Mar 8, 2024
088ccad
code locations
sl0thentr0py Mar 8, 2024
4107fd4
Merge remote-tracking branch 'origin/master' into neel/metrics/code-l…
sl0thentr0py Mar 12, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 16 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
- Add `Sentry::Metrics.timing` API for measuring block duration [#2254](https://github.com/getsentry/sentry-ruby/pull/2254)
- Add metric summaries on spans [#2255](https://github.com/getsentry/sentry-ruby/pull/2255)
- Add `config.metrics.before_emit` callback [#2258](https://github.com/getsentry/sentry-ruby/pull/2258)
- Add code locations for metrics [#2263](https://github.com/getsentry/sentry-ruby/pull/2263)

The SDK now supports recording and aggregating metrics. A new thread will be started
for aggregation and will flush the pending data to Sentry every 5 seconds.
Expand Down Expand Up @@ -45,11 +46,14 @@
Sentry::Metrics.timing('how_long') { sleep(1) }
# timing - measure duration of code block in other duraton units
Sentry::Metrics.timing('how_long_ms', unit: 'millisecond') { sleep(0.5) }
```

You can filter some keys or update tags on the fly with the `before_emit` callback, which will be triggered before a metric is aggregated.

# add a before_emit callback to filter keys or update tags
```ruby
Sentry.init do |config|
# ...
config.metrics.enabled = true
# the 'foo' metric will be filtered and the tags will be updated to add :bar and remove :baz
config.metrics.before_emit = lambda do |key, tags|
return nil if key == 'foo'
tags[:bar] = 42
Expand All @@ -59,6 +63,16 @@
end
```

By default, the SDK will send code locations for unique metrics (defined by type, key and unit) once a day and with every startup/shutdown of your application.
You can turn this off with the following:

```ruby
Sentry.init do |config|
# ...
config.metrics.enable_code_locations = false
end
```

### Bug Fixes

- Fix undefined method 'constantize' issue in `sentry-resque` ([#2248](https://github.com/getsentry/sentry-ruby/pull/2248))
Expand Down
4 changes: 3 additions & 1 deletion sentry-ruby/lib/sentry/backtrace.rb
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@
# Parses a single line of a given backtrace
# @param [String] unparsed_line The raw line from +caller+ or some backtrace
# @return [Line] The parsed backtrace line
def self.parse(unparsed_line, in_app_pattern)
def self.parse(unparsed_line, in_app_pattern = nil)
ruby_match = unparsed_line.match(RUBY_INPUT_FORMAT)
if ruby_match
_, file, number, _, method = ruby_match.to_a
Expand All @@ -57,6 +57,8 @@
end

def in_app
return false unless in_app_pattern

Check warning on line 60 in sentry-ruby/lib/sentry/backtrace.rb

View check run for this annotation

Codecov / codecov/patch

sentry-ruby/lib/sentry/backtrace.rb#L60

Added line #L60 was not covered by tests

if file =~ in_app_pattern
true
else
Expand Down
8 changes: 8 additions & 0 deletions sentry-ruby/lib/sentry/interfaces/stacktrace_builder.rb
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,14 @@
StacktraceInterface.new(frames: frames)
end

# Get the code location hash for a single line for where metrics where added.
# @return [Hash]
def metrics_code_location(unparsed_line)
parsed_line = Backtrace::Line.parse(unparsed_line)
frame = convert_parsed_line_into_frame(parsed_line)
frame.to_hash.reject { |k, _| %i[project_root in_app].include?(k) }

Check warning on line 70 in sentry-ruby/lib/sentry/interfaces/stacktrace_builder.rb

View check run for this annotation

Codecov / codecov/patch

sentry-ruby/lib/sentry/interfaces/stacktrace_builder.rb#L68-L70

Added lines #L68 - L70 were not covered by tests
end

private

def convert_parsed_line_into_frame(line)
Expand Down
11 changes: 7 additions & 4 deletions sentry-ruby/lib/sentry/metrics.rb
Original file line number Diff line number Diff line change
Expand Up @@ -35,17 +35,20 @@

def timing(key, unit: 'second', tags: {}, timestamp: nil, &block)
return unless block_given?
return unless DURATION_UNITS.include?(unit)
return yield unless DURATION_UNITS.include?(unit)

Check warning on line 38 in sentry-ruby/lib/sentry/metrics.rb

View check run for this annotation

Codecov / codecov/patch

sentry-ruby/lib/sentry/metrics.rb#L38

Added line #L38 was not covered by tests

Sentry.with_child_span(op: OP_NAME, description: key) do |span|
result, value = Sentry.with_child_span(op: OP_NAME, description: key) do |span|

Check warning on line 40 in sentry-ruby/lib/sentry/metrics.rb

View check run for this annotation

Codecov / codecov/patch

sentry-ruby/lib/sentry/metrics.rb#L40

Added line #L40 was not covered by tests
tags.each { |k, v| span.set_tag(k, v.is_a?(Array) ? v.join(', ') : v.to_s) } if span

start = Timing.send(unit.to_sym)
yield
result = yield

Check warning on line 44 in sentry-ruby/lib/sentry/metrics.rb

View check run for this annotation

Codecov / codecov/patch

sentry-ruby/lib/sentry/metrics.rb#L44

Added line #L44 was not covered by tests
value = Timing.send(unit.to_sym) - start

Sentry.metrics_aggregator&.add(:d, key, value, unit: unit, tags: tags, timestamp: timestamp)
[result, value]

Check warning on line 47 in sentry-ruby/lib/sentry/metrics.rb

View check run for this annotation

Codecov / codecov/patch

sentry-ruby/lib/sentry/metrics.rb#L47

Added line #L47 was not covered by tests
end

Sentry.metrics_aggregator&.add(:d, key, value, unit: unit, tags: tags, timestamp: timestamp)
result

Check warning on line 51 in sentry-ruby/lib/sentry/metrics.rb

View check run for this annotation

Codecov / codecov/patch

sentry-ruby/lib/sentry/metrics.rb#L50-L51

Added lines #L50 - L51 were not covered by tests
end
end
end
Expand Down
108 changes: 82 additions & 26 deletions sentry-ruby/lib/sentry/metrics/aggregator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@
FLUSH_INTERVAL = 5
ROLLUP_IN_SECONDS = 10

# this is how far removed from user code in the backtrace we are
# when we record code locations
DEFAULT_STACKLEVEL = 4

KEY_SANITIZATION_REGEX = /[^a-zA-Z0-9_\/.-]+/
VALUE_SANITIZATION_REGEX = /[^[[:word:]][[:digit:]][[:space:]]_:\/@\.{}\[\]$-]+/

Expand All @@ -19,12 +23,14 @@
}

# exposed only for testing
attr_reader :thread, :buckets, :flush_shift
attr_reader :thread, :buckets, :flush_shift, :code_locations

def initialize(configuration, client)
@client = client
@logger = configuration.logger
@before_emit = configuration.metrics.before_emit
@enable_code_locations = configuration.metrics.enable_code_locations
@stacktrace_builder = configuration.stacktrace_builder

Check warning on line 33 in sentry-ruby/lib/sentry/metrics/aggregator.rb

View check run for this annotation

Codecov / codecov/patch

sentry-ruby/lib/sentry/metrics/aggregator.rb#L32-L33

Added lines #L32 - L33 were not covered by tests

@default_tags = {}
@default_tags['release'] = configuration.release if configuration.release
Expand All @@ -34,47 +40,41 @@
@exited = false
@mutex = Mutex.new

# buckets are a nested hash of timestamp -> bucket keys -> Metric instance
# a nested hash of timestamp -> bucket keys -> Metric instance
@buckets = {}

# the flush interval needs to be shifted once per startup to create jittering
@flush_shift = Random.rand * ROLLUP_IN_SECONDS

# a nested hash of timestamp (start of day) -> meta keys -> frame
@code_locations = {}

Check warning on line 50 in sentry-ruby/lib/sentry/metrics/aggregator.rb

View check run for this annotation

Codecov / codecov/patch

sentry-ruby/lib/sentry/metrics/aggregator.rb#L50

Added line #L50 was not covered by tests
end

def add(type,
key,
value,
unit: 'none',
tags: {},
timestamp: nil)
timestamp: nil,
stacklevel: nil)
return unless ensure_thread
return unless METRIC_TYPES.keys.include?(type)

timestamp = timestamp.to_i if timestamp.is_a?(Time)
timestamp ||= Sentry.utc_now.to_i
updated_tags = get_updated_tags(tags)
return if @before_emit && !@before_emit.call(key, updated_tags)

Check warning on line 64 in sentry-ruby/lib/sentry/metrics/aggregator.rb

View check run for this annotation

Codecov / codecov/patch

sentry-ruby/lib/sentry/metrics/aggregator.rb#L63-L64

Added lines #L63 - L64 were not covered by tests

timestamp ||= Sentry.utc_now

Check warning on line 66 in sentry-ruby/lib/sentry/metrics/aggregator.rb

View check run for this annotation

Codecov / codecov/patch

sentry-ruby/lib/sentry/metrics/aggregator.rb#L66

Added line #L66 was not covered by tests

# this is integer division and thus takes the floor of the division
# and buckets into 10 second intervals
bucket_timestamp = (timestamp / ROLLUP_IN_SECONDS) * ROLLUP_IN_SECONDS
updated_tags = get_updated_tags(tags)

return if @before_emit && !@before_emit.call(key, updated_tags)
bucket_timestamp = (timestamp.to_i / ROLLUP_IN_SECONDS) * ROLLUP_IN_SECONDS

Check warning on line 70 in sentry-ruby/lib/sentry/metrics/aggregator.rb

View check run for this annotation

Codecov / codecov/patch

sentry-ruby/lib/sentry/metrics/aggregator.rb#L70

Added line #L70 was not covered by tests

serialized_tags = serialize_tags(updated_tags)
bucket_key = [type, key, unit, serialized_tags]

added = @mutex.synchronize do
@buckets[bucket_timestamp] ||= {}

if (metric = @buckets[bucket_timestamp][bucket_key])
old_weight = metric.weight
metric.add(value)
metric.weight - old_weight
else
metric = METRIC_TYPES[type].new(value)
@buckets[bucket_timestamp][bucket_key] = metric
metric.weight
end
record_code_location(type, key, unit, timestamp, stacklevel: stacklevel) if @enable_code_locations
process_bucket(bucket_timestamp, bucket_key, type, value)

Check warning on line 77 in sentry-ruby/lib/sentry/metrics/aggregator.rb

View check run for this annotation

Codecov / codecov/patch

sentry-ruby/lib/sentry/metrics/aggregator.rb#L76-L77

Added lines #L76 - L77 were not covered by tests
end

# for sets, we pass on if there was a new entry to the local gauge
Expand All @@ -84,14 +84,28 @@

def flush(force: false)
flushable_buckets = get_flushable_buckets!(force)
return if flushable_buckets.empty?
code_locations = get_code_locations!
return if flushable_buckets.empty? && code_locations.empty?

Check warning on line 88 in sentry-ruby/lib/sentry/metrics/aggregator.rb

View check run for this annotation

Codecov / codecov/patch

sentry-ruby/lib/sentry/metrics/aggregator.rb#L87-L88

Added lines #L87 - L88 were not covered by tests

payload = serialize_buckets(flushable_buckets)
envelope = Envelope.new
envelope.add_item(
{ type: 'statsd', length: payload.bytesize },
payload
)

unless flushable_buckets.empty?
payload = serialize_buckets(flushable_buckets)
envelope.add_item(

Check warning on line 94 in sentry-ruby/lib/sentry/metrics/aggregator.rb

View check run for this annotation

Codecov / codecov/patch

sentry-ruby/lib/sentry/metrics/aggregator.rb#L92-L94

Added lines #L92 - L94 were not covered by tests
{ type: 'statsd', length: payload.bytesize },
payload
)
end

unless code_locations.empty?
code_locations.each do |timestamp, locations|
payload = serialize_locations(timestamp, locations)
envelope.add_item(

Check warning on line 103 in sentry-ruby/lib/sentry/metrics/aggregator.rb

View check run for this annotation

Codecov / codecov/patch

sentry-ruby/lib/sentry/metrics/aggregator.rb#L100-L103

Added lines #L100 - L103 were not covered by tests
{ type: 'metric_meta', content_type: 'application/json' },
payload
)
end
end

Sentry.background_worker.perform do
@client.transport.send_envelope(envelope)
Expand Down Expand Up @@ -154,6 +168,14 @@
end
end

def get_code_locations!
@mutex.synchronize do
code_locations = @code_locations
@code_locations = {}
code_locations

Check warning on line 175 in sentry-ruby/lib/sentry/metrics/aggregator.rb

View check run for this annotation

Codecov / codecov/patch

sentry-ruby/lib/sentry/metrics/aggregator.rb#L172-L175

Added lines #L172 - L175 were not covered by tests
end
end

# serialize buckets to statsd format
def serialize_buckets(buckets)
buckets.map do |timestamp, timestamp_buckets|
Expand All @@ -167,6 +189,18 @@
end.flatten.join("\n")
end

def serialize_locations(timestamp, locations)
mapping = locations.map do |meta_key, location|
type, key, unit = meta_key
mri = "#{type}:#{sanitize_key(key)}@#{unit}"

Check warning on line 195 in sentry-ruby/lib/sentry/metrics/aggregator.rb

View check run for this annotation

Codecov / codecov/patch

sentry-ruby/lib/sentry/metrics/aggregator.rb#L193-L195

Added lines #L193 - L195 were not covered by tests

# note this needs to be an array but it really doesn't serve a purpose right now
[mri, [location.merge(type: 'location')]]

Check warning on line 198 in sentry-ruby/lib/sentry/metrics/aggregator.rb

View check run for this annotation

Codecov / codecov/patch

sentry-ruby/lib/sentry/metrics/aggregator.rb#L198

Added line #L198 was not covered by tests
end.to_h

{ timestamp: timestamp, mapping: mapping }

Check warning on line 201 in sentry-ruby/lib/sentry/metrics/aggregator.rb

View check run for this annotation

Codecov / codecov/patch

sentry-ruby/lib/sentry/metrics/aggregator.rb#L201

Added line #L201 was not covered by tests
end

def sanitize_key(key)
key.gsub(KEY_SANITIZATION_REGEX, '_')
end
Expand Down Expand Up @@ -199,6 +233,28 @@

scope.span.metrics_local_aggregator.add(key, value)
end

def process_bucket(timestamp, key, type, value)
@buckets[timestamp] ||= {}

Check warning on line 238 in sentry-ruby/lib/sentry/metrics/aggregator.rb

View check run for this annotation

Codecov / codecov/patch

sentry-ruby/lib/sentry/metrics/aggregator.rb#L238

Added line #L238 was not covered by tests

if (metric = @buckets[timestamp][key])
old_weight = metric.weight
metric.add(value)
metric.weight - old_weight

Check warning on line 243 in sentry-ruby/lib/sentry/metrics/aggregator.rb

View check run for this annotation

Codecov / codecov/patch

sentry-ruby/lib/sentry/metrics/aggregator.rb#L240-L243

Added lines #L240 - L243 were not covered by tests
else
metric = METRIC_TYPES[type].new(value)
@buckets[timestamp][key] = metric
metric.weight

Check warning on line 247 in sentry-ruby/lib/sentry/metrics/aggregator.rb

View check run for this annotation

Codecov / codecov/patch

sentry-ruby/lib/sentry/metrics/aggregator.rb#L245-L247

Added lines #L245 - L247 were not covered by tests
end
end

def record_code_location(type, key, unit, timestamp, stacklevel: nil)
meta_key = [type, key, unit]
start_of_day = Time.utc(timestamp.year, timestamp.month, timestamp.day).to_i

Check warning on line 253 in sentry-ruby/lib/sentry/metrics/aggregator.rb

View check run for this annotation

Codecov / codecov/patch

sentry-ruby/lib/sentry/metrics/aggregator.rb#L252-L253

Added lines #L252 - L253 were not covered by tests

@code_locations[start_of_day] ||= {}
@code_locations[start_of_day][meta_key] ||= @stacktrace_builder.metrics_code_location(caller[stacklevel || DEFAULT_STACKLEVEL])

Check warning on line 256 in sentry-ruby/lib/sentry/metrics/aggregator.rb

View check run for this annotation

Codecov / codecov/patch

sentry-ruby/lib/sentry/metrics/aggregator.rb#L255-L256

Added lines #L255 - L256 were not covered by tests
end
end
end
end
9 changes: 8 additions & 1 deletion sentry-ruby/lib/sentry/metrics/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,18 @@ module Metrics
class Configuration
include ArgumentCheckingHelper

# Enable metrics usage
# Enable metrics usage.
# Starts a new {Sentry::Metrics::Aggregator} instance to aggregate metrics
# and a thread to aggregate flush every 5 seconds.
# @return [Boolean]
attr_accessor :enabled

# Enable code location reporting.
# Will be sent once per day.
# True by default.
# @return [Boolean]
attr_accessor :enable_code_locations

# Optional Proc, called before emitting a metric to the aggregator.
# Use it to filter keys (return false/nil) or update tags.
# Make sure to return true at the end.
Expand All @@ -28,6 +34,7 @@ class Configuration

def initialize
@enabled = false
@enable_code_locations = true
end

def before_emit=(value)
Expand Down
45 changes: 29 additions & 16 deletions sentry-ruby/spec/sentry/interfaces/stacktrace_builder_spec.rb
Original file line number Diff line number Diff line change
@@ -1,26 +1,26 @@
require 'spec_helper'

RSpec.describe Sentry::StacktraceBuilder do
describe "#build" do
let(:fixture_root) { File.join(Dir.pwd, "spec", "support") }
let(:fixture_file) { File.join(fixture_root, "stacktrace_test_fixture.rb") }
let(:configuration) do
Sentry::Configuration.new.tap do |config|
config.project_root = fixture_root
end
let(:fixture_root) { File.join(Dir.pwd, "spec", "support") }
let(:fixture_file) { File.join(fixture_root, "stacktrace_test_fixture.rb") }
let(:configuration) do
Sentry::Configuration.new.tap do |config|
config.project_root = fixture_root
end
end

let(:backtrace) do
[
"#{fixture_file}:6:in `bar'",
"#{fixture_file}:2:in `foo'"
]
end
let(:backtrace) do
[
"#{fixture_file}:6:in `bar'",
"#{fixture_file}:2:in `foo'"
]
end

subject do
configuration.stacktrace_builder
end
subject do
configuration.stacktrace_builder
end

describe "#build" do
it "ignores frames without filename" do
interface = subject.build(backtrace: [":6:in `foo'"])
expect(interface.frames).to be_empty
Expand Down Expand Up @@ -82,4 +82,17 @@
end
end
end

describe '#metrics_code_location' do
it 'builds metrics code location hash for line' do
hash = subject.metrics_code_location(backtrace.first)

expect(hash[:filename]).to match(/stacktrace_test_fixture.rb/)
expect(hash[:function]).to eq("bar")
expect(hash[:lineno]).to eq(6)
expect(hash[:pre_context]).to eq(["end\n", "\n", "def bar\n"])
expect(hash[:context_line]).to eq(" baz\n")
expect(hash[:post_context]).to eq(["end\n", nil, nil])
end
end
end
Loading
Loading