rspec-otel 0.0.7 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b966349d533ad57a8ee5fc1fd79b7ed99922beb6aad8fc1fb59e97d8062cdf5e
4
- data.tar.gz: e167fba956f24f180846574e5b87214f906b0d35377d7b92230df99aa6244dc2
3
+ metadata.gz: 34bc15bc0e142300b4a3d26488b19cf53567d7398377699267c30092a4c81f24
4
+ data.tar.gz: 3d7b067208db46d4c30d8768f16de46681b5bbfed4d841d4e426ed558f17ae98
5
5
  SHA512:
6
- metadata.gz: 5f19d4e4b8c94818c8d11e373380fc527c8e61f875e24189c3fa401a38d49f1647eb4f5e4862dea702de3397fd2ec81ee6deca4612056678c8de9faa90208eae
7
- data.tar.gz: a3320a67e728db92483d66a7cad492abbc7785cb7088417ab8e9e23b1648db348b0b32e869b308d6369a0cb7c267c613a17e7f0f810d11ab5138a4cbb6072b98
6
+ metadata.gz: cd70b47b5b70a3b3eb9c6ef8f73bc5ea6e3bc5ab9e834ec94e3defce8f0ac34912a16df748a15757a1c46208b11a9f77614a2e1acfdffdadc3bc025847c0f812
7
+ data.tar.gz: f224136edc1819bff811c66cba4b2d6b449bd286e99272b32a84c2247746882956a3d2b62fd3319978a7150719a94c93e57635c1960d45c0b65b194ac2653ca5
data/README.md CHANGED
@@ -73,6 +73,47 @@ Several conditions can be added to the matcher:
73
73
  _The `*_event` condition can be called multiple times with different events._
74
74
 
75
75
 
76
+ ### Matching the presence of a metric
77
+
78
+ You can match the emission of a metric with the `emit_metric` matcher:
79
+
80
+ ```ruby
81
+ require 'spec_helper'
82
+
83
+ RSpec.describe 'User API' do
84
+ it 'emits a metric' do
85
+ expect do
86
+ get :user, id: 1
87
+ end.to emit_metric('http.server.duration')
88
+ end
89
+ end
90
+ ```
91
+
92
+ `emit_metric` will also match a regular expression:
93
+
94
+ ```ruby
95
+ expect do
96
+ get :user, id: 1
97
+ end.to emit_metric(/^http\.server\./)
98
+ ```
99
+
100
+ Several conditions can be added to the matcher:
101
+
102
+ * `of_type` - Will match only metrics of the specified instrument kind (`:counter`, `:histogram`, `:gauge`, `:up_down_counter`, `:observable_counter`, `:observable_gauge`, `:observable_up_down_counter`).
103
+ * `with_attributes` - Will match only the metrics with the specified attributes on a data point.
104
+ * `without_attributes` - Will only match the metrics that do not have the specified attributes on any data point.
105
+ * `with_value` - Will match only the metrics where a data point has the specified value (applies to counters, gauges, and up-down counters).
106
+ * `with_count` - Will match only the metrics where a histogram data point has the specified recording count (applies to histograms only).
107
+
108
+ ```ruby
109
+ expect do
110
+ get :user, id: 1
111
+ end.to emit_metric('http.server.duration')
112
+ .of_type(:histogram)
113
+ .with_attributes({ 'http.request.method' => 'GET' })
114
+ ```
115
+
116
+
76
117
  ### Disabling
77
118
 
78
119
  We wrap every example in a new OpenTelemetry SDK configuration by default, if you wish to disable this you can tag your example with `:rspec_otel_disable_tracing`:
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RspecOtel
4
+ module Matchers
5
+ class EmitMetric
6
+ def initialize(name)
7
+ @name = name
8
+ @kind = nil
9
+ @filters = []
10
+ @before_count = 0
11
+ @pre_snapshot = {}
12
+ @closest_metric = nil
13
+ @closest_filter_count = 0
14
+ @emitted_outside_block = false
15
+ end
16
+
17
+ def matches?(block)
18
+ execute_block(block) if block.respond_to?(:call)
19
+ matching_metric?
20
+ end
21
+
22
+ def of_type(kind)
23
+ @kind = kind
24
+ self
25
+ end
26
+
27
+ def with_attributes(attributes)
28
+ @filters << ->(dp) { attributes_match?(dp.attributes || {}, attributes) }
29
+ self
30
+ end
31
+
32
+ def without_attributes(attributes)
33
+ @filters << ->(dp) { !attributes_match?(dp.attributes || {}, attributes) }
34
+ self
35
+ end
36
+
37
+ def with_value(value)
38
+ @filters << lambda { |dp|
39
+ raise ArgumentError, 'with_value is not supported for histogram data points' unless dp.respond_to?(:value)
40
+
41
+ dp.value == value
42
+ }
43
+ self
44
+ end
45
+
46
+ def with_count(count)
47
+ @filters << lambda { |dp|
48
+ raise ArgumentError, 'with_count is only supported for histogram data points' if dp.respond_to?(:value)
49
+
50
+ dp.count == count
51
+ }
52
+ self
53
+ end
54
+
55
+ def failure_message
56
+ closest = @closest_metric || new_snapshots.first
57
+ if closest.nil?
58
+ "expected metric #{printable_name} to have been emitted, but no metrics were emitted at all"
59
+ elsif @emitted_outside_block
60
+ "expected metric #{printable_name} to have been emitted within the block, but it was already emitted before"
61
+ else
62
+ "expected metric #{printable_name} to have been emitted, but it couldn't be found. " \
63
+ "Found a close matching metric named `#{closest.name}`#{MetricDetails.new(closest)}"
64
+ end
65
+ end
66
+
67
+ def failure_message_when_negated
68
+ "expected metric #{printable_name} to not have been emitted"
69
+ end
70
+
71
+ def supports_block_expectations?
72
+ true
73
+ end
74
+
75
+ private
76
+
77
+ def execute_block(block)
78
+ RspecOtel.metric_exporter.pull
79
+ @pre_snapshot = build_pre_snapshot(RspecOtel.metric_exporter.metric_snapshots)
80
+ @before_count = RspecOtel.metric_exporter.metric_snapshots.length
81
+ block.call
82
+ RspecOtel.metric_exporter.pull
83
+ end
84
+
85
+ def matching_metric?
86
+ new_snapshots.each do |metric_data|
87
+ next unless name_matches?(metric_data.name)
88
+ next unless kind_matches?(metric_data.instrument_kind)
89
+
90
+ return true if matching_data_point?(metric_data)
91
+
92
+ @closest_metric ||= metric_data
93
+ end
94
+
95
+ false
96
+ end
97
+
98
+ def matching_data_point?(metric_data)
99
+ any_changed = false
100
+ metric_data.data_points.each do |data_point|
101
+ next unless data_point_changed?(metric_data.name, metric_data.instrument_kind, data_point)
102
+
103
+ any_changed = true
104
+ return true if all_filters_match?(metric_data, data_point)
105
+ end
106
+ @emitted_outside_block ||= !any_changed
107
+ false
108
+ end
109
+
110
+ def all_filters_match?(metric_data, data_point)
111
+ count = @filters.count { |f| f.call(data_point) }
112
+ if count > @closest_filter_count
113
+ @closest_metric = metric_data
114
+ @closest_filter_count = count
115
+ end
116
+ count == @filters.length
117
+ end
118
+
119
+ def data_point_changed?(metric_name, instrument_kind, data_point)
120
+ pre = @pre_snapshot.dig(metric_name, data_point.attributes)
121
+ return true if pre.nil?
122
+ return false if observable_instrument?(instrument_kind)
123
+
124
+ pre != data_point_magnitude(data_point)
125
+ end
126
+
127
+ def observable_instrument?(instrument_kind)
128
+ %i[observable_counter observable_gauge observable_up_down_counter].include?(instrument_kind)
129
+ end
130
+
131
+ def data_point_magnitude(data_point)
132
+ data_point.respond_to?(:value) ? data_point.value : data_point.count
133
+ end
134
+
135
+ def build_pre_snapshot(snapshots)
136
+ # metric_snapshots accumulates across pulls; if the same metric name appears multiple
137
+ # times, to_h keeps the last entry — which is the most recent (and correct) baseline.
138
+ snapshots.to_h do |metric_data|
139
+ [metric_data.name, metric_data.data_points.to_h { |dp| [dp.attributes, data_point_magnitude(dp)] }]
140
+ end
141
+ end
142
+
143
+ def new_snapshots
144
+ RspecOtel.metric_exporter.metric_snapshots[@before_count..]
145
+ end
146
+
147
+ def kind_matches?(instrument_kind)
148
+ @kind.nil? || instrument_kind == @kind
149
+ end
150
+
151
+ def name_matches?(metric_name)
152
+ case @name
153
+ when String then metric_name == @name
154
+ when Regexp then metric_name.match?(@name)
155
+ end
156
+ end
157
+
158
+ def printable_name
159
+ case @name
160
+ when String then "'#{@name}'"
161
+ when Regexp then @name.inspect
162
+ end
163
+ end
164
+
165
+ def attributes_match?(actual, expected)
166
+ expected.all? { |k, v| actual[k] == v }
167
+ end
168
+ end
169
+ end
170
+ end
@@ -2,7 +2,7 @@
2
2
 
3
3
  module RspecOtel
4
4
  module Matchers
5
- class EmitSpan # rubocop:disable Metrics/ClassLength
5
+ class EmitSpan
6
6
  attr_reader :name
7
7
 
8
8
  def initialize(name = nil)
@@ -14,95 +14,55 @@ module RspecOtel
14
14
  @filters << name_filter
15
15
  end
16
16
 
17
- def matches?(block) # rubocop:disable Metrics/MethodLength
18
- if block.respond_to?(:call)
19
- @before_spans = RspecOtel.exporter.finished_spans
20
- block.call
21
- end
22
-
23
- closest_count = 0
24
- (RspecOtel.exporter.finished_spans - @before_spans).each do |span|
25
- count = @filters.count { |f| f.call(span) }
26
- @closest_span = span if count > closest_count
27
- closest_count = count
28
- return true if count == @filters.count
29
- end
30
-
31
- false
17
+ def matches?(block)
18
+ capture_before_spans(block)
19
+ matching_span?
32
20
  end
33
21
 
34
22
  def as_child
35
- @filters << lambda do |span|
36
- span.parent_span_id && span.parent_span_id != OpenTelemetry::Trace::INVALID_SPAN_ID
37
- end
38
-
23
+ @filters << ->(span) { span.parent_span_id && span.parent_span_id != OpenTelemetry::Trace::INVALID_SPAN_ID }
39
24
  self
40
25
  end
41
26
 
42
27
  def as_root
43
- @filters << lambda do |span|
44
- span.parent_span_id == OpenTelemetry::Trace::INVALID_SPAN_ID
45
- end
46
-
28
+ @filters << ->(span) { span.parent_span_id == OpenTelemetry::Trace::INVALID_SPAN_ID }
47
29
  self
48
30
  end
49
31
 
50
32
  def with_attributes(attributes)
51
- @filters << lambda do |span|
52
- attributes_match?(span.attributes, attributes)
53
- end
54
-
33
+ @filters << ->(span) { attributes_match?(span.attributes, attributes) }
55
34
  self
56
35
  end
57
36
 
58
37
  def without_attributes(attributes)
59
- @filters << lambda do |span|
60
- !attributes_match?(span.attributes, attributes)
61
- end
62
-
38
+ @filters << ->(span) { !attributes_match?(span.attributes, attributes) }
63
39
  self
64
40
  end
65
41
 
66
42
  def with_link(attributes = {})
67
- @filters << lambda do |span|
68
- span.links &&
69
- link_match?(span.links, attributes)
70
- end
71
-
43
+ @filters << ->(span) { span.links && link_match?(span.links, attributes) }
72
44
  self
73
45
  end
74
46
 
75
47
  def without_link(attributes = {})
76
- @filters << lambda do |span|
77
- span.links.nil? ||
78
- !link_match?(span.links, attributes)
79
- end
80
-
48
+ @filters << ->(span) { span.links.nil? || !link_match?(span.links, attributes) }
81
49
  self
82
50
  end
83
51
 
84
52
  def with_event(name, attributes = {})
85
- @filters << lambda do |span|
86
- span.events &&
87
- event_match?(span.events, OpenTelemetry::SDK::Trace::Event.new(name, attributes))
88
- end
89
-
53
+ event = OpenTelemetry::SDK::Trace::Event.new(name, attributes)
54
+ @filters << ->(span) { span.events && event_match?(span.events, event) }
90
55
  self
91
56
  end
92
57
 
93
58
  def without_event(name, attributes = {})
94
- @filters << lambda do |span|
95
- span.events.nil? ||
96
- !event_match?(span.events, OpenTelemetry::SDK::Trace::Event.new(name, attributes))
97
- end
98
-
59
+ event = OpenTelemetry::SDK::Trace::Event.new(name, attributes)
60
+ @filters << ->(span) { span.events.nil? || !event_match?(span.events, event) }
99
61
  self
100
62
  end
101
63
 
102
64
  def with_status(code, description)
103
- @filters << lambda do |span|
104
- status_match?(span.status, code, description)
105
- end
65
+ @filters << ->(span) { status_match?(span.status, code, description) }
106
66
  self
107
67
  end
108
68
 
@@ -116,15 +76,12 @@ module RspecOtel
116
76
 
117
77
  def failure_message
118
78
  closest = closest_span
119
- expect_content = "expected span #{failure_match_description} #{printable_name} to have been emitted"
79
+ prefix = "expected span #{failure_match_description} #{printable_name} to have been emitted"
120
80
 
121
81
  case closest
122
- when nil
123
- "#{expect_content}, but there were no spans emitted at all"
124
- when OpenTelemetry::SDK::Trace::SpanData
125
- "#{expect_content}, but it couldn't be found. Found a close matching span named `#{closest.name}`"
126
- else
127
- raise "I don't know what to do with a #{closest.class} span"
82
+ when nil then "#{prefix}, but there were no spans emitted at all"
83
+ when OpenTelemetry::SDK::Trace::SpanData then closest_not_found_message(prefix, closest)
84
+ else raise "I don't know what to do with a #{closest.class} span"
128
85
  end
129
86
  end
130
87
 
@@ -138,6 +95,24 @@ module RspecOtel
138
95
 
139
96
  private
140
97
 
98
+ def capture_before_spans(block)
99
+ return unless block.respond_to?(:call)
100
+
101
+ @before_spans = RspecOtel.exporter.finished_spans
102
+ block.call
103
+ end
104
+
105
+ def matching_span?
106
+ closest_count = 0
107
+ (RspecOtel.exporter.finished_spans - @before_spans).each do |span|
108
+ count = @filters.count { |f| f.call(span) }
109
+ @closest_span = span if count > closest_count
110
+ closest_count = count
111
+ return true if count == @filters.count
112
+ end
113
+ false
114
+ end
115
+
141
116
  def closest_span
142
117
  return @closest_span unless @closest_span.nil?
143
118
 
@@ -219,6 +194,11 @@ module RspecOtel
219
194
 
220
195
  !link.empty?
221
196
  end
197
+
198
+ def closest_not_found_message(prefix, span)
199
+ "#{prefix}, but it couldn't be found. " \
200
+ "Found a close matching span named `#{span.name}`#{SpanDetails.new(span)}"
201
+ end
222
202
  end
223
203
  end
224
204
  end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RspecOtel
4
+ module Matchers
5
+ class MetricDetails
6
+ def initialize(metric)
7
+ @metric = metric
8
+ end
9
+
10
+ def to_s
11
+ "\n#{[type_details, data_points_details].compact.join("\n")}"
12
+ end
13
+
14
+ private
15
+
16
+ def type_details
17
+ " type: #{@metric.instrument_kind}"
18
+ end
19
+
20
+ def data_points_details
21
+ return unless @metric.data_points&.any?
22
+
23
+ format_collection('data_points', @metric.data_points) { |dp| format_data_point(dp) }
24
+ end
25
+
26
+ def format_collection(header, collection, &)
27
+ item_lines = collection.map(&)
28
+ " #{header}:\n#{item_lines.join("\n")}"
29
+ end
30
+
31
+ def format_data_point(data_point)
32
+ magnitude_label = data_point.respond_to?(:value) ? "value: #{data_point.value}" : "count: #{data_point.count}"
33
+ attrs = data_point.attributes
34
+ attrs&.any? ? " - #{magnitude_label} #{attrs.inspect}" : " - #{magnitude_label}"
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RspecOtel
4
+ module Matchers
5
+ class SpanDetails
6
+ def initialize(span)
7
+ @span = span
8
+ end
9
+
10
+ def to_s
11
+ lines = [attributes_details, events_details, links_details, status_details].compact
12
+ return '' if lines.empty?
13
+
14
+ "\n#{lines.join("\n")}"
15
+ end
16
+
17
+ private
18
+
19
+ def attributes_details
20
+ return unless @span.attributes&.any?
21
+
22
+ " attributes: #{@span.attributes.inspect}"
23
+ end
24
+
25
+ def events_details
26
+ return unless @span.events&.any?
27
+
28
+ format_collection('events', @span.events) { |e| format_item(e.name, e.attributes) }
29
+ end
30
+
31
+ def links_details
32
+ return unless @span.links&.any?
33
+
34
+ format_collection('links', @span.links) { |l| format_item('link', l.attributes) }
35
+ end
36
+
37
+ def status_details
38
+ return if @span.status.nil? || @span.status.code == OpenTelemetry::Trace::Status::UNSET
39
+
40
+ label = status_label(@span.status.code)
41
+ desc = @span.status.description
42
+ status_str = desc.to_s.empty? ? label : "#{label} (#{desc})"
43
+ " status: #{status_str}"
44
+ end
45
+
46
+ def format_collection(header, collection, &)
47
+ item_lines = collection.map(&)
48
+ " #{header}:\n#{item_lines.join("\n")}"
49
+ end
50
+
51
+ def format_item(label, attributes)
52
+ attributes&.any? ? " - #{label} #{attributes.inspect}" : " - #{label}"
53
+ end
54
+
55
+ def status_label(code)
56
+ case code
57
+ when OpenTelemetry::Trace::Status::OK then 'ok'
58
+ when OpenTelemetry::Trace::Status::ERROR then 'error'
59
+ else 'unknown'
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
@@ -5,7 +5,14 @@ module RspecOtel
5
5
  def emit_span(name)
6
6
  EmitSpan.new(name)
7
7
  end
8
+
9
+ def emit_metric(name)
10
+ EmitMetric.new(name)
11
+ end
8
12
  end
9
13
  end
10
14
 
11
15
  require 'rspec_otel/matchers/emit_span'
16
+ require 'rspec_otel/matchers/emit_metric'
17
+ require 'rspec_otel/matchers/span_details'
18
+ require 'rspec_otel/matchers/metric_details'
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RspecOtel
4
- VERSION = '0.0.7'
4
+ VERSION = '0.1.0'
5
5
  end
data/lib/rspec_otel.rb CHANGED
@@ -2,12 +2,17 @@
2
2
 
3
3
  require 'opentelemetry/sdk'
4
4
  require 'opentelemetry-test-helpers'
5
+ require 'opentelemetry-metrics-sdk'
5
6
 
6
7
  module RspecOtel
7
8
  def self.exporter
8
9
  @exporter ||= OpenTelemetry::SDK::Trace::Export::InMemorySpanExporter.new
9
10
  end
10
11
 
12
+ def self.metric_exporter
13
+ @metric_exporter ||= OpenTelemetry::SDK::Metrics::Export::InMemoryMetricPullExporter.new
14
+ end
15
+
11
16
  def self.record
12
17
  span_processor = OpenTelemetry::SDK::Trace::Export::SimpleSpanProcessor.new(exporter)
13
18
 
@@ -15,6 +20,10 @@ module RspecOtel
15
20
  c.add_span_processor span_processor
16
21
  end
17
22
 
23
+ meter_provider = OpenTelemetry::SDK::Metrics::MeterProvider.new
24
+ meter_provider.add_metric_reader(metric_exporter)
25
+ OpenTelemetry.meter_provider = meter_provider
26
+
18
27
  yield
19
28
  ensure
20
29
  reset
@@ -22,7 +31,9 @@ module RspecOtel
22
31
 
23
32
  def self.reset
24
33
  OpenTelemetry::TestHelpers.reset_opentelemetry
34
+ OpenTelemetry.meter_provider = OpenTelemetry::Internal::ProxyMeterProvider.new
25
35
  @exporter = nil
36
+ @metric_exporter = nil
26
37
  end
27
38
  end
28
39
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rspec-otel
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.7
4
+ version: 0.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Damien MATHIEU
@@ -9,6 +9,20 @@ bindir: bin
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: logger
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '0'
12
26
  - !ruby/object:Gem::Dependency
13
27
  name: opentelemetry-api
14
28
  requirement: !ruby/object:Gem::Requirement
@@ -23,6 +37,20 @@ dependencies:
23
37
  - - "~>"
24
38
  - !ruby/object:Gem::Version
25
39
  version: '1.0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: opentelemetry-metrics-sdk
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '0.12'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '0.12'
26
54
  - !ruby/object:Gem::Dependency
27
55
  name: opentelemetry-sdk
28
56
  requirement: !ruby/object:Gem::Requirement
@@ -76,7 +104,10 @@ files:
76
104
  - README.md
77
105
  - lib/rspec_otel.rb
78
106
  - lib/rspec_otel/matchers.rb
107
+ - lib/rspec_otel/matchers/emit_metric.rb
79
108
  - lib/rspec_otel/matchers/emit_span.rb
109
+ - lib/rspec_otel/matchers/metric_details.rb
110
+ - lib/rspec_otel/matchers/span_details.rb
80
111
  - lib/rspec_otel/rspec.rb
81
112
  - lib/rspec_otel/version.rb
82
113
  homepage: https://github.com/dmathieu/rspec-otel
@@ -98,7 +129,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
98
129
  - !ruby/object:Gem::Version
99
130
  version: '0'
100
131
  requirements: []
101
- rubygems_version: 3.6.9
132
+ rubygems_version: 4.0.6
102
133
  specification_version: 4
103
134
  summary: RSpec matchers for the OpenTelemetry framework
104
135
  test_files: []