diff --git a/lib/ditty/components/ditty.rb b/lib/ditty/components/ditty.rb index c19f86d..f78d4b9 100644 --- a/lib/ditty/components/ditty.rb +++ b/lib/ditty/components/ditty.rb @@ -9,6 +9,9 @@ def self.load controllers = File.expand_path('../controllers', __dir__) Dir.glob("#{controllers}/*.rb").sort.each { |f| require f } + services = File.expand_path('../services', __dir__) + Dir.glob("#{services}/*.rb").sort.each { |f| require f } + require 'ditty/models/user' require 'ditty/models/role' require 'ditty/models/identity' @@ -19,6 +22,8 @@ def self.load def self.configure(_container) require 'ditty/db' unless defined? ::DB require 'ditty/listener' + + self.load end def self.migrations diff --git a/lib/ditty/controllers/application_controller.rb b/lib/ditty/controllers/application_controller.rb index e7aa5c6..88d4a42 100644 --- a/lib/ditty/controllers/application_controller.rb +++ b/lib/ditty/controllers/application_controller.rb @@ -11,6 +11,7 @@ require 'ditty/helpers/pundit' require 'ditty/helpers/authentication' require 'ditty/services/logger' +require 'ditty/middleware/telemetry' require 'active_support' require 'active_support/inflector' require 'rack/contrib' @@ -36,6 +37,7 @@ class ApplicationController < Sinatra::Base register Sinatra::Flash, Sinatra::RespondWith + use Middleware::Telemetry use Rack::JSONBodyParser use Rack::MethodOverride use Rack::NestedParams @@ -65,6 +67,15 @@ def config(name, default = '') end end + # Fine for certain things, but using middleware might be a better idea + before do + broadcast(:before_route, target: self) + end + + after do + broadcast(:after_route, target: self) + end + def view_folders folders = ['./views'] folders << settings.view_folder if settings.view_folder @@ -98,6 +109,7 @@ def find_template(views, name, engine, &block) end not_found do + broadcast(:application_error, env['sinatra.error'], error_code: 404, target: self) respond_to do |format| status 404 format.html do @@ -114,7 +126,7 @@ def find_template(views, name, engine, &block) end error Helpers::NotAuthenticated, ::Pundit::NotAuthorizedError do - # TODO: Check if this is logged / tracked + broadcast(:application_error, env['sinatra.error'], error_code: 403, target: self) if authenticated? respond_to do |format| status 403 @@ -141,6 +153,7 @@ def find_template(views, name, engine, &block) end error ::Sinatra::Param::InvalidParameterError do + broadcast(:application_error, env['sinatra.error'], error_code: 400, target: self) respond_to do |format| status 400 format.html do @@ -155,6 +168,7 @@ def find_template(views, name, engine, &block) end error ::Sequel::NoMatchingRow do + broadcast(:application_error, env['sinatra.error'], error_code: 404, target: self) respond_to do |format| status 404 format.html do @@ -171,6 +185,7 @@ def find_template(views, name, engine, &block) end error ::Sequel::ValidationFailed do + broadcast(:application_error, env['sinatra.error'], error_code: 400, target: self) respond_to do |format| entity = env['sinatra.error'].model errors = env['sinatra.error'].errors @@ -187,7 +202,7 @@ def find_template(views, name, engine, &block) error ::Sequel::ForeignKeyConstraintViolation do error = env['sinatra.error'] - broadcast(:application_error, error) + broadcast(:application_error, error, error_code: 400, target: self) logger.error error respond_to do |format| status 400 @@ -203,7 +218,7 @@ def find_template(views, name, engine, &block) error ::Ditty::TemplateNotFoundError do # TODO: Display a better error message error = env['sinatra.error'] - broadcast(:application_error, error) + broadcast(:application_error, error, error_code: 500, target: self) logger.error error respond_to do |format| status 500 @@ -215,7 +230,7 @@ def find_template(views, name, engine, &block) error do error = env['sinatra.error'] - broadcast(:application_error, error) + broadcast(:application_error, error, error_code: 500, target: self) logger.error error respond_to do |format| status 500 diff --git a/lib/ditty/middleware/telemetry.rb b/lib/ditty/middleware/telemetry.rb new file mode 100644 index 0000000..53206c4 --- /dev/null +++ b/lib/ditty/middleware/telemetry.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require 'ditty/services/open_telemetry' + +module Ditty + module Middleware + class Telemetry + attr_reader :env, :telemetry + + def initialize(app, telemetry = nil) + @app = app + @telemetry = telemetry || Services::OpenTelemetry.new + end + + def call(env) + @env = env + request = Rack::Request.new(env) + attribs = { + ::OpenTelemetry::SemanticConventions::Trace::HTTP_URL => request.url, + ::OpenTelemetry::SemanticConventions::Trace::HTTP_METHOD => request.request_method, + } + + telemetry.instrumented("#{request.request_method} #{request.path}", attribs) do |span| + # Not sure if we should try and catch exceptions? + result = @app.call(env) + span.add_attributes( + ::OpenTelemetry::SemanticConventions::Trace::HTTP_STATUS_CODE => result[0] + ) + result + end + end + + class << self + def method_missing(method, *args, &block) + instance.send(method, *args, &block) + end + + def respond_to_missing?(method, _include_private) + return true if instance.respond_to?(method) + + super + end + end + end + end +end diff --git a/lib/ditty/services/logger.rb b/lib/ditty/services/logger.rb index 6766ae3..b0e90ea 100644 --- a/lib/ditty/services/logger.rb +++ b/lib/ditty/services/logger.rb @@ -30,7 +30,7 @@ def initialize end end - # TODO: REfac this so that you can log something like ES to a separate logger + # TODO: Refac this so that you can log something like ES to a separate logger def method_missing(method, *args, &block) loggers.each { |logger| logger.send(method, *args, &block) } diff --git a/lib/ditty/services/open_telemetry.rb b/lib/ditty/services/open_telemetry.rb new file mode 100644 index 0000000..000256b --- /dev/null +++ b/lib/ditty/services/open_telemetry.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +module Ditty + module Services + class OpenTelemetry + def tracer + # ::OpenTelemetry::Instrumentation::Sinatra::Instrumentation.instance.tracer + @tracer ||= ::OpenTelemetry.tracer_provider.tracer('Ditty', ::Ditty::VERSION) + end + + def instrumented? + (ENV['DITTY_TELEMETRY_ENABLED'] || 0).to_i == 1 + end + + def instrumented(span_name, attribs = {}) + return yield unless instrumented? + + tracer.in_span(span_name, attributes: base_attributes.merge(attribs)) do |span| + yield(span) + end + end + + def application_error(error, opts = {}) + error ||= env['sinatra.error'] + return unless error.is_a? StandardError + return unless @request_span + + request = opts[:target].request + @request_span&.record_exception( + error, + attributes: base_attributes.merge( + ::OpenTelemetry::SemanticConventions::Trace::HTTP_URL => request.url, + ::OpenTelemetry::SemanticConventions::Trace::HTTP_METHOD => request.request_method, + ::OpenTelemetry::SemanticConventions::Trace::HTTP_STATUS_CODE => opts[:error_code] || 500 + ) + ) + if (opts[:error_code] || 500) >= 500 + @request_span&.status = ::OpenTelemetry::Trace::Status.error("Application Error: #{error.class}") + end + @request_span&.finish + end + + private + + def base_attributes + { + 'ditty.version' => ::Ditty::VERSION, + } + end + end + end +end + +Wisper.subscribe(::Ditty::Services::OpenTelemetry.new, on: %i[application_error]) unless ENV['RACK_ENV'] == 'test'