yes-core 2.4.1 → 2.4.3

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: 32cf1150f338458f687fbda0d60e3a30625636b4664d056d5ff4ebf939f4731b
4
- data.tar.gz: 6984f21a84c0053ba4c1c6e38560f10c07d024ec0305734d2146a487ec8ab2a2
3
+ metadata.gz: 70e0c755ba6b965b16e2331495e59061de1ed745c80bcd4cb4d6da4a5e8cdd8b
4
+ data.tar.gz: fe6b3f3e8600ecb1c0558850eef7302d1d938296c391c7539c1caa5de69135d8
5
5
  SHA512:
6
- metadata.gz: 3d9909b27209da6ddc3bcf943d6c255cec3fcec1951eed981c9f84dcd80314dc6e4f704b3bdaad0fe464b81d5adf31bf66a9828178cf806bdc75542c921e398a
7
- data.tar.gz: 966d09cc084b81bcef2c4863ebe3419dab9783f360297eecd532652a70deb8a45b7c89f7b167acf176dbf716f832ed5ce30c173ac202b5456f5ac23d1ac33146
6
+ metadata.gz: 2c0ef42cdb28eb261828d7eb189e03b22fdb154294145aa7966f63fe9ae3f2dcdacef5c6890037ef481420a7460ad02c1f234fcab4c8c1d79a6f1c46529448ee
7
+ data.tar.gz: fa787ad495b279695b2d1100fcf24237630fef1e426e617e0979231442751e8943893d14eaa3a0902d42580bfddd7fdad2b686f0d59e48f353642e9c10531b20
data/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## [2.4.3] - 2026-09-15
4
+ - See root CHANGELOG.md for details.
5
+
6
+ ## [2.4.2] - 2026-09-15
7
+ - See root CHANGELOG.md for details.
8
+
3
9
  ## [2.4.1] - 2026-09-07
4
10
  - See root CHANGELOG.md for details.
5
11
 
@@ -121,6 +121,8 @@ module Yes
121
121
  Yes::Core::Command::Invalid => e
122
122
  command_response_class(cmd).new(cmd: cmd, error: e, batch_id: cmd.batch_id)
123
123
  end
124
+ ensure
125
+ Yes::Core::OpenTelemetry::OtlSpan.record_retries(retries)
124
126
  end
125
127
 
126
128
  private
@@ -69,6 +69,8 @@ module Yes
69
69
  clear_pending_update_state if aggregate.class.read_model_enabled?
70
70
  Yes::Core::Commands::CommandGroupResponse.new(cmd:, error: e)
71
71
  end
72
+ ensure
73
+ Yes::Core::OpenTelemetry::OtlSpan.record_retries(retries)
72
74
  end
73
75
 
74
76
  private
@@ -35,6 +35,7 @@ module Yes
35
35
  def call(group_name, payload, guards: true, metadata: nil)
36
36
  prepared = prepare_payload(payload, metadata)
37
37
  cmd = command_utilities.build_group_command(group_name, prepared)
38
+ otl_record_command(cmd)
38
39
  guard_evaluator_class = command_utilities.fetch_guard_evaluator_class_for_group(group_name)
39
40
 
40
41
  ReadModelRecoveryService.check_and_recover_with_retries(read_model, aggregate:) if aggregate.class.read_model_enabled?
@@ -49,6 +50,17 @@ module Yes
49
50
 
50
51
  attr_reader :aggregate, :command_utilities, :read_model
51
52
 
53
+ # Records which command group the span ran; see CommandHandler#otl_record_command.
54
+ #
55
+ # @param cmd [Object] the built command group
56
+ # @return [void]
57
+ def otl_record_command(cmd)
58
+ name = cmd.class.name
59
+ return if name.nil?
60
+
61
+ self.class.current_span&.add_attributes({ CommandHandler::COMMAND_ATTRIBUTE => name })
62
+ end
63
+
52
64
  # Prepares the payload before constructing the group command.
53
65
  # Mirrors the metadata-injection logic in {CommandHandler#prepare_payload}.
54
66
  #
@@ -11,6 +11,10 @@ module Yes
11
11
  # response = handler.call(:approve_documents, { document_ids: '123', another: 'value' })
12
12
  #
13
13
  class CommandHandler
14
+ # Span attribute naming the command that ran. The span keeps a fixed name so its
15
+ # latency histogram stays one series per service; this is how the command is identified.
16
+ COMMAND_ATTRIBUTE = 'command'
17
+
14
18
  include Yes::Core::OpenTelemetry::Trackable
15
19
 
16
20
  # Initializes a new CommandHandler
@@ -32,6 +36,7 @@ module Yes
32
36
  def call(command_name, payload, guards: true, metadata: nil)
33
37
  prepared_payload = prepare_payload(command_name, payload, metadata)
34
38
  cmd = command_utilities.build_command(command_name, prepared_payload)
39
+ otl_record_command(cmd)
35
40
 
36
41
  guard_evaluator_class = command_utilities.fetch_guard_evaluator_class(command_name)
37
42
 
@@ -51,6 +56,18 @@ module Yes
51
56
 
52
57
  attr_reader :aggregate, :command_utilities, :read_model
53
58
 
59
+ # Records which command the span ran. The span itself keeps a fixed name so its latency
60
+ # histogram stays a single series per service; the command goes on an attribute instead.
61
+ #
62
+ # @param cmd [Object] the built command
63
+ # @return [void]
64
+ def otl_record_command(cmd)
65
+ name = cmd.class.name
66
+ return if name.nil?
67
+
68
+ self.class.current_span&.add_attributes({ COMMAND_ATTRIBUTE => name })
69
+ end
70
+
54
71
  # Prepares the command payload
55
72
  #
56
73
  # @param command_name [Symbol] The command name
@@ -59,7 +59,10 @@ module Yes
59
59
 
60
60
  otl_trackable(
61
61
  :call,
62
- Yes::Core::OpenTelemetry::OtlSpan::OtlData.new(span_name: 'Publish Event', span_kind: :producer, track_sql: true)
62
+ Yes::Core::OpenTelemetry::OtlSpan::OtlData.new(
63
+ span_name: 'Publish Event', span_kind: :producer, track_sql: true,
64
+ tolerated_errors: Yes::Core::OpenTelemetry::OtlSpan::REVISION_CONFLICT
65
+ )
63
66
  )
64
67
 
65
68
  private
@@ -122,7 +122,10 @@ module Yes
122
122
  middlewares: Middlewares.for_write
123
123
  ).tap { otl_record_response(_1) }
124
124
  end
125
- otl_trackable :publish_event, OpenTelemetry::OtlSpan::OtlData.new(span_name: 'Publish Event', span_kind: :producer)
125
+ otl_trackable :publish_event, OpenTelemetry::OtlSpan::OtlData.new(
126
+ span_name: 'Publish Event', span_kind: :producer,
127
+ tolerated_errors: OpenTelemetry::OtlSpan::REVISION_CONFLICT
128
+ )
126
129
 
127
130
  private
128
131
 
@@ -208,7 +211,6 @@ module Yes
208
211
  PgEventstore::WrongExpectedRevisionError.new(
209
212
  revision:, expected_revision:, stream:, verdict: :unmatched_stream_revision
210
213
  ).tap do |error|
211
- self.class.current_span&.status = ::OpenTelemetry::Trace::Status.error('Wrong expected revision')
212
214
  self.class.current_span&.add_attributes(
213
215
  {
214
216
  current_revision: revision,
@@ -9,15 +9,29 @@ module Yes
9
9
  # span = OtlSpan.new(otl_data: OtlData.new(span_name: 'MySpan'), otl_tracer: tracer)
10
10
  # span.otl_span(arg1, arg2) { do_work }
11
11
  class OtlSpan
12
+ # Span attribute that names the tolerated error a span ended with (see {OtlData#tolerated_errors}).
13
+ OUTCOME_ATTRIBUTE = 'outcome'
14
+
15
+ # Span attribute with the number of retries a command needed before it settled.
16
+ RETRIES_ATTRIBUTE = 'retries'
17
+
18
+ # A revision conflict is an optimistic-concurrency retry signal, not a failure: the
19
+ # command is re-run and almost always succeeds. Publishing spans tolerate it so the
20
+ # span status stays clean and the conflict is countable on its own.
21
+ REVISION_CONFLICT = { PgEventstore::WrongExpectedRevisionError => 'revision_conflict' }.freeze
22
+
12
23
  # Configuration struct for OpenTelemetry span data
13
- OtlData = Struct.new(:span_name, :span_kind, :span_attributes, :links_extractor, :track_sql) do
24
+ OtlData = Struct.new(:span_name, :span_kind, :span_attributes, :links_extractor, :track_sql,
25
+ :tolerated_errors) do
14
26
  # @param span_name [String, nil] name of the span
15
27
  # @param span_kind [Symbol] kind of span (:internal, :client, :server, :producer, :consumer)
16
28
  # @param span_attributes [Hash] additional span attributes
17
29
  # @param links_extractor [Proc] extracts OTL context links from arguments
18
30
  # @param track_sql [Boolean] whether to track SQL queries within the span
31
+ # @param tolerated_errors [Hash{Class => String}] errors that are re-raised but do not mark the
32
+ # span as failed; the span gets the mapped value as its +outcome+ attribute instead
19
33
  def initialize(span_name: nil, span_kind: :internal, span_attributes: {}, links_extractor: proc { [] },
20
- track_sql: false)
34
+ track_sql: false, tolerated_errors: {})
21
35
  super
22
36
  end
23
37
  end
@@ -28,6 +42,17 @@ module Yes
28
42
  # @return [Object] OpenTelemetry tracer instance
29
43
  attr_reader :otl_tracer
30
44
 
45
+ # Stamps the number of retries a command needed on the span it is running in, so
46
+ # contention shows up per command and not only per publish attempt.
47
+ #
48
+ # @param retries [Integer] retries performed before the command settled
49
+ # @return [void]
50
+ def self.record_retries(retries)
51
+ return if retries.zero? || Yes::Core.configuration.otl_tracer.nil?
52
+
53
+ ::OpenTelemetry::Trace.current_span.set_attribute(RETRIES_ATTRIBUTE, retries)
54
+ end
55
+
31
56
  # @param otl_data [OtlData] span configuration
32
57
  # @param otl_tracer [Object] OpenTelemetry tracer instance
33
58
  def initialize(otl_data:, otl_tracer:)
@@ -37,50 +62,118 @@ module Yes
37
62
 
38
63
  # Creates a span and executes the given block within it.
39
64
  #
65
+ # A tolerated error (see {OtlData#tolerated_errors}) is recorded on the span as an exception
66
+ # event and as the +outcome+ attribute, but the span keeps a non-error status. The error is
67
+ # re-raised once the span has ended, so callers see exactly the same exception as before.
68
+ #
40
69
  # @param args [Array] positional arguments passed to the links extractor
41
70
  # @param kwargs [Hash] keyword arguments passed to the links extractor
42
71
  # @yield the block to execute within the span
43
72
  # @return [Object] the return value of the block
44
- def otl_span(*args, **kwargs, &block)
45
- links = otl_links(args, kwargs)
46
-
47
- parent_span = ::OpenTelemetry::Trace.current_span.context.valid? ? ::OpenTelemetry::Trace.current_span : nil
48
- root_track_sql = parent_span&.try(:attributes)&.[]('root_track_sql') ||
49
- parent_span&.try(:attributes)&.[]('track_sql')
73
+ def otl_span(*args, **kwargs, &)
74
+ parent_span = current_parent_span
75
+ root_track_sql = root_track_sql?(parent_span)
76
+ tolerated_error = nil
50
77
 
51
- otl_tracer.in_span(
78
+ result = otl_tracer.in_span(
52
79
  otl_data.span_name || 'UnknownName',
53
- links:,
80
+ links: otl_links(args, kwargs),
54
81
  kind: otl_data.span_kind,
55
- attributes: {
56
- 'track_sql' => otl_data.track_sql,
57
- 'root_track_sql' => root_track_sql || false
58
- }.merge(otl_data.span_attributes)
59
- ) do
60
- next unless block_given?
61
- next yield if !root_track_sql && !otl_data.track_sql
62
- next yield if parent_span.present? && root_track_sql
63
-
64
- callback = lambda do |sql_event|
65
- next if %w[SCHEMA TRANSACTION].include?(sql_event.payload[:name])
66
-
67
- otl_tracer.in_span("SQL #{sql_event.payload[:name]}") do |span|
68
- span.set_attribute('db.system', 'postgresql')
69
- span.set_attribute('db.statement', sql_event.payload[:sql])
70
- span.set_attribute('db.binds', sql_event.payload[:binds].map do |attr|
71
- next { name: attr.name, value: attr.value } if attr.respond_to?(:name) && attr.respond_to?(:value)
72
-
73
- { name: attr.class.to_s, value: attr }
74
- end.to_json)
75
- span.set_attribute('db.event_name', sql_event.payload[:name])
76
- end
77
- end
78
- ActiveSupport::Notifications.subscribed(callback, 'sql.active_record', &block)
82
+ attributes: span_attributes(root_track_sql)
83
+ ) do |span|
84
+ run_block(parent_span, root_track_sql, &)
85
+ rescue StandardError => e
86
+ tolerated_error = tolerate(span, e)
87
+ nil
79
88
  end
89
+
90
+ raise tolerated_error if tolerated_error
91
+
92
+ result
80
93
  end
81
94
 
82
95
  private
83
96
 
97
+ # @return [::OpenTelemetry::Trace::Span, nil] the span this one is created under, if any
98
+ def current_parent_span
99
+ span = ::OpenTelemetry::Trace.current_span
100
+ span.context.valid? ? span : nil
101
+ end
102
+
103
+ # @param parent_span [::OpenTelemetry::Trace::Span, nil] the enclosing span, if any
104
+ # @return [Boolean, nil] whether an enclosing span already tracks SQL
105
+ def root_track_sql?(parent_span)
106
+ parent_span&.try(:attributes)&.[]('root_track_sql') || parent_span&.try(:attributes)&.[]('track_sql')
107
+ end
108
+
109
+ # @param root_track_sql [Boolean, nil] whether an enclosing span already tracks SQL
110
+ # @return [Hash] the attributes the span starts with
111
+ def span_attributes(root_track_sql)
112
+ {
113
+ 'track_sql' => otl_data.track_sql,
114
+ 'root_track_sql' => root_track_sql || false
115
+ }.merge(otl_data.span_attributes)
116
+ end
117
+
118
+ # Runs the traced block, wrapping it in SQL tracking when the span asks for it.
119
+ #
120
+ # @param parent_span [OpenTelemetry::Trace::Span, nil] the enclosing span, if any
121
+ # @param root_track_sql [Boolean, nil] whether an enclosing span already tracks SQL
122
+ # @yield the block to execute
123
+ # @return [Object, nil] the return value of the block
124
+ def run_block(parent_span, root_track_sql, &)
125
+ return unless block_given?
126
+ return yield if !root_track_sql && !otl_data.track_sql
127
+ return yield if parent_span.present? && root_track_sql
128
+
129
+ ActiveSupport::Notifications.subscribed(sql_span_callback, 'sql.active_record', &)
130
+ end
131
+
132
+ # @return [Proc] records one child span per SQL statement executed inside the traced block
133
+ def sql_span_callback
134
+ lambda do |sql_event|
135
+ next if %w[SCHEMA TRANSACTION].include?(sql_event.payload[:name])
136
+
137
+ record_sql_span(sql_event.payload)
138
+ end
139
+ end
140
+
141
+ # @param payload [Hash] the +sql.active_record+ notification payload
142
+ # @return [void]
143
+ def record_sql_span(payload)
144
+ otl_tracer.in_span("SQL #{payload[:name]}") do |span|
145
+ span.set_attribute('db.system', 'postgresql')
146
+ span.set_attribute('db.statement', payload[:sql])
147
+ span.set_attribute('db.binds', sql_binds(payload[:binds]))
148
+ span.set_attribute('db.event_name', payload[:name])
149
+ end
150
+ end
151
+
152
+ # @param binds [Array] the statement's bind parameters
153
+ # @return [String] the binds as JSON, one +name+/+value+ pair each
154
+ def sql_binds(binds)
155
+ binds.map do |attr|
156
+ next { name: attr.name, value: attr.value } if attr.respond_to?(:name) && attr.respond_to?(:value)
157
+
158
+ { name: attr.class.to_s, value: attr }
159
+ end.to_json
160
+ end
161
+
162
+ # Records a tolerated error on the span without failing it.
163
+ #
164
+ # @param span [OpenTelemetry::Trace::Span] the span the error was raised in
165
+ # @param error [StandardError] the error raised by the traced block
166
+ # @return [StandardError] the error, to be re-raised once the span has ended
167
+ # @raise [StandardError] the error itself when it is not tolerated
168
+ def tolerate(span, error)
169
+ outcome = otl_data.tolerated_errors.find { |klass, _outcome| error.is_a?(klass) }&.last
170
+ raise error unless outcome
171
+
172
+ span.record_exception(error)
173
+ span.set_attribute(OUTCOME_ATTRIBUTE, outcome)
174
+ error
175
+ end
176
+
84
177
  # Extracts OpenTelemetry links from arguments using the configured links_extractor.
85
178
  #
86
179
  # @param args [Array] positional arguments
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Yes
4
4
  module Core
5
- VERSION = '2.4.1'
5
+ VERSION = '2.4.3'
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: yes-core
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.4.1
4
+ version: 2.4.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nico Ritsche