brut 0.0.9 → 0.0.11

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: b455ac9d87f61b5baa45cc6f4cc11c3b940b015aaf7792ba5ed8229dfd23d2fc
4
- data.tar.gz: 5e6216b69db294da81ed31041a4b65a4a2b04de229e3d8695e5c9e1ad1625a84
3
+ metadata.gz: 75470f9c0becdf6066db6bf7d4a53f170e783e2e9a664cc5eef779d977427c3a
4
+ data.tar.gz: 70006b0aba279306c4f7f99bde119b2d348a55b383179b70955240e5bc39be01
5
5
  SHA512:
6
- metadata.gz: 688aba783c6b979976ddf40417a6220a10295effef357aa9ebc0b1cd772fac759cae4ddaa2ce09c5bb4b1559d65ee04261d57a3c4253559b7f22a4c9dfffa42b
7
- data.tar.gz: 67dbc5b31bf1c6e02f4f5b505d3f69e1eb084f862ccb78372377a3b49052e71711af4868eec6c5fd3bc68a4994bc10bbecb5ce42a260677c6a42c765f8a21e0a
6
+ metadata.gz: 6cfdd32b6fe7595d3a8215d5ed5a9bc8ec33f6093284fbf82be365c2bd7ca2a1e92695ab63a0cb629424ff0877f726647b1853433ab620f694a8de9b130fd852
7
+ data.tar.gz: 2487a01fa0138416af5821c4699c68febd1bffb16be906e53cc2b39c0315f6cf0fc26c87d20b23de975eaef52b683ae495a8ab09b21b944f074e78db7adeb5cb
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- brut (0.0.9)
4
+ brut (0.0.11)
5
5
  concurrent-ruby
6
6
  i18n
7
7
  irb
data/lib/brut/cli/app.rb CHANGED
@@ -211,7 +211,7 @@ class Brut::CLI::App
211
211
  if self.class.configure_only?
212
212
  as_execution_result(command.execute)
213
213
  else
214
- result = Brut.container.instrumentation.span("CLI.#{$0}", class: self.class.name) do |span|
214
+ result = Brut.container.instrumentation.span("CLI #{$0}", prefix: "brut.cli", class: self.class.name) do |span|
215
215
  as_execution_result(command.execute)
216
216
  end
217
217
  result
@@ -13,12 +13,10 @@ class Brut::CLI::Apps::DB < Brut::CLI::App
13
13
  def handle_bootstrap_exception(ex)
14
14
  case ex
15
15
  when Sequel::DatabaseConnectionError
16
- err.puts "Database needs to be created"
17
- stop_execution
16
+ abort_execution("Database needs to be created")
18
17
  when Sequel::DatabaseError
19
18
  if ex.cause.kind_of?(PG::UndefinedTable)
20
- err.puts "Migrations need to be run"
21
- stop_execution
19
+ abort_execution("Migrations need to be run")
22
20
  else
23
21
  super
24
22
  end
@@ -87,6 +85,7 @@ class Brut::CLI::Apps::DB < Brut::CLI::App
87
85
  stop_execution
88
86
  when Sequel::DatabaseError
89
87
  if ex.cause.kind_of?(PG::UndefinedTable)
88
+ out.puts ex.message
90
89
  out.puts "Migrations need to be run"
91
90
  continue_execution
92
91
  else
@@ -151,8 +150,7 @@ class Brut::CLI::Apps::DB < Brut::CLI::App
151
150
  def handle_bootstrap_exception(ex)
152
151
  case ex
153
152
  when Sequel::DatabaseConnectionError
154
- err.puts "Database does not exist. Create it first"
155
- stop_execution
153
+ abort_execution("Database does not exist. Create it first")
156
154
  when Sequel::DatabaseError
157
155
  if ex.cause.kind_of?(PG::UndefinedTable)
158
156
  # ignoring - we are running migrations which will address this
@@ -47,11 +47,11 @@ class Brut::CLI::Apps::Test < Brut::CLI::App
47
47
  end
48
48
  Brut.container.instrumentation.span("tests.run") do |span|
49
49
  if args.empty?
50
- span.add_attributes(tests: :all)
50
+ span.add_prefixed_attributes("brut.cli.test", tests: :all)
51
51
  out.puts "Running all tests"
52
52
  system! "#{rspec_command} #{Brut.container.app_specs_dir}/"
53
53
  else
54
- span.add_attributes(tests: args.length)
54
+ span.add_prefixed_attributes("brut.cli.test", tests: args.length)
55
55
  test_args = args.map { |_|
56
56
  '"' + Shellwords.escape(_) + '"'
57
57
  }.join(" ")
data/lib/brut/cli.rb CHANGED
@@ -26,12 +26,7 @@ module Brut
26
26
  # @param project_root [Pathname] the path to the root of your project. This is needed before the Brut framework is initialized so
27
27
  # it must be specified explicitly.
28
28
  def self.app(app_klass, project_root:)
29
- Brut::CLI::AppRunner.new(app_klass:,project_root:).run!.tap {
30
- otel_configured = OpenTelemetry.tracer_provider.is_a?(OpenTelemetry::SDK::Trace::TracerProvider)
31
- if otel_configured
32
- OpenTelemetry.tracer_provider.shutdown
33
- end
34
- }
29
+ Brut::CLI::AppRunner.new(app_klass:,project_root:).run!
35
30
  end
36
31
  autoload(:App, "brut/cli/app")
37
32
  autoload(:Command, "brut/cli/command")
@@ -17,6 +17,9 @@ require "opentelemetry/exporter/otlp"
17
17
  # intended to use or interact with this class at all. End of line.
18
18
  class Brut::Framework::MCP
19
19
 
20
+ @otel_shutdown = Concurrent::AtomicBoolean.new(false)
21
+ def self.otel_shutdown = @otel_shutdown
22
+
20
23
  # Create and configure the MCP. The app will not work until {#boot!} has been called, however most of the core configuration
21
24
  # will be available via `Brut.container`.
22
25
  #
@@ -50,6 +53,17 @@ class Brut::Framework::MCP
50
53
  setup_zeitwerk
51
54
 
52
55
  @app = app_klass.new
56
+ otel_prefix = if @app.id == @app.organization
57
+ @app.id
58
+ else
59
+ "#{@app.organization}.#{@app.id}"
60
+ end
61
+ Brut.container.store(
62
+ "otel_attribute_prefix",
63
+ "String",
64
+ "Prefix for all OTel attributes set by the app",
65
+ otel_prefix
66
+ )
53
67
  end
54
68
 
55
69
  # Starts up the internals of Brut and that app so that it can receive requests from
@@ -66,58 +80,34 @@ class Brut::Framework::MCP
66
80
  begin
67
81
  Brut.container.sequel_db_handle.disconnect
68
82
  rescue Sequel::DatabaseConnectionError
69
- SemanticLogger["Sequel::Database"].info "Not connected to database, so not disconnecting"
83
+ SemanticLogger[self.class].info "Not connected to database, so not disconnecting"
84
+ end
85
+ otel_configured = OpenTelemetry.tracer_provider.is_a?(OpenTelemetry::SDK::Trace::TracerProvider)
86
+ if otel_configured
87
+ if $PROGRAM_NAME =~ /^sidekiq/
88
+ SemanticLogger[self.class].info "Assuming we are sidekiq, which will shutdown OpenTelemetry, so doing nothing", program: $PROGRAM_NAME
89
+ else
90
+ if self.class.otel_shutdown.make_true
91
+ SemanticLogger[self.class].info "Shutting down OpenTelemetry", program: $PROGRAM_NAME
92
+ OpenTelemetry.tracer_provider.shutdown
93
+ else
94
+ SemanticLogger[self.class].info "OpenTelemetry already shutdown", program: $PROGRAM_NAME
95
+ end
96
+ end
97
+ else
98
+ SemanticLogger[self.class].info "OpenTelemetry was not configured, so no shutdown needed", program: $PROGRAM_NAME
70
99
  end
71
100
  end
72
- Sequel::Database.extension :pg_array
73
- Sequel::Database.extension :pg_json
74
-
75
- sequel_db = Brut.container.sequel_db_handle
76
-
77
- Sequel::Model.db = sequel_db
78
101
 
79
- Sequel::Model.plugin :find_bang
80
- Sequel::Model.plugin :created_at
81
- Sequel::Model.plugin :table_select
82
- Sequel::Model.plugin :skip_saving_columns
102
+ boot_otel!
103
+ boot_postgres!
83
104
 
84
- if !Brut.container.external_id_prefix.nil?
85
- Sequel::Model.plugin :external_id, global_prefix: Brut.container.external_id_prefix
86
- end
87
105
  if Brut.container.eager_load_classes?
88
106
  SemanticLogger["Brut"].info("Eagerly loading app's classes")
89
107
  @loader.eager_load
90
108
  else
91
109
  SemanticLogger["Brut"].info("Lazily loading app's classes")
92
110
  end
93
- OpenTelemetry::SDK.configure do |c|
94
- c.service_name = @app.id
95
- if ENV["OTEL_TRACES_EXPORTER"]
96
- SemanticLogger[self.class].info "OTEL_TRACES_EXPORTER was set (to '#{ENV['OTEL_TRACES_EXPORTER']}'), so Brut's OTel logging is disabled"
97
- else
98
- c.add_span_processor(
99
- OpenTelemetry::SDK::Trace::Export::SimpleSpanProcessor.new(
100
- Brut::Instrumentation::LoggerSpanExporter.new
101
- )
102
- )
103
- end
104
-
105
- if defined?(OpenTelemetry::Instrumentation::Sidekiq)
106
- c.use 'OpenTelemetry::Instrumentation::Sidekiq', {
107
- span_naming: :job_class,
108
- }
109
- else
110
- SemanticLogger[self.class].info "OpenTelemetry::Instrumentation::Sidekiq is not loaded, so Sidekiq traces will not be captured"
111
- end
112
- end
113
-
114
- Brut.container.store(
115
- "tracer",
116
- OpenTelemetry::SDK::Trace::Tracer,
117
- "Tracer for Open Telemetry",
118
- OpenTelemetry.tracer_provider.tracer(@app.id)
119
- )
120
- Sequel::Database.extension :brut_instrumentation
121
111
 
122
112
  @app.boot!
123
113
 
@@ -215,7 +205,7 @@ class Brut::Framework::MCP
215
205
  hook = klass.new
216
206
  span.add_prefixed_attributes("#{method}.args",args.map { |k,v| [ k,v.class] }.to_h )
217
207
  result = hook.send(method,**args)
218
- span.add_attributes(result:)
208
+ span.add_prefixed_attributes("brut", result_class: result.class)
219
209
  case result
220
210
  in URI => uri
221
211
  redirect to(uri.to_s)
@@ -304,4 +294,53 @@ private
304
294
 
305
295
  @loader.setup
306
296
  end
297
+
298
+ def boot_postgres!
299
+ Sequel::Database.extension :pg_array
300
+ Sequel::Database.extension :pg_json
301
+
302
+ sequel_db = Brut.container.sequel_db_handle
303
+ sequel_db.extension :brut_instrumentation
304
+
305
+ Sequel::Model.db = sequel_db
306
+
307
+ Sequel::Model.plugin :find_bang
308
+ Sequel::Model.plugin :created_at
309
+ Sequel::Model.plugin :table_select
310
+ Sequel::Model.plugin :skip_saving_columns
311
+
312
+ if !Brut.container.external_id_prefix.nil?
313
+ Sequel::Model.plugin :external_id, global_prefix: Brut.container.external_id_prefix
314
+ end
315
+ end
316
+
317
+ def boot_otel!
318
+ OpenTelemetry::SDK.configure do |c|
319
+ c.service_name = @app.id
320
+ if ENV["OTEL_TRACES_EXPORTER"]
321
+ SemanticLogger[self.class].info "OTEL_TRACES_EXPORTER was set (to '#{ENV['OTEL_TRACES_EXPORTER']}'), so Brut's OTel logging is disabled"
322
+ else
323
+ c.add_span_processor(
324
+ OpenTelemetry::SDK::Trace::Export::SimpleSpanProcessor.new(
325
+ Brut::Instrumentation::LoggerSpanExporter.new
326
+ )
327
+ )
328
+ end
329
+
330
+ if defined?(OpenTelemetry::Instrumentation::Sidekiq)
331
+ c.use 'OpenTelemetry::Instrumentation::Sidekiq', {
332
+ span_naming: :job_class,
333
+ }
334
+ else
335
+ SemanticLogger[self.class].info "OpenTelemetry::Instrumentation::Sidekiq is not loaded, so Sidekiq traces will not be captured"
336
+ end
337
+ end
338
+
339
+ Brut.container.store(
340
+ "tracer",
341
+ OpenTelemetry::SDK::Trace::Tracer,
342
+ "Tracer for Open Telemetry",
343
+ OpenTelemetry.tracer_provider.tracer(@app.id)
344
+ )
345
+ end
307
346
  end
@@ -79,7 +79,8 @@ class Brut::FrontEnd::Component
79
79
  #
80
80
  # @return [Brut::FrontEnd::Templates::HTMLSafeString] string containing the component's HTML.
81
81
  def render
82
- Brut.container.instrumentation.span("#{self.class}.render") do |span|
82
+ Brut.container.instrumentation.span("#{self.class} render") do |span|
83
+ span.add_prefixed_attributes("brut", type: :component, class: self.class.name)
83
84
  Brut.container.component_locator.locate(self.template_name).
84
85
  then { Brut::FrontEnd::Template.new(it) }.
85
86
  then { it.render_template(self).html_safe! }
@@ -133,7 +134,7 @@ class Brut::FrontEnd::Component
133
134
  # @return [Brut::FrontEnd::Templates::HTMLSafeString] of the rendered component.
134
135
  def component(component_instance,&block)
135
136
  component_name = component_instance.kind_of?(Class) ? component_instance.name : component_instance.class.name
136
- Brut.container.instrumentation.span(component_name) do |span|
137
+ Brut.container.instrumentation.span("component #{component_name}") do |span|
137
138
  if component_instance.kind_of?(Class)
138
139
  if !component_instance.ancestors.include?(Brut::FrontEnd::Component)
139
140
  raise ArgumentError,"#{component_instance} is not a component and cannot be created"
@@ -141,9 +142,9 @@ class Brut::FrontEnd::Component
141
142
  component_instance = Thread.current.thread_variable_get(:request_context).
142
143
  then { |request_context| request_context.as_constructor_args(component_instance,request_params: nil)
143
144
  }.then { |constructor_args| component_instance.new(**constructor_args) }
144
- span.add_attributes("global_component" => true, "class" => component_instance.class.name)
145
+ span.add_prefixed_attributes("brut", "global_component" => true)
145
146
  else
146
- span.add_attributes("global_component" => false, "class" => component_instance.class.name)
147
+ span.add_prefixed_attributes("brut", "global_component" => false)
147
148
  end
148
149
  if !block.nil?
149
150
  component_instance.yielded_block = block
@@ -35,7 +35,7 @@ class Brut::FrontEnd::Form
35
35
  self.class.input_definitions.key?(key)
36
36
  }
37
37
  if unknown_params.any?
38
- Brut.container.instrumentation.add_attributes(ignored_unknown_params: unknown_params)
38
+ Brut.container.instrumentation.add_attributes(prefix: :brut, ignored_unknown_params: unknown_params.join(","))
39
39
  end
40
40
  @params = params.except(*unknown_params).map { |name,value|
41
41
  input_definition = begin
@@ -132,7 +132,18 @@ class Brut::FrontEnd::Form
132
132
  # values are arrays of {Brut::FrontEnd::Forms::ValidityState} instances.
133
133
  #
134
134
  # @param [true|false] server_side_only if true, only server side constraints are returned.
135
- # @return [Hash] map of input names to arrays of validity states
135
+ # @return [Hash<String|Array[2]>] a map of input names to arrays of constraint violations. The first element in the
136
+ # array is the validity state for the input, which is a {Brut::FrontEnd::Forms::ValidityState} instance. The second
137
+ # element is the index of the input in the array. This index is used when you have more than one field with the same
138
+ # name.
139
+ #
140
+ # @example iterationg
141
+ # form.constraint_violations.each do |input_name, (constraint_violations,index)|
142
+ # # input_name is the input's name, e.g. "email"
143
+ # # constraint_violations is an array of {Brut::FrontEnd::Forms::ValidityState} instances, one for each
144
+ # # problem with the field's value
145
+ # # index is the index of the input in the array, e.g. 0 for the first email field, 1 for the second, etc.
146
+ # end
136
147
  #
137
148
  def constraint_violations(server_side_only: false)
138
149
  @inputs.map { |input_name, inputs|
@@ -179,15 +190,17 @@ class Brut::FrontEnd::Form
179
190
  private
180
191
 
181
192
  def convert_to_string_or_nil(hash)
193
+ converted_hash = {}
182
194
  hash.each do |key,value|
195
+ key = key.to_s
183
196
  case value
184
- in Hash then convert_to_string_or_nil(value)
185
- in String then hash[key] = RichString.new(value).to_s_or_nil
186
- in Numeric then hash[key] = value.to_s
187
- in TrueClass then hash[key] = "true"
188
- in FalseClass then hash[key] = "false"
189
- in NilClass then # it's fine
190
- in Array then hash[key] = value
197
+ in Hash then converted_hash[key] = convert_to_string_or_nil(value)
198
+ in String then converted_hash[key] = RichString.new(value).to_s_or_nil
199
+ in Numeric then converted_hash[key] = value.to_s
200
+ in TrueClass then converted_hash[key] = "true"
201
+ in FalseClass then converted_hash[key] = "false"
202
+ in NilClass then converted_hash[key] = nil
203
+ in Array then converted_hash[key] = value
191
204
  else
192
205
  if Brut.container.project_env.test?
193
206
  raise ArgumentError, "Got #{value.class} for #{key} in params hash, which is not expected"
@@ -196,5 +209,6 @@ private
196
209
  end
197
210
  end
198
211
  end
212
+ converted_hash
199
213
  end
200
214
  end
@@ -4,7 +4,7 @@ class Brut::FrontEnd::Handlers::CspReportingHandler < Brut::FrontEnd::Handler
4
4
  def handle(body:)
5
5
  begin
6
6
  parsed = JSON.parse(body.read)
7
- Brut.container.instrumentation.add_attributes(parsed)
7
+ Brut.container.instrumentation.add_attributes(parsed.merge(prefix: "brut.csp-reporting"))
8
8
  rescue => ex
9
9
  Brut.container.instrumentation.record_exception(ex)
10
10
  end
@@ -8,8 +8,13 @@ class Brut::FrontEnd::Handlers::LocaleDetectionHandler < Brut::FrontEnd::Handler
8
8
  def handle(body:,session:)
9
9
  begin
10
10
  parsed = JSON.parse(body.read)
11
- Brut.container.instrumentation.add_attributes(parsed_body: parsed)
12
- Brut.container.instrumentation.add_attributes(parsed_class: parsed.class)
11
+
12
+ Brut.container.instrumentation.add_attributes(
13
+ prefix: "brut.locale-detection",
14
+ parsed_body: parsed,
15
+ parsed_class: parsed.class
16
+ )
17
+
13
18
  if parsed.kind_of?(Hash)
14
19
  locale = parsed["locale"]
15
20
  timezone = parsed["timeZone"]
@@ -7,7 +7,7 @@ class Brut::FrontEnd::Middlewares::AnnotateBrutOwnedPaths < Brut::FrontEnd::Midd
7
7
  end
8
8
  def call(env)
9
9
  if env["PATH_INFO"] =~ /^\/__brut\//
10
- Brut.container.instrumentation.add_attributes("brut.owned_path" => true)
10
+ Brut.container.instrumentation.add_attributes(prefix: "brut", owned_path: true)
11
11
  env["brut.owned_path"] = true
12
12
  end
13
13
  @app.call(env)
@@ -3,9 +3,33 @@ class Brut::FrontEnd::Middlewares::OpenTelemetrySpan < Brut::FrontEnd::Middlewar
3
3
  @app = app
4
4
  end
5
5
  def call(env)
6
- path = env["REQUEST_PATH"]
7
- method = env["REQUEST_METHOD"]
8
- Brut.container.instrumentation.span("HTTP.#{method}.#{path}") do |span|
6
+ otel_standard_attributes = {
7
+ "http.request.method" => env["REQUEST_METHOD"],
8
+ "url.path" => env["PATH_INFO"],
9
+ "url.query" => env["QUERY_STRING"],
10
+ "url.scheme" => env["rack.url_scheme"],
11
+ "url.full" => "#{env["rack.url_scheme"]}://#{env["HTTP_HOST"]}#{env["REQUEST_URI"]}",
12
+ "server.address" => env["HTTP_HOST"],
13
+ "user_agent.original" => env["HTTP_USER_AGENT"],
14
+ "network.peer.ip" => env["REMOTE_ADDR"],
15
+ "network.peer.port" => env["REMOTE_PORT"],
16
+ "network.protocol.version" => env["HTTP_VERSION"],
17
+ }.merge(
18
+ "http.request.header.accept-language" => env["HTTP_ACCEPT_LANGUAGE"],
19
+ "http.request.header.referer" => env["HTTP_REFERER"],
20
+ "http.request.header.user-agent" => env["HTTP_USER_AGENT"],
21
+ "http.request.header.accept" => env["HTTP_ACCEPT"],
22
+ ).delete_if { |_,v| v.nil? || v.empty? }
23
+
24
+ span_name = if env["PATH_INFO"] =~ /^\/js\//
25
+ "HTTP #{env['REQUEST_METHOD']} JS"
26
+ elsif env["PATH_INFO"] =~ /^\/css\//
27
+ "HTTP #{env['REQUEST_METHOD']} CSS"
28
+ else
29
+ "HTTP #{env['REQUEST_METHOD']}"
30
+ end
31
+ Brut.container.tracer.in_span(span_name,kind: :server, attributes: otel_standard_attributes) do |span|
32
+ env["brut.otel.root_span"] = span
9
33
  @app.call(env)
10
34
  end
11
35
  end
@@ -5,7 +5,6 @@
5
5
  class Brut::FrontEnd::RouteHooks::LocaleDetection < Brut::FrontEnd::RouteHook
6
6
  def before(session:,env:)
7
7
  http_accept_language = Brut::I18n::HTTPAcceptLanguage.from_header(env["HTTP_ACCEPT_LANGUAGE"])
8
- Brut.container.instrumentation.add_attributes(http_accept_language:)
9
8
  if !session.http_accept_language.known?
10
9
  session.http_accept_language = http_accept_language
11
10
  end
@@ -20,10 +19,10 @@ class Brut::FrontEnd::RouteHooks::LocaleDetection < Brut::FrontEnd::RouteHook
20
19
  end
21
20
  end
22
21
  if best_locale
23
- Brut.container.instrumentation.add_attributes(best_locale:)
22
+ Brut.container.instrumentation.add_attributes(prefix: "brut.locale-detection", best_locale:)
24
23
  ::I18n.locale = best_locale
25
24
  else
26
- Brut.container.instrumentation.add_attributes(best_locale: false)
25
+ Brut.container.instrumentation.add_attributes(prefix: "brut.locale-detection", best_locale: false)
27
26
  end
28
27
  continue
29
28
  end
@@ -3,9 +3,7 @@ class Brut::Instrumentation::OpenTelemetry
3
3
  #
4
4
  # @param [String] name the name of the span. Should be specific to the code being wrapped, but not contain dynamic information. For
5
5
  # example, you could call this the method name, but should not include parameters in the name.
6
- # @param [Hash<String|Symbol,Object>] attributes Hash of attributes to include in this span. This is as if you called
7
- # {Brut::Instrumentation::OpenTelemetry::Span#add_attributes} as the first line of the block. See that method for more details on
8
- # the contents of this hash.
6
+ # @param [Hash<String|Symbol,Object>] attributes Hash of attributes to include in this span. This is as if you called {Brut::Instrumentation::OpenTelemetry::Span#add_attributes} as the first line of the block. There is a special attribute named `prefix:` that will control how attributes are prefixed. If it is missing, the app's configured OTel prefix is used. If it is sent to `false`, no prefixing is done. Otherwise, the provided value is used as the prefix. Generally, you don't want to set this so your app's prefix is used. Also note that the keys and values are automatically converted to primitive types, so you can use whatever makes sense. Just know that for rich objects `to_s` will be called.
9
7
  #
10
8
  # @yield [Brut::Instrumentation::OpenTelemetry::Span] executes this block in the context of a new OpenTelemetry span. yields
11
9
  # the span so you can call further methods on it.
@@ -16,9 +14,9 @@ class Brut::Instrumentation::OpenTelemetry
16
14
  #
17
15
  def span(name,**attributes,&block)
18
16
  result = nil
19
- Brut.container.tracer.in_span(name) do |span|
17
+ normalized_attributes = NormalizedAttributes.new(:detect,attributes).to_h
18
+ Brut.container.tracer.in_span(name, attributes: normalized_attributes) do |span|
20
19
  wrapped_span = Span.new(span)
21
- wrapped_span.add_attributes(attributes)
22
20
  begin
23
21
  result = block.(wrapped_span)
24
22
  rescue => ex
@@ -46,22 +44,32 @@ class Brut::Instrumentation::OpenTelemetry
46
44
  current_span.record_exception(ex,attributes: NormalizedAttributes.new(nil,attributes).to_h)
47
45
  end
48
46
 
49
- # Adds attributes to the current span
47
+ # Adds attributes to the span, converting the hash or keyword arguments to strings. This will use
48
+ # the app's Otel prefix for all attributes, so you do not have to prefix them.
49
+ # If you need to set standard attributes, you should use {Brut::Instrumentation::OpenTelemetry::Span#add_prefixed_attributes} instead.
50
50
  # @param [Hash] attributes any attributes to attach to the event.
51
51
  def add_attributes(attributes)
52
52
  current_span = OpenTelemetry::Trace.current_span
53
- current_span.add_attributes(NormalizedAttributes.new(nil,attributes).to_h)
53
+ current_span.add_attributes(NormalizedAttributes.new(:detect,attributes).to_h)
54
54
  end
55
55
 
56
+ private
57
+
56
58
  class NormalizedAttributes
57
59
  def initialize(prefix,attributes)
58
- prefix = if prefix
59
- "#{prefix}."
60
- else
61
- ""
62
- end
60
+ if prefix == :detect
61
+ prefix = attributes.delete(:prefix)
62
+ if prefix.nil?
63
+ prefix = Brut.container.otel_attribute_prefix
64
+ end
65
+ end
66
+ prefix_string = if prefix
67
+ "#{prefix}."
68
+ else
69
+ ""
70
+ end
63
71
  @attributes = (attributes || {}).map { |key,value|
64
- [ "#{prefix}#{key}", normalize_value(value) ]
72
+ [ "#{prefix_string}#{key}", normalize_value(value) ]
65
73
  }.to_h
66
74
  end
67
75
 
@@ -82,20 +90,23 @@ class Brut::Instrumentation::OpenTelemetry
82
90
  value.to_s
83
91
  end
84
92
  end
93
+
85
94
  end
86
95
 
87
96
  class Span < SimpleDelegator
88
97
 
89
- # Adds attributes to the span, converting the hash or keyword arguments to strings.
98
+ # Adds attributes to the span, converting the hash or keyword arguments to strings. This will use
99
+ # the app's Otel prefix for all attributes, so you do not have to prefix them.
100
+ # If you need to set standard attributes, you should use {#add_prefixed_attributes} instead.
90
101
  #
91
102
  # @param [Hash] attributes a hash of the attributes to add. Keys will be converted to strings via `to_s`.
92
103
  # Values will be converted via {Brut::Instrumentation::OpenTelemetry::NormalizedAttributes}, which preserves strings, numbers, and
93
104
  # booleans, and converts the rest to strings via `to_s`.
94
105
  def add_attributes(attributes)
95
- add_prefixed_attributes(nil,attributes)
106
+ add_prefixed_attributes(:detect,attributes)
96
107
  end
97
108
 
98
- # Adds attributes to the span, prefixing each key with the given prefix, then converting the hash or keyword arguments to strings.
109
+ # Adds attributes to the span, prefixing each key with the given prefix, then converting the hash or keyword arguments to strings. For example, if the prefix is 'my_app' and you add the attributes 'type' and 'reason', the actual attribute names will be 'my_app.type' and 'my_app.reason'.
99
110
  #
100
111
  # @see #add_attributes
101
112
  def add_prefixed_attributes(prefix,attributes)
@@ -2,6 +2,7 @@ module Brut::Instrumentation
2
2
  autoload(:OpenTelemetry,"brut/instrumentation/open_telemetry")
3
3
  autoload(:LoggerSpanExporter,"brut/instrumentation/logger_span_exporter")
4
4
 
5
+ # Convenience method to add attributes to create a span without accessing the instrumentation instance directly.
5
6
  def span(name,**attributes,&block)
6
7
  Brut.container.instrumentation.span(name,**attributes,&block)
7
8
  end
@@ -65,19 +65,28 @@ module Brut::SinatraHelpers
65
65
  Brut.container.routing.register_page(path)
66
66
 
67
67
  get path do
68
- route = Brut.container.routing.for(path: path,method: :get)
69
- page_class = route.handler_class
70
- Brut.container.instrumentation.span(page_class.name, path: path) do |span|
68
+ brut_route = Brut.container.routing.for(path: path,method: :get)
69
+ page_class = brut_route.handler_class
70
+ path_template = brut_route.path_template
71
+
72
+ root_span = env["brut.otel.root_span"]
73
+ if root_span
74
+ root_span.name = "GET #{path_template}"
75
+ root_span.add_attributes("http.route" => path_template)
76
+ end
77
+
78
+ Brut.container.instrumentation.span(page_class.name) do |span|
79
+ span.add_prefixed_attributes("brut", type: :page, class: page_class)
71
80
  request_context = Thread.current.thread_variable_get(:request_context)
72
81
  constructor_args = request_context.as_constructor_args(
73
82
  page_class,
74
83
  request_params: params,
75
- route: route,
84
+ route: brut_route,
76
85
  )
77
- span.add_prefixed_attributes("initializer.args", constructor_args.map { |k,v| [k.to_s,v.class.name] }.to_h)
86
+ span.add_prefixed_attributes("brut.initializer.args", constructor_args.map { |k,v| [k.to_s,v.class.name] }.to_h)
78
87
  page_instance = page_class.new(**constructor_args)
79
88
  result = page_instance.handle!
80
- span.add_attributes(result_class: result.class)
89
+ span.add_prefixed_attributes("brut", result_class: result.class)
81
90
  case result
82
91
  in URI => uri
83
92
  redirect to(uri.to_s)
@@ -148,37 +157,55 @@ module Brut::SinatraHelpers
148
157
  # This must be re-looked up per-request do allow reloading to work
149
158
  brut_route = Brut.container.routing.for(path:,method:)
150
159
 
160
+ path_template = brut_route.path_template
161
+
162
+ root_span = env["brut.otel.root_span"]
163
+ if root_span
164
+ root_span.name = "#{method} #{path_template}"
165
+ root_span.add_attributes("http.route" => path_template)
166
+ end
167
+
151
168
  handler_class = brut_route.handler_class
152
- form_class = brut_route.respond_to?(:form_class) ? brut_route.form_class : nil
153
-
154
- request_context = Thread.current.thread_variable_get(:request_context)
155
- handler = handler_class.new
156
- form = if form_class.nil?
157
- nil
158
- else
159
- form_class.new(params: params)
160
- end
161
-
162
- process_args = request_context.as_method_args(handler,:handle,request_params: params,form: form,route:brut_route)
163
-
164
- result = handler.handle!(**process_args)
165
-
166
- case result
167
- in URI => uri
168
- redirect to(uri.to_s)
169
- in Brut::FrontEnd::Component => component_instance
170
- render_html(component_instance).to_s
171
- in [ Brut::FrontEnd::Component => component_instance, Brut::FrontEnd::HttpStatus => http_status ]
172
- [
173
- http_status.to_i,
174
- render_html(component_instance).to_s,
175
- ]
176
- in Brut::FrontEnd::HttpStatus => http_status
177
- http_status.to_i
178
- in Brut::FrontEnd::Download => download
179
- [ 200, download.headers, download.data ]
180
- else
181
- raise NoMatchingPatternError, "Result from #{handler.class}'s handle! method was a #{result.class}, which cannot be used to understand the response to generate"
169
+
170
+ Brut.container.instrumentation.span(handler_class.name) do |span|
171
+
172
+ form_class = brut_route.respond_to?(:form_class) ? brut_route.form_class : nil
173
+
174
+ span.add_prefixed_attributes("brut",
175
+ type: form_class ? :form : :action,
176
+ class: handler_class,
177
+ form_class: form_class,
178
+ )
179
+
180
+ request_context = Thread.current.thread_variable_get(:request_context)
181
+ handler = handler_class.new
182
+ form = if form_class.nil?
183
+ nil
184
+ else
185
+ form_class.new(params: params)
186
+ end
187
+
188
+ process_args = request_context.as_method_args(handler,:handle,request_params: params,form: form,route:brut_route)
189
+
190
+ result = handler.handle!(**process_args)
191
+
192
+ case result
193
+ in URI => uri
194
+ redirect to(uri.to_s)
195
+ in Brut::FrontEnd::Component => component_instance
196
+ render_html(component_instance).to_s
197
+ in [ Brut::FrontEnd::Component => component_instance, Brut::FrontEnd::HttpStatus => http_status ]
198
+ [
199
+ http_status.to_i,
200
+ render_html(component_instance).to_s,
201
+ ]
202
+ in Brut::FrontEnd::HttpStatus => http_status
203
+ http_status.to_i
204
+ in Brut::FrontEnd::Download => download
205
+ [ 200, download.headers, download.data ]
206
+ else
207
+ raise NoMatchingPatternError, "Result from #{handler.class}'s handle! method was a #{result.class}, which cannot be used to understand the response to generate"
208
+ end
182
209
  end
183
210
  end
184
211
  end
data/lib/brut/version.rb CHANGED
@@ -1,4 +1,4 @@
1
1
  module Brut
2
2
  # @!visibility private
3
- VERSION = "0.0.9"
3
+ VERSION = "0.0.11"
4
4
  end
@@ -4,7 +4,13 @@ module Sequel
4
4
  module BrutInstrumentation
5
5
  # @!visibility private
6
6
  def log_connection_yield(sql,conn,args=nil)
7
- Brut.container.instrumentation.span("SQL", sql: sql) do |span|
7
+ otel_attributes = {
8
+ "db.system" => "sql",
9
+ "db.system.name" => "postgresql",
10
+ "db.query.text" => sql,
11
+ "prefix" => false,
12
+ }
13
+ Brut.container.instrumentation.span("SQL", **otel_attributes) do |span|
8
14
  super
9
15
  end
10
16
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: brut
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.9
4
+ version: 0.0.11
5
5
  platform: ruby
6
6
  authors:
7
7
  - David Bryant Copeland
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2025-04-02 00:00:00.000000000 Z
10
+ date: 2025-04-04 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: irb