mbuzz 0.6.2

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,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mbuzz
4
+ class ConfigurationError < StandardError; end
5
+
6
+ class Configuration
7
+ attr_accessor :api_key, :api_url, :enabled, :debug, :timeout, :batch_size, :flush_interval, :logger,
8
+ :skip_paths, :skip_extensions
9
+
10
+ # Default paths to skip - health checks, assets, etc.
11
+ DEFAULT_SKIP_PATHS = %w[
12
+ /up
13
+ /health
14
+ /healthz
15
+ /ping
16
+ /cable
17
+ /assets
18
+ /packs
19
+ /rails/active_storage
20
+ /api
21
+ ].freeze
22
+
23
+ # Default extensions to skip - static assets
24
+ DEFAULT_SKIP_EXTENSIONS = %w[
25
+ .js .css .map .png .jpg .jpeg .gif .ico .svg .woff .woff2 .ttf .eot .webp
26
+ ].freeze
27
+
28
+ def initialize
29
+ @api_url = "https://mbuzz.co/api/v1"
30
+ @enabled = true
31
+ @debug = false
32
+ @timeout = 5
33
+ @batch_size = 50
34
+ @flush_interval = 30
35
+ @skip_paths = []
36
+ @skip_extensions = []
37
+ end
38
+
39
+ def validate!
40
+ raise ConfigurationError, "api_key is required" if api_key.nil? || api_key.empty?
41
+ end
42
+
43
+ def all_skip_paths
44
+ DEFAULT_SKIP_PATHS + Array(skip_paths)
45
+ end
46
+
47
+ def all_skip_extensions
48
+ DEFAULT_SKIP_EXTENSIONS + Array(skip_extensions)
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mbuzz
4
+ module ControllerHelpers
5
+ def mbuzz_track(event_type, properties: {})
6
+ Client.track(
7
+ user_id: mbuzz_user_id,
8
+ visitor_id: mbuzz_visitor_id,
9
+ event_type: event_type,
10
+ properties: properties
11
+ )
12
+ end
13
+
14
+ def mbuzz_identify(traits: {})
15
+ Client.identify(
16
+ user_id: mbuzz_user_id,
17
+ visitor_id: mbuzz_visitor_id,
18
+ traits: traits
19
+ )
20
+ end
21
+
22
+ def mbuzz_user_id
23
+ request.env[ENV_USER_ID_KEY]
24
+ end
25
+
26
+ def mbuzz_visitor_id
27
+ request.env[ENV_VISITOR_ID_KEY]
28
+ end
29
+
30
+ def mbuzz_session_id
31
+ request.env[ENV_SESSION_ID_KEY]
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rack"
4
+
5
+ module Mbuzz
6
+ module Middleware
7
+ class Tracking
8
+ attr_reader :app, :request
9
+
10
+ def initialize(app)
11
+ @app = app
12
+ end
13
+
14
+ def call(env)
15
+ return app.call(env) if skip_request?(env)
16
+
17
+ reset_request_state!
18
+ @request = Rack::Request.new(env)
19
+
20
+ env[ENV_VISITOR_ID_KEY] = visitor_id
21
+ env[ENV_USER_ID_KEY] = user_id
22
+ env[ENV_SESSION_ID_KEY] = session_id
23
+
24
+ RequestContext.with_context(request: request) do
25
+ create_session_if_new
26
+
27
+ status, headers, body = app.call(env)
28
+ set_visitor_cookie(headers)
29
+ set_session_cookie(headers)
30
+ [status, headers, body]
31
+ end
32
+ end
33
+
34
+ # Path filtering - skip health checks, static assets, etc.
35
+
36
+ def skip_request?(env)
37
+ path = env["PATH_INFO"].to_s.downcase
38
+
39
+ skip_by_path?(path) || skip_by_extension?(path)
40
+ end
41
+
42
+ def skip_by_path?(path)
43
+ Mbuzz.config.all_skip_paths.any? { |skip| path.start_with?(skip) }
44
+ end
45
+
46
+ def skip_by_extension?(path)
47
+ Mbuzz.config.all_skip_extensions.any? { |ext| path.end_with?(ext) }
48
+ end
49
+
50
+ private
51
+
52
+ def reset_request_state!
53
+ @request = nil
54
+ @visitor_id = nil
55
+ @session_id = nil
56
+ @user_id = nil
57
+ end
58
+
59
+ def visitor_id
60
+ @visitor_id ||= visitor_id_from_cookie || Visitor::Identifier.generate
61
+ end
62
+
63
+ def visitor_id_from_cookie
64
+ request.cookies[VISITOR_COOKIE_NAME]
65
+ end
66
+
67
+ def user_id
68
+ @user_id ||= user_id_from_session
69
+ end
70
+
71
+ def user_id_from_session
72
+ request.session[SESSION_USER_ID_KEY] if request.session
73
+ end
74
+
75
+ def set_visitor_cookie(headers)
76
+ Rack::Utils.set_cookie_header!(headers, VISITOR_COOKIE_NAME, visitor_cookie_options)
77
+ end
78
+
79
+ def visitor_cookie_options
80
+ base_cookie_options.merge(
81
+ value: visitor_id,
82
+ max_age: VISITOR_COOKIE_MAX_AGE
83
+ )
84
+ end
85
+
86
+ # Session ID management
87
+
88
+ def session_id
89
+ @session_id ||= session_id_from_cookie || generate_session_id
90
+ end
91
+
92
+ def session_id_from_cookie
93
+ request.cookies[SESSION_COOKIE_NAME]
94
+ end
95
+
96
+ def generate_session_id
97
+ SecureRandom.hex(32)
98
+ end
99
+
100
+ def new_session?
101
+ session_id_from_cookie.nil?
102
+ end
103
+
104
+ # Session creation
105
+
106
+ def create_session_if_new
107
+ return unless new_session?
108
+
109
+ create_session_async
110
+ end
111
+
112
+ def create_session_async
113
+ Thread.new { create_session }
114
+ end
115
+
116
+ def create_session
117
+ Client.session(
118
+ visitor_id: visitor_id,
119
+ session_id: session_id,
120
+ url: request.url,
121
+ referrer: request.referer
122
+ )
123
+ rescue => e
124
+ log_session_error(e)
125
+ end
126
+
127
+ def log_session_error(error)
128
+ Mbuzz.config.logger&.error("Session creation failed: #{error.message}")
129
+ end
130
+
131
+ # Session cookie
132
+
133
+ def set_session_cookie(headers)
134
+ Rack::Utils.set_cookie_header!(headers, SESSION_COOKIE_NAME, session_cookie_options)
135
+ end
136
+
137
+ def session_cookie_options
138
+ base_cookie_options.merge(
139
+ value: session_id,
140
+ max_age: SESSION_COOKIE_MAX_AGE
141
+ )
142
+ end
143
+
144
+ # Shared cookie options
145
+
146
+ def base_cookie_options
147
+ options = {
148
+ path: VISITOR_COOKIE_PATH,
149
+ httponly: true,
150
+ same_site: VISITOR_COOKIE_SAME_SITE
151
+ }
152
+ options[:secure] = true if request.ssl?
153
+ options
154
+ end
155
+ end
156
+ end
157
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mbuzz
4
+ class Railtie < Rails::Railtie
5
+ initializer "mbuzz.configure_rails" do |app|
6
+ app.middleware.use Mbuzz::Middleware::Tracking
7
+
8
+ ActiveSupport.on_load(:action_controller) do
9
+ include Mbuzz::ControllerHelpers
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mbuzz
4
+ class RequestContext
5
+ def self.with_context(request:)
6
+ Thread.current[:mbuzz_request] = request
7
+ yield
8
+ ensure
9
+ Thread.current[:mbuzz_request] = nil
10
+ end
11
+
12
+ def self.current
13
+ return nil unless Thread.current[:mbuzz_request]
14
+
15
+ new(Thread.current[:mbuzz_request])
16
+ end
17
+
18
+ attr_reader :request
19
+
20
+ def initialize(request)
21
+ @request = request
22
+ end
23
+
24
+ def url
25
+ @request.url
26
+ end
27
+
28
+ def referrer
29
+ @request.referrer
30
+ end
31
+
32
+ def user_agent
33
+ @request.user_agent
34
+ end
35
+
36
+ def enriched_properties(custom = {})
37
+ { url: url, referrer: referrer }.compact.merge(custom)
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mbuzz
4
+ VERSION = "0.6.2"
5
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module Mbuzz
6
+ module Visitor
7
+ class Identifier
8
+ def self.generate
9
+ SecureRandom.hex(32)
10
+ end
11
+ end
12
+ end
13
+ end
data/lib/mbuzz.rb ADDED
@@ -0,0 +1,178 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "mbuzz/version"
4
+ require_relative "mbuzz/configuration"
5
+ require_relative "mbuzz/visitor/identifier"
6
+ require_relative "mbuzz/request_context"
7
+ require_relative "mbuzz/api"
8
+ require_relative "mbuzz/client"
9
+ require_relative "mbuzz/middleware/tracking"
10
+ require_relative "mbuzz/controller_helpers"
11
+
12
+ require_relative "mbuzz/railtie" if defined?(Rails::Railtie)
13
+
14
+ module Mbuzz
15
+ class Error < StandardError; end
16
+
17
+ EVENTS_PATH = "/events"
18
+ IDENTIFY_PATH = "/identify"
19
+ CONVERSIONS_PATH = "/conversions"
20
+ SESSIONS_PATH = "/sessions"
21
+
22
+ VISITOR_COOKIE_NAME = "_mbuzz_vid"
23
+ VISITOR_COOKIE_MAX_AGE = 60 * 60 * 24 * 365 * 2 # 2 years
24
+ VISITOR_COOKIE_PATH = "/"
25
+ VISITOR_COOKIE_SAME_SITE = "Lax"
26
+
27
+ SESSION_COOKIE_NAME = "_mbuzz_sid"
28
+ SESSION_COOKIE_MAX_AGE = 30 * 60 # 30 minutes
29
+
30
+ SESSION_USER_ID_KEY = "user_id"
31
+ ENV_USER_ID_KEY = "mbuzz.user_id"
32
+ ENV_VISITOR_ID_KEY = "mbuzz.visitor_id"
33
+ ENV_SESSION_ID_KEY = "mbuzz.session_id"
34
+
35
+ # ============================================================================
36
+ # Configuration
37
+ # ============================================================================
38
+
39
+ def self.config
40
+ @config ||= Configuration.new
41
+ end
42
+
43
+ # New simplified configuration method (v0.5.0)
44
+ # @param api_key [String] Your mbuzz API key
45
+ # @param api_url [String, nil] Override API URL (defaults to https://mbuzz.co/api/v1)
46
+ # @param session_timeout [Integer, nil] Session timeout in seconds
47
+ # @param debug [Boolean, nil] Enable debug logging
48
+ # @param skip_paths [Array<String>, nil] Additional paths to skip tracking (e.g., ["/admin", "/internal"])
49
+ # @param skip_extensions [Array<String>, nil] Additional extensions to skip (e.g., [".pdf"])
50
+ def self.init(api_key:, api_url: nil, session_timeout: nil, debug: nil, skip_paths: nil, skip_extensions: nil)
51
+ config.api_key = api_key
52
+ config.api_url = api_url if api_url
53
+ config.session_timeout = session_timeout if session_timeout
54
+ config.debug = debug unless debug.nil?
55
+ config.skip_paths = skip_paths if skip_paths
56
+ config.skip_extensions = skip_extensions if skip_extensions
57
+ config
58
+ end
59
+
60
+ # @deprecated Use {.init} instead
61
+ def self.configure
62
+ warn "[DEPRECATION] Mbuzz.configure is deprecated. Use Mbuzz.init(api_key: ...) instead."
63
+ yield(config)
64
+ end
65
+
66
+ # ============================================================================
67
+ # Context Accessors
68
+ # ============================================================================
69
+
70
+ def self.visitor_id
71
+ RequestContext.current&.request&.env&.dig(ENV_VISITOR_ID_KEY) || fallback_visitor_id
72
+ end
73
+
74
+ def self.fallback_visitor_id
75
+ @fallback_visitor_id ||= Visitor::Identifier.generate
76
+ end
77
+ private_class_method :fallback_visitor_id
78
+
79
+ def self.user_id
80
+ RequestContext.current&.request&.env&.dig(ENV_USER_ID_KEY)
81
+ end
82
+
83
+ def self.session_id
84
+ RequestContext.current&.request&.env&.dig(ENV_SESSION_ID_KEY)
85
+ end
86
+
87
+ # ============================================================================
88
+ # 4-Call Model API
89
+ # ============================================================================
90
+
91
+ # Track an event (journey step)
92
+ #
93
+ # @param event_type [String] The name of the event
94
+ # @param properties [Hash] Custom event properties (url, referrer auto-added)
95
+ # @return [Hash, false] Result hash on success, false on failure
96
+ #
97
+ # @example
98
+ # Mbuzz.event("add_to_cart", product_id: "SKU-123", price: 49.99)
99
+ #
100
+ def self.event(event_type, **properties)
101
+ Client.track(
102
+ visitor_id: visitor_id,
103
+ session_id: session_id,
104
+ user_id: user_id,
105
+ event_type: event_type,
106
+ properties: enriched_properties(properties)
107
+ )
108
+ end
109
+
110
+ # @deprecated Use {.event} instead
111
+ def self.track(event_type, properties: {})
112
+ warn "[DEPRECATION] Mbuzz.track is deprecated. Use Mbuzz.event(event_type, **properties) instead."
113
+ event(event_type, **properties)
114
+ end
115
+
116
+ # Track a conversion (revenue-generating outcome)
117
+ #
118
+ # @param conversion_type [String] The type of conversion
119
+ # @param revenue [Numeric, nil] Revenue amount
120
+ # @param user_id [String, nil] User ID for acquisition-linked conversions
121
+ # @param is_acquisition [Boolean] Mark this as the acquisition conversion for this user
122
+ # @param inherit_acquisition [Boolean] Inherit attribution from user's acquisition conversion
123
+ # @param properties [Hash] Custom properties
124
+ # @return [Hash, false] Result hash on success, false on failure
125
+ #
126
+ # @example Basic conversion
127
+ # Mbuzz.conversion("purchase", revenue: 99.99, order_id: "ORD-123")
128
+ #
129
+ # @example Acquisition conversion (marks signup as THE acquisition moment)
130
+ # Mbuzz.conversion("signup", user_id: "user_123", is_acquisition: true)
131
+ #
132
+ # @example Recurring revenue (inherits attribution from acquisition)
133
+ # Mbuzz.conversion("payment", user_id: "user_123", revenue: 49.00, inherit_acquisition: true)
134
+ #
135
+ def self.conversion(conversion_type, revenue: nil, user_id: nil, is_acquisition: false, inherit_acquisition: false, **properties)
136
+ Client.conversion(
137
+ visitor_id: visitor_id,
138
+ user_id: user_id,
139
+ conversion_type: conversion_type,
140
+ revenue: revenue,
141
+ is_acquisition: is_acquisition,
142
+ inherit_acquisition: inherit_acquisition,
143
+ properties: enriched_properties(properties)
144
+ )
145
+ end
146
+
147
+ # Identify a user and optionally link to current visitor
148
+ #
149
+ # @param user_id [String, Numeric] Your application's user identifier
150
+ # @param traits [Hash] User attributes (email, name, plan, etc.)
151
+ # @param visitor_id [String, nil] Explicit visitor ID (auto-captured from cookie if nil)
152
+ # @return [Hash, false] Result hash with identity_id and visitor_linked, or false on failure
153
+ #
154
+ # @example Basic identification
155
+ # Mbuzz.identify("user_123", traits: { email: "jane@example.com" })
156
+ #
157
+ # @example With explicit visitor_id
158
+ # Mbuzz.identify("user_123", visitor_id: "abc123...", traits: { email: "jane@example.com" })
159
+ #
160
+ def self.identify(user_id, traits: {}, visitor_id: nil)
161
+ Client.identify(
162
+ user_id: user_id,
163
+ visitor_id: visitor_id || self.visitor_id,
164
+ traits: traits
165
+ )
166
+ end
167
+
168
+ # ============================================================================
169
+ # Private Helpers
170
+ # ============================================================================
171
+
172
+ def self.enriched_properties(custom_properties)
173
+ return custom_properties unless RequestContext.current
174
+
175
+ RequestContext.current.enriched_properties(custom_properties)
176
+ end
177
+ private_class_method :enriched_properties
178
+ end