omnitrack-rb 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.
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Omnitrack
4
+ # Central configuration object. Use Omnitrack.configure { |c| ... } in an initializer.
5
+ class Configuration
6
+ VALID_MODES = %i[auto frontend backend hybrid].freeze
7
+ VALID_LOG_LVLS = %i[debug info warn error none].freeze
8
+
9
+ # :auto — gem detects API-only vs full-stack at runtime
10
+ # :frontend — always inject JS helpers / use cookies
11
+ # :backend — server-side only; no JS
12
+ # :hybrid — both simultaneously
13
+ attr_accessor :mode
14
+
15
+ # Hash keyed by adapter symbol. Each value is a sub-hash of adapter options.
16
+ # Example:
17
+ # { google_ads: { enabled: true, customer_id: "123" },
18
+ # meta: { enabled: true, pixel_id: "456" } }
19
+ attr_accessor :adapters
20
+
21
+ # Automatically capture gclid / fbclid / ttclid from incoming requests
22
+ attr_accessor :auto_capture
23
+
24
+ # :debug | :info | :warn | :error | :none
25
+ attr_accessor :log_level
26
+
27
+ # Path to dedicated log file. Defaults to Rails.root/log/omnitrack.log
28
+ attr_accessor :log_file
29
+
30
+ # When true, dispatch tracking calls through ActiveJob (non-blocking)
31
+ attr_accessor :async
32
+
33
+ # Queue name used for ActiveJob tracking jobs
34
+ attr_accessor :queue_name
35
+
36
+ # HTTP timeout (seconds) for outbound adapter API calls
37
+ attr_accessor :timeout
38
+
39
+ # Max retry attempts for failed adapter calls
40
+ attr_accessor :max_retries
41
+
42
+ # Retry delay base in seconds (exponential back-off: delay * 2^attempt)
43
+ attr_accessor :retry_delay
44
+
45
+ # Hook: called with (event_name, adapter_name, error) on adapter failure
46
+ attr_accessor :on_error
47
+
48
+ def initialize
49
+ @mode = :auto
50
+ @adapters = {}
51
+ @auto_capture = true
52
+ @log_level = :info
53
+ @log_file = nil # resolved lazily against Rails.root
54
+ @async = false
55
+ @queue_name = :omnitrack
56
+ @timeout = 5
57
+ @max_retries = 3
58
+ @retry_delay = 1
59
+ @on_error = nil
60
+ end
61
+
62
+ def validate!
63
+ unless VALID_MODES.include?(@mode)
64
+ raise Omnitrack::ConfigurationError,
65
+ "Invalid mode: #{@mode.inspect}. Must be one of #{VALID_MODES}"
66
+ end
67
+
68
+ unless VALID_LOG_LVLS.include?(@log_level)
69
+ raise Omnitrack::ConfigurationError,
70
+ "Invalid log_level: #{@log_level.inspect}. Must be one of #{VALID_LOG_LVLS}"
71
+ end
72
+
73
+ true
74
+ end
75
+
76
+ # Returns the resolved log file path (String)
77
+ def resolved_log_file
78
+ return @log_file.to_s if @log_file
79
+
80
+ if defined?(Rails) && Rails.root
81
+ Rails.root.join("log", "omnitrack.log").to_s
82
+ else
83
+ "log/omnitrack.log"
84
+ end
85
+ end
86
+
87
+ # Returns the hash config for a specific adapter (or {})
88
+ def adapter_config(name)
89
+ @adapters.fetch(name.to_sym, {})
90
+ end
91
+
92
+ # True when the given adapter is enabled in config
93
+ def adapter_enabled?(name)
94
+ cfg = adapter_config(name)
95
+ cfg.fetch(:enabled, false)
96
+ end
97
+
98
+ # Effective Rails mode — resolves :auto at runtime
99
+ def effective_mode
100
+ return @mode unless @mode == :auto
101
+
102
+ if defined?(Rails) && Rails.application
103
+ Rails.application.config.api_only ? :backend : :frontend
104
+ else
105
+ :backend
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Omnitrack
4
+ # Captures and exposes tracking parameters from a Rack request.
5
+ # Works in both cookie-based (full-stack) and cookie-free (API-only) modes.
6
+ #
7
+ # Stored per-request in thread-local storage via Omnitrack::Context.current.
8
+ class Context
9
+ CLICK_ID_PARAMS = %w[gclid fbclid ttclid sclid msclkid].freeze
10
+ UTM_PARAMS = %w[utm_source utm_medium utm_campaign utm_term utm_content].freeze
11
+
12
+ # Thread-local current context — set by the middleware
13
+ def self.current
14
+ Thread.current[:omnitrack_context]
15
+ end
16
+
17
+ def self.current=(ctx)
18
+ Thread.current[:omnitrack_context] = ctx
19
+ end
20
+
21
+ def self.clear!
22
+ Thread.current[:omnitrack_context] = nil
23
+ end
24
+
25
+ # Build a Context from a Rack::Request object
26
+ def self.from_request(request)
27
+ new(
28
+ ip: extract_ip(request),
29
+ user_agent: request.env["HTTP_USER_AGENT"],
30
+ click_ids: extract_click_ids(request),
31
+ utm_params: extract_utm_params(request),
32
+ cookies: safe_cookies(request),
33
+ headers: extract_tracking_headers(request)
34
+ )
35
+ end
36
+
37
+ attr_reader :ip, :user_agent, :click_ids, :utm_params, :cookies, :headers,
38
+ :custom_data
39
+
40
+ def initialize(ip: nil, user_agent: nil, click_ids: {}, utm_params: {},
41
+ cookies: {}, headers: {}, custom_data: {})
42
+ @ip = ip
43
+ @user_agent = user_agent
44
+ @click_ids = click_ids.transform_keys(&:to_sym)
45
+ @utm_params = utm_params.transform_keys(&:to_sym)
46
+ @cookies = cookies
47
+ @headers = headers
48
+ @custom_data = custom_data || {}
49
+ end
50
+
51
+ # Merge extra data into the context (e.g., from a controller concern)
52
+ def merge!(data = {})
53
+ @custom_data.merge!(data.to_h)
54
+ self
55
+ end
56
+
57
+ def gclid = click_ids[:gclid]
58
+ def fbclid = click_ids[:fbclid]
59
+ def ttclid = click_ids[:ttclid]
60
+
61
+ # Full context as a hash — passed into adapter payloads
62
+ def to_h
63
+ {
64
+ ip: @ip,
65
+ user_agent: @user_agent,
66
+ click_ids: @click_ids,
67
+ utm_params: @utm_params,
68
+ custom_data: @custom_data
69
+ }.reject { |_, v| v.nil? || (v.respond_to?(:empty?) && v.empty?) }
70
+ end
71
+
72
+ # -------------------------------------------------------------------
73
+ # Private extraction helpers
74
+ # -------------------------------------------------------------------
75
+
76
+ def self.extract_ip(request)
77
+ # Respect common proxy headers
78
+ forwarded = request.env["HTTP_X_FORWARDED_FOR"]
79
+ if forwarded
80
+ forwarded.split(",").first.to_s.strip
81
+ else
82
+ request.ip
83
+ end
84
+ end
85
+ private_class_method :extract_ip
86
+
87
+ def self.extract_click_ids(request)
88
+ CLICK_ID_PARAMS.each_with_object({}) do |param, h|
89
+ value = request.params[param] ||
90
+ request.env["HTTP_X_#{param.upcase}"]
91
+ h[param] = value if value && !value.empty?
92
+ end
93
+ end
94
+ private_class_method :extract_click_ids
95
+
96
+ def self.extract_utm_params(request)
97
+ UTM_PARAMS.each_with_object({}) do |param, h|
98
+ value = request.params[param]
99
+ h[param] = value if value && !value.empty?
100
+ end
101
+ end
102
+ private_class_method :extract_utm_params
103
+
104
+ def self.safe_cookies(request)
105
+ request.cookies.to_h
106
+ rescue StandardError
107
+ {}
108
+ end
109
+ private_class_method :safe_cookies
110
+
111
+ def self.extract_tracking_headers(request)
112
+ {
113
+ referer: request.env["HTTP_REFERER"],
114
+ accept_language: request.env["HTTP_ACCEPT_LANGUAGE"]
115
+ }.compact
116
+ end
117
+ private_class_method :extract_tracking_headers
118
+ end
119
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Omnitrack
4
+ # Base class for all Omnitrack errors
5
+ class Error < StandardError; end
6
+
7
+ # Raised when the gem is misconfigured
8
+ class ConfigurationError < Error; end
9
+
10
+ # Raised (and caught internally) when an adapter API call fails.
11
+ # Carries the original exception for diagnostics.
12
+ class AdapterError < Error
13
+ attr_reader :original
14
+
15
+ def initialize(message = nil, original: nil)
16
+ super(message)
17
+ @original = original
18
+ end
19
+ end
20
+
21
+ # Raised when an event payload fails validation
22
+ class InvalidPayloadError < Error; end
23
+
24
+ # Raised when an adapter is referenced but not registered
25
+ class UnknownAdapterError < Error; end
26
+ end
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Omnitrack
6
+ module Helpers
7
+ # Rails view helpers for injecting tracking pixels and JS snippets.
8
+ # Auto-included in ActionView::Base by the Railtie when in frontend mode.
9
+ module ViewHelpers
10
+ # Emit all enabled platform JS/pixel tags as a single HTML blob.
11
+ # Place in your layout <head>:
12
+ # <%= omnitrack_tags %>
13
+ def omnitrack_tags
14
+ return "".html_safe unless Omnitrack.frontend_mode?
15
+
16
+ tags = []
17
+ tags << google_analytics_tag if Omnitrack.config.adapter_enabled?(:google_analytics)
18
+ tags << meta_pixel_tag if Omnitrack.config.adapter_enabled?(:meta)
19
+ tags << tiktok_pixel_tag if Omnitrack.config.adapter_enabled?(:tiktok)
20
+ tags << snapchat_pixel_tag if Omnitrack.config.adapter_enabled?(:snapchat)
21
+
22
+ tags.compact.join("\n").html_safe
23
+ end
24
+
25
+ # Emit a single <script> block to push a custom event to all enabled platforms.
26
+ # Place after a key action (e.g., order confirmation page):
27
+ # <%= omnitrack_event_tag("purchase", value: 99.00, currency: "USD") %>
28
+ def omnitrack_event_tag(event_name, **payload)
29
+ return "".html_safe unless Omnitrack.frontend_mode?
30
+
31
+ js = []
32
+ js << gtag_event(event_name, payload) if Omnitrack.config.adapter_enabled?(:google_analytics)
33
+ js << fbq_event(event_name, payload) if Omnitrack.config.adapter_enabled?(:meta)
34
+ js << ttq_event(event_name, payload) if Omnitrack.config.adapter_enabled?(:tiktok)
35
+
36
+ return "".html_safe if js.empty?
37
+
38
+ content_tag(:script) { js.join("\n").html_safe }
39
+ end
40
+
41
+ private
42
+
43
+ # ------------------------------------------------------------------
44
+ # Platform-specific tag builders
45
+ # ------------------------------------------------------------------
46
+
47
+ def google_analytics_tag
48
+ measurement_id = Omnitrack.config.adapter_config(:google_analytics)[:measurement_id]
49
+ return unless measurement_id.present?
50
+
51
+ <<~HTML
52
+ <!-- OmniTrack: Google Analytics 4 -->
53
+ <script async src="https://www.googletagmanager.com/gtag/js?id=#{measurement_id}"></script>
54
+ <script>
55
+ window.dataLayer = window.dataLayer || [];
56
+ function gtag(){dataLayer.push(arguments);}
57
+ gtag('js', new Date());
58
+ gtag('config', '#{measurement_id}');
59
+ </script>
60
+ HTML
61
+ end
62
+
63
+ def meta_pixel_tag
64
+ pixel_id = Omnitrack.config.adapter_config(:meta)[:pixel_id]
65
+ return unless pixel_id.present?
66
+
67
+ <<~HTML
68
+ <!-- OmniTrack: Meta Pixel -->
69
+ <script>
70
+ !function(f,b,e,v,n,t,s)
71
+ {if(f.fbq)return;n=f.fbq=function(){n.callMethod?
72
+ n.callMethod.apply(n,arguments):n.queue.push(arguments)};
73
+ if(!f._fbq)f._fbq=n;n.push=n;n.loaded=!0;n.version='2.0';
74
+ n.queue=[];t=b.createElement(e);t.async=!0;
75
+ t.src=v;s=b.getElementsByTagName(e)[0];
76
+ s.parentNode.insertBefore(t,s)}(window, document,'script',
77
+ 'https://connect.facebook.net/en_US/fbevents.js');
78
+ fbq('init', '#{pixel_id}');
79
+ fbq('track', 'PageView');
80
+ </script>
81
+ <noscript>
82
+ <img height="1" width="1" style="display:none"
83
+ src="https://www.facebook.com/tr?id=#{pixel_id}&ev=PageView&noscript=1"/>
84
+ </noscript>
85
+ HTML
86
+ end
87
+
88
+ def tiktok_pixel_tag
89
+ pixel_id = Omnitrack.config.adapter_config(:tiktok)[:pixel_id]
90
+ return unless pixel_id.present?
91
+
92
+ <<~HTML
93
+ <!-- OmniTrack: TikTok Pixel -->
94
+ <script>
95
+ !function (w, d, t) {
96
+ w.TiktokAnalyticsObject=t;var ttq=w[t]=w[t]||[];
97
+ ttq.methods=["page","track","identify","instances","debug","on","off","once","ready","alias","group","enableCookie","disableCookie"];
98
+ ttq.setAndDefer=function(t,e){t[e]=function(){t.push([e].concat(Array.prototype.slice.call(arguments,0)))}};
99
+ for(var i=0;i<ttq.methods.length;i++)ttq.setAndDefer(ttq,ttq.methods[i]);
100
+ ttq.instance=function(t){for(var e=ttq._i[t]||[],n=0;n<ttq.methods.length;n++)ttq.setAndDefer(e,ttq.methods[n]);return e};
101
+ ttq.load=function(e,n){var i="https://analytics.tiktok.com/i18n/pixel/events.js";
102
+ ttq._i=ttq._i||{},ttq._i[e]=[],ttq._i[e]._u=i,ttq._t=ttq._t||{},ttq._t[e]=+new Date,ttq._o=ttq._o||{},ttq._o[e]=n||{};
103
+ var o=document.createElement("script");o.type="text/javascript";o.async=!0;o.src=i+"?sdkid="+e+"&lib="+t;
104
+ var a=document.getElementsByTagName("script")[0];a.parentNode.insertBefore(o,a)};
105
+ ttq.load('#{pixel_id}');
106
+ ttq.page();
107
+ }(window, document, 'ttq');
108
+ </script>
109
+ HTML
110
+ end
111
+
112
+ def snapchat_pixel_tag
113
+ pixel_id = Omnitrack.config.adapter_config(:snapchat)[:pixel_id]
114
+ return unless pixel_id.present?
115
+
116
+ <<~HTML
117
+ <!-- OmniTrack: Snapchat Pixel -->
118
+ <script type='text/javascript'>
119
+ (function(e,t,n){if(e.snaptr)return;var a=e.snaptr=function()
120
+ {a.handleRequest?a.handleRequest.apply(a,arguments):a.queue.push(arguments)};
121
+ a.queue=[];var s='script';r=t.createElement(s);r.async=!0;
122
+ r.src=n;var u=t.getElementsByTagName(s)[0];
123
+ u.parentNode.insertBefore(r,u);})(window,document,
124
+ 'https://sc-static.net/scevent.min.js');
125
+ snaptr('init', '#{pixel_id}', {});
126
+ snaptr('track', 'PAGE_VIEW');
127
+ </script>
128
+ HTML
129
+ end
130
+
131
+ # ------------------------------------------------------------------
132
+ # JS event helpers
133
+ # ------------------------------------------------------------------
134
+
135
+ def gtag_event(event_name, payload)
136
+ "gtag('event', '#{event_name}', #{payload.to_json});"
137
+ end
138
+
139
+ def fbq_event(event_name, payload)
140
+ "fbq('track', '#{event_name}', #{payload.to_json});"
141
+ end
142
+
143
+ def ttq_event(event_name, payload)
144
+ "ttq.track('#{event_name}', #{payload.to_json});"
145
+ end
146
+ end
147
+ end
148
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Omnitrack
4
+ module Jobs
5
+ # ActiveJob wrapper for asynchronous event dispatch.
6
+ # Enqueued automatically when Omnitrack.config.async == true.
7
+ #
8
+ # The job serializes the operation + arguments to JSON so they survive
9
+ # the queue boundary. All arguments must be plain Ruby primitives.
10
+ #
11
+ class TrackingJob < ActiveJob::Base
12
+ queue_as { Omnitrack.config.queue_name }
13
+
14
+ # @param operation [String] stringified symbol, e.g. "track_event"
15
+ # @param args [Array] JSON-serialisable positional arguments
16
+ def perform(operation, *args)
17
+ Omnitrack::Pipeline::Dispatcher.dispatch(operation.to_sym, *args)
18
+ rescue StandardError => e
19
+ Omnitrack.logger.error("tracking_job.error",
20
+ operation: operation,
21
+ error: e.class.name,
22
+ message: e.message)
23
+ raise # Re-raise so ActiveJob retry logic still works
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "logger"
5
+ require "json"
6
+
7
+ module Omnitrack
8
+ # Structured JSON logger that writes to a dedicated omnitrack.log file.
9
+ # All public methods are thread-safe (delegating to Ruby's stdlib Logger).
10
+ class Logger
11
+ SEVERITY_MAP = {
12
+ debug: ::Logger::DEBUG,
13
+ info: ::Logger::INFO,
14
+ warn: ::Logger::WARN,
15
+ error: ::Logger::ERROR,
16
+ none: ::Logger::UNKNOWN
17
+ }.freeze
18
+
19
+ def initialize(log_file:, log_level: :info)
20
+ @level = log_level.to_sym
21
+ @log_file = log_file
22
+ @backend = build_backend(log_file)
23
+ apply_level!
24
+ end
25
+
26
+ # -------------------------------------------------------------------
27
+ # Public logging API
28
+ # -------------------------------------------------------------------
29
+
30
+ def debug(message, **context)
31
+ write(:debug, message, context)
32
+ end
33
+
34
+ def info(message, **context)
35
+ write(:info, message, context)
36
+ end
37
+
38
+ def warn(message, **context)
39
+ write(:warn, message, context)
40
+ end
41
+
42
+ def error(message, **context)
43
+ write(:error, message, context)
44
+ end
45
+
46
+ # Convenience: log an adapter request
47
+ def log_request(adapter:, event:, payload: {})
48
+ info("adapter.request",
49
+ adapter: adapter,
50
+ event: event,
51
+ payload: payload)
52
+ end
53
+
54
+ # Convenience: log an adapter response
55
+ def log_response(adapter:, event:, status:, body: nil, duration_ms: nil)
56
+ info("adapter.response",
57
+ adapter: adapter,
58
+ event: event,
59
+ status: status,
60
+ body: body,
61
+ duration_ms: duration_ms)
62
+ end
63
+
64
+ # Convenience: log an adapter error
65
+ def log_error(adapter:, event:, error:, attempt: nil)
66
+ error("adapter.error",
67
+ adapter: adapter,
68
+ event: event,
69
+ error: error.class.name,
70
+ message: error.message,
71
+ attempt: attempt,
72
+ backtrace: error.backtrace&.first(5))
73
+ end
74
+
75
+ # Re-open the log file (useful after logrotate)
76
+ def reopen
77
+ @backend.reopen(@log_file)
78
+ end
79
+
80
+ private
81
+
82
+ def build_backend(log_file)
83
+ # Ensure directory exists
84
+ dir = File.dirname(log_file)
85
+ FileUtils.mkdir_p(dir) unless Dir.exist?(dir)
86
+
87
+ backend = ::Logger.new(log_file, "daily")
88
+ backend.formatter = method(:json_formatter)
89
+ backend
90
+ end
91
+
92
+ def apply_level!
93
+ @backend.level = SEVERITY_MAP.fetch(@level, ::Logger::INFO)
94
+ end
95
+
96
+ def write(severity, message, context)
97
+ return if @level == :none
98
+
99
+ @backend.public_send(severity, build_payload(message, context))
100
+ rescue StandardError
101
+ # Never let logger failures propagate into the main app
102
+ end
103
+
104
+ def build_payload(message, context)
105
+ {
106
+ timestamp: Time.now.utc.iso8601(3),
107
+ message: message
108
+ }.merge(compact_context(context))
109
+ end
110
+
111
+ def compact_context(context)
112
+ context.reject { |_, v| v.nil? }
113
+ end
114
+
115
+ def json_formatter(_severity, _time, _progname, msg)
116
+ payload = msg.is_a?(Hash) ? msg : { message: msg.to_s }
117
+ "#{JSON.generate(payload)}\n"
118
+ rescue StandardError
119
+ "#{msg}\n"
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rack"
4
+
5
+ module Omnitrack
6
+ module Middleware
7
+ # Rack middleware that:
8
+ # 1. Builds an Omnitrack::Context from the incoming request
9
+ # 2. Makes it available thread-locally as Omnitrack::Context.current
10
+ # 3. Clears it after the response (prevents cross-request data leakage)
11
+ #
12
+ # Auto-inserted by the Railtie; can also be added manually:
13
+ # config.middleware.use Omnitrack::Middleware::RequestTracker
14
+ #
15
+ class RequestTracker
16
+ def initialize(app)
17
+ @app = app
18
+ end
19
+
20
+ def call(env)
21
+ request = Rack::Request.new(env)
22
+
23
+ if Omnitrack.config.auto_capture
24
+ ctx = Omnitrack::Context.from_request(request)
25
+ Omnitrack::Context.current = ctx
26
+
27
+ Omnitrack.logger.debug("middleware.request",
28
+ method: request.request_method,
29
+ path: request.path,
30
+ click_ids: ctx.click_ids,
31
+ utm_params: ctx.utm_params)
32
+ end
33
+
34
+ @app.call(env)
35
+ ensure
36
+ Omnitrack::Context.clear!
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Omnitrack
4
+ module Pipeline
5
+ # Central event dispatcher.
6
+ # Instantiates all enabled adapters, runs the requested operation on each,
7
+ # and returns an Omnitrack::MultiResult.
8
+ #
9
+ # Usage (called by Omnitrack module methods):
10
+ # Omnitrack::Pipeline::Dispatcher.dispatch(:track_event, "purchase", payload)
11
+ #
12
+ class Dispatcher
13
+ class << self
14
+ # @param operation [Symbol] one of :track_event, :track_conversion, :identify_user
15
+ # @param args [Array] forwarded to the adapter method
16
+ # @return [Omnitrack::MultiResult]
17
+ def dispatch(operation, *args)
18
+ adapters = Omnitrack::Registry.enabled_adapters
19
+
20
+ if adapters.empty?
21
+ Omnitrack.logger.warn("dispatcher.no_adapters",
22
+ operation: operation)
23
+ return Omnitrack::MultiResult.new([
24
+ Omnitrack::Result.skipped(reason: "no enabled adapters")
25
+ ])
26
+ end
27
+
28
+ results = adapters.map do |adapter|
29
+ dispatch_to(adapter, operation, args)
30
+ end
31
+
32
+ Omnitrack::MultiResult.new(results)
33
+ end
34
+
35
+ private
36
+
37
+ def dispatch_to(adapter, operation, args)
38
+ unless adapter.respond_to?(operation)
39
+ return Omnitrack::Result.skipped(
40
+ reason: "#{adapter.name} does not implement #{operation}",
41
+ adapter: adapter.name
42
+ )
43
+ end
44
+
45
+ adapter.public_send(operation, *args)
46
+ rescue StandardError => e
47
+ # Adapter-level isolation — one adapter blowing up must not affect others
48
+ Omnitrack.logger.log_error(adapter: adapter.name,
49
+ event: operation.to_s,
50
+ error: e)
51
+ Omnitrack::Result.failure(error: e, adapter: adapter.name)
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end