From 6825941c0b9890cb853be2463764708dd27a1e04 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Sun, 6 Sep 2026 21:11:47 +0200 Subject: [PATCH] feat: add Prism, Instrumentation and Jobs --- lib/prism/instrumentation.rb | 121 +++++++++++++++++++++++++++++++++++ lib/prism/jobs.rb | 15 +++++ lib/prism/mailer.rb | 67 +++++++++++++++++++ 3 files changed, 203 insertions(+) create mode 100644 lib/prism/instrumentation.rb create mode 100644 lib/prism/jobs.rb create mode 100644 lib/prism/mailer.rb diff --git a/lib/prism/instrumentation.rb b/lib/prism/instrumentation.rb new file mode 100644 index 0000000..db1e8d7 --- /dev/null +++ b/lib/prism/instrumentation.rb @@ -0,0 +1,121 @@ +module Prism + # ActiveSupport::Notifications-lite: a process-wide publish/subscribe bus + # for cross-cutting events (queries, request dispatch, template renders). + # Instrumented code owns naming and payload shape; subscribers (loggers, + # metrics collectors, tests) opt into a subset by exact name or Regexp. + module Instrumentation + Event = Struct.new(:name, :payload, :duration, :time) do + def duration_ms + duration * 1000.0 + end + end + + class Subscriber + def initialize(pattern, block) + @pattern = pattern + @block = block + end + + def match?(name) + case @pattern + when nil then true + when Regexp then @pattern.match?(name) + else @pattern.to_s == name + end + end + + def call(event) + @block.call(event) + end + end + + class Bus + def initialize + @subscribers = [] + @mutex = Mutex.new + end + + def subscribe(pattern = nil, &block) + raise ArgumentError, 'block required' unless block + subscriber = Subscriber.new(pattern, block) + @mutex.synchronize { @subscribers = @subscribers + [subscriber] } + subscriber + end + + def unsubscribe(subscriber) + @mutex.synchronize { @subscribers = @subscribers - [subscriber] } + end + + def subscribers + @mutex.synchronize { @subscribers.dup } + end + + # Yields a mutable payload hash so instrumented code (and nested + # instrument calls further down the stack) can enrich it before the + # event is published. Always publishes, even when the block raises - + # the exception is recorded on the payload and re-raised afterward. + def instrument(name, payload = {}) + start = Process.clock_gettime(Process::CLOCK_MONOTONIC) + time = Time.now + result = nil + begin + result = block_given? ? yield(payload) : nil + result + rescue StandardError => e + payload[:exception] = [e.class.name, e.message] + payload[:exception_object] = e + raise + ensure + duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start + publish(Event.new(name, payload, duration, time)) + end + end + + private + + def publish(event) + subscribers.each do |subscriber| + next unless subscriber.match?(event.name) + subscriber.call(event) + end + end + end + + class << self + def bus + @bus ||= Bus.new + end + + def instrument(name, payload = {}, &block) + bus.instrument(name, payload, &block) + end + + def subscribe(pattern = nil, &block) + bus.subscribe(pattern, &block) + end + + def unsubscribe(subscriber) + bus.unsubscribe(subscriber) + end + end + + # Rails-style one-line-per-event logger: " sql.prism (0.3ms) SELECT posts". + # Attach once (typically in an initializer / Application setup); returns + # the underlying Subscriber so callers can #detach it later. + class LogSubscriber + def self.attach(io = $stdout, patterns: [nil]) + patterns.map do |pattern| + Instrumentation.subscribe(pattern) { |event| io.puts(format_event(event)) } + end + end + + def self.format_event(event) + ms = format('%.1f', event.duration_ms) + details = event.payload.reject { |key, _| key == :exception_object } + .map { |key, value| "#{key}=#{value.inspect}" }.join(' ') + line = " #{event.name} (#{ms}ms)" + details.empty? ? line : "#{line} #{details}" + end + end + end +end diff --git a/lib/prism/jobs.rb b/lib/prism/jobs.rb new file mode 100644 index 0000000..d2f9111 --- /dev/null +++ b/lib/prism/jobs.rb @@ -0,0 +1,15 @@ +require_relative 'jobs/job' +require_relative 'jobs/queue' +require_relative 'jobs/worker' + +module Prism + module Jobs + class << self + attr_writer :default_queue + + def default_queue + @default_queue ||= Queue.new('db/jobs.pstore') + end + end + end +end diff --git a/lib/prism/mailer.rb b/lib/prism/mailer.rb new file mode 100644 index 0000000..c1733fe --- /dev/null +++ b/lib/prism/mailer.rb @@ -0,0 +1,67 @@ +require_relative 'mailer/message' +require_relative 'mailer/delivery' +require_relative 'mailer/delivery_job' +require_relative 'mailer/base' +require_relative 'mailer/preview' +require_relative 'mailer/preview_app' + +module Prism + module Mailer + class << self + attr_accessor :smtp_settings + + # Router used by Mailer::Base#url_for to build absolute links inside + # mail bodies, where there is no incoming request to pull a router off + # of. Set once, normally via Application#enable_mailer, e.g. + # `Prism::Mailer.router = BlogApp.router`. + attr_accessor :router + attr_writer :delivery_method, :default_url_options + + def delivery_method + @delivery_method ||= :test + end + + # App-wide fallback for Mailer::Base.default_url_options (host, + # protocol, port); a mailer class's own default_url_options take + # precedence when both are set. + def default_url_options + @default_url_options ||= {} + end + + # Messages handed to Mailer.deliver! while delivery_method == :test + # pile up here instead of going out over the network, mirroring + # ActionMailer::Base.deliveries for assertions in tests. + def deliveries + @deliveries ||= [] + end + + def deliver!(message) + Instrumentation.instrument('deliver_mail.prism', subject: message.subject, + to: message.to, + delivery_method: delivery_method) do + case delivery_method + when :test + deliveries << message + when :smtp + deliver_via_smtp(message) + else + raise ArgumentError, "unknown Prism::Mailer.delivery_method #{delivery_method.inspect}" + end + end + message + end + + private + + def deliver_via_smtp(message) + require 'net/smtp' + settings = smtp_settings || {} + Net::SMTP.start(settings[:address] || 'localhost', settings[:port] || 25, + settings[:domain] || 'localhost', settings[:user_name], + settings[:password], settings[:authentication]) do |smtp| + smtp.send_message(message.to_s, message.from, message.all_recipients) + end + end + end + end +end