mbuzz 0.8.2 → 0.10.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 58bb33f56016e563027651d3fe6e0d5a8bd55a5b51e9920693a74b380380612a
4
- data.tar.gz: 169f9818380dad8e239311ed1bd9c3501eb96b24d3f650a90679af1ad91630d0
3
+ metadata.gz: 03aa54a68ab1b6e6313d69c910c88aec7241e19d65b72b9cee37a6e187454692
4
+ data.tar.gz: 3ddce95a2f8795635278785f403daf6a41e15bc31fc01c3990989c20911ebc21
5
5
  SHA512:
6
- metadata.gz: 684d91a340148e2a0762a476c401b9c8dc21f0bbff61882c8740394cc00ff72ea7ed65264f0d37880bac055e8add2b62548d4b6b474b427fe3fb8d03544d126e
7
- data.tar.gz: 446e948eb88580966f4aaa9e5e0757510e7569f84fecf6bd1967c8ac364745b445e71484584807ae525f298b2b6e939bdeff6357982b47d1bf3cea3c79145808
6
+ metadata.gz: 4ba8606ecbeec460a9145937b8d123c93c455476d92795e9ddf2b291082e57cdce4818cd709a427ed28d125e0ee6766110f0425039c43bfba75328f5da6f756f
7
+ data.tar.gz: 8d9233587278aaccb19fd5f124e486e1385b626b2eb4db3d7125ab0e64d6b1500117a33fe89e1fad84c846e6a292e6183760013ecb33e16d44082036d2c5a299
data/CHANGELOG.md CHANGED
@@ -5,6 +5,30 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.10.0] - 2026-09-08
9
+
10
+ ### BREAKING — the page snippet is now required
11
+
12
+ - **A page response no longer sets the visitor cookie.** Only `POST /_mbuzz/session` mints, and the inline snippet in the README's "Full-page caching" section is what calls it. **Upgrading without adding that snippet stops tracking entirely** — no cookie is ever minted, so no visitor exists and every event is dropped.
13
+
14
+ ### Fixed
15
+
16
+ - **A cached page no longer hands every visitor the same id.** The page response carried a `Set-Cookie`, and a full-page cache stored it and replayed it to everyone — so unrelated people merged into a single journey. Corruption rather than loss: every row still exists, each attributed to the wrong person, and nothing looks missing. The page path now uses only the cookie the browser already holds; the uncached session endpoint is the sole place a visitor is minted.
17
+
18
+ Found by the cache harness at `sdk_integration_tests/scenarios/page_cache_test.rb`, and it could not have been found any other way: in isolation "reuse the cookie the browser presents" is correct — it is what lets a returning visitor keep their id. The bug exists only once a cache has handed one cookie to many people. The WordPress plugin reached the same conclusion first (`CookieBootstrap::CONTEXT_PAGE`).
19
+
20
+ ## [0.9.0] - 2026-09-02
21
+
22
+ ### Added
23
+
24
+ - **A dropped event or conversion now says so.** `Mbuzz.event` and `Mbuzz.conversion` return `false` when there is no `visitor_id` and no `user_id` — previously with no request, no log and no signal of any kind. That silence is what made the caching bug above invisible on a live account for a full day. They now warn at `Rails.logger.warn` (or stderr outside Rails), naming the dropped call and pointing at the session endpoint. Deliberately **not** gated behind `debug`: the people who hit this are precisely the ones not running in debug.
25
+ - **Attribution now survives a full-page cache.** A cached page is answered without entering the Rack stack, so the tracking middleware never ran, no visitor cookie was set, and every later event was dropped for having no one to attribute it to — silently, while the page rendered perfectly. `Mbuzz::Middleware::SessionEndpoint` answers `POST /_mbuzz/session`, a path caches don't store, and the server sets the cookie on that response. Mounted automatically in Rails; add it ahead of `Tracking` in Rack/Sinatra apps. See "Full-page caching" in the README for the one-time page snippet.
26
+
27
+ ### Notes
28
+
29
+ - The visitor id is still never created or read in JavaScript. It stays `HttpOnly` and server-set, preserving its full two-year life — a cookie written by `document.cookie` is capped at 7 days under Safari's ITP, and 24 hours after an ad click.
30
+ - Inline the page snippet rather than serving it as a file: asset optimisers delay external scripts until first interaction, which would miss any visitor who lands and converts without clicking first.
31
+
8
32
  ## [0.8.2] - 2026-03-15
9
33
 
10
34
  ### Changed
data/README.md CHANGED
@@ -188,10 +188,53 @@ require 'mbuzz'
188
188
 
189
189
  Mbuzz.init(api_key: ENV['MBUZZ_API_KEY'])
190
190
 
191
+ use Mbuzz::Middleware::SessionEndpoint # see "Full-page caching" below
191
192
  use Mbuzz::Middleware::Tracking
192
193
  run MyApp
193
194
  ```
194
195
 
196
+ ## Full-page caching — the snippet below is REQUIRED
197
+
198
+ **Add this to your layout or nothing is tracked.** Since 0.10.0 a page response never sets the
199
+ visitor cookie: only `POST /_mbuzz/session` mints, and the snippet is what calls it.
200
+
201
+ A page response can be stored by a full-page cache (Cloudflare, Varnish, nginx, Rack::Cache, a
202
+ CDN) and replayed to every visitor. A `Set-Cookie` sitting in that cache hands everyone the
203
+ *first* visitor's id, so unrelated people merge into one journey — corruption rather than loss,
204
+ since every row exists and is simply attributed to the wrong person. Minting only on a response
205
+ no cache stores is the only way to prevent it.
206
+
207
+ The same endpoint solves the original problem too: a cached page never enters the Rack stack,
208
+ so the tracking middleware cannot run — but this one request always reaches it.
209
+
210
+ `Mbuzz::Middleware::SessionEndpoint` answers `POST /_mbuzz/session`. Rails mounts it for you;
211
+ Rack and Sinatra apps add it ahead of `Tracking` as shown above.
212
+
213
+ Then call it once per page, from your layout:
214
+
215
+ ```html
216
+ <script>
217
+ fetch('/_mbuzz/session', {
218
+ method: 'POST',
219
+ headers: { 'Content-Type': 'application/json' },
220
+ body: JSON.stringify({ url: location.href, referrer: document.referrer || '' }),
221
+ credentials: 'same-origin',
222
+ keepalive: true
223
+ }).catch(function () {});
224
+ </script>
225
+ ```
226
+
227
+ Two things to keep as they are:
228
+
229
+ - **Inline the script, don't enqueue a file.** Asset optimisers (WP Rocket, LiteSpeed, and the
230
+ Rails equivalents) delay external scripts until the visitor first interacts. A visitor who
231
+ lands and converts without clicking anything first would never be established.
232
+ - **`credentials: 'same-origin'` is required**, or the cookie never comes back.
233
+
234
+ The visitor id is never created or read in JavaScript. It stays `HttpOnly` and server-set, which
235
+ is what preserves its full two-year life — a cookie written by `document.cookie` is capped at
236
+ 7 days under Safari's ITP, and 24 hours after an ad click.
237
+
195
238
  ## Configuration Options
196
239
 
197
240
  ```ruby
@@ -19,7 +19,7 @@ module Mbuzz
19
19
  end
20
20
 
21
21
  def call
22
- return false unless input_valid?
22
+ return warn_dropped unless input_valid?
23
23
  return proxy_result if proxy_accepted?
24
24
  return false unless conversion_id
25
25
 
@@ -28,6 +28,11 @@ module Mbuzz
28
28
 
29
29
  private
30
30
 
31
+ def warn_dropped
32
+ DroppedCall.warn_missing_identity("conversion", @conversion_type) unless has_identifier?
33
+ false
34
+ end
35
+
31
36
  def input_valid?
32
37
  has_identifier? && present?(@conversion_type) && hash?(@properties)
33
38
  end
@@ -14,7 +14,7 @@ module Mbuzz
14
14
  end
15
15
 
16
16
  def call
17
- return false unless input_valid?
17
+ return warn_dropped unless input_valid?
18
18
  return proxy_result if proxy_accepted?
19
19
  return false unless event
20
20
 
@@ -24,6 +24,13 @@ module Mbuzz
24
24
 
25
25
  private
26
26
 
27
+ # A drop with no request and no log is how this failure stayed invisible
28
+ # on a live account for a full day. Say it out loud.
29
+ def warn_dropped
30
+ DroppedCall.warn_missing_identity("event", @event_type) unless @user_id || @visitor_id
31
+ false
32
+ end
33
+
27
34
  def input_valid?
28
35
  present?(@event_type) && hash?(@properties) && (@user_id || @visitor_id)
29
36
  end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mbuzz
4
+ # Says out loud when a call is dropped for having nothing to attribute it to.
5
+ #
6
+ # Every SDK guarded its send with a bare `return false unless valid?`: no
7
+ # request, no log, nothing. Behind a full-page cache that is the whole failure
8
+ # — the cookie is never minted, so every later event fails this guard and
9
+ # vanishes. It cost a full day on a live account precisely because the silence
10
+ # was total from both sides.
11
+ #
12
+ # Deliberately NOT behind config.debug. The customers who hit this are exactly
13
+ # the ones not running in debug, so a debug-gated warning would be silent for
14
+ # everyone who needs it.
15
+ module DroppedCall
16
+ # Built lazily: SESSION_ENDPOINT_PATH is defined in mbuzz.rb *after* this
17
+ # file is required, so interpolating it at load time would not resolve.
18
+ def self.missing_identity
19
+ "no visitor_id and no user_id. If your pages are served from a full-page " \
20
+ "cache, mount Mbuzz::Middleware::SessionEndpoint and call POST " \
21
+ "#{SESSION_ENDPOINT_PATH} from the page — see the README's " \
22
+ "\"Full-page caching\" section."
23
+ end
24
+
25
+ def self.warn_missing_identity(kind, name)
26
+ emit("dropped #{kind} #{name.inspect}: #{missing_identity}")
27
+ end
28
+
29
+ def self.warn_invalid(kind, name, reason)
30
+ emit("dropped #{kind} #{name.inspect}: #{reason}.")
31
+ end
32
+
33
+ def self.emit(message)
34
+ text = "[mbuzz] #{message}"
35
+ return Rails.logger.warn(text) if defined?(Rails) && Rails.logger
36
+
37
+ Kernel.warn(text)
38
+ end
39
+ private_class_method :emit
40
+ end
41
+ end
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rack"
4
+ require "json"
5
+ require "digest"
6
+ require "securerandom"
7
+
8
+ module Mbuzz
9
+ module Middleware
10
+ # Establishes the visitor from a page a cache served.
11
+ #
12
+ # A cached page never enters the Rack stack, so Tracking never runs and no
13
+ # visitor cookie is set — every later event is then rejected for having no
14
+ # one to attribute it to, silently, while the page renders perfectly.
15
+ #
16
+ # This endpoint is the one request on such a page that always reaches the
17
+ # app. A small script on the page POSTs here; the SERVER mints the cookie on
18
+ # the response. The id is never created or read in JS, so it stays HttpOnly
19
+ # and keeps its full two-year life — a cookie written by document.cookie is
20
+ # capped at 7 days under Safari's ITP, and 24 hours after an ad click.
21
+ #
22
+ # Mount ahead of Tracking:
23
+ #
24
+ # config.middleware.insert_before Mbuzz::Middleware::Tracking,
25
+ # Mbuzz::Middleware::SessionEndpoint
26
+ class SessionEndpoint
27
+ # Nothing to return: the response exists for its Set-Cookie header.
28
+ NO_CONTENT_STATUS = 204
29
+
30
+ def initialize(app)
31
+ @app = app
32
+ end
33
+
34
+ def call(env)
35
+ return @app.call(env) unless session_request?(env)
36
+
37
+ mint(Rack::Request.new(env))
38
+ end
39
+
40
+ private
41
+
42
+ # POST only: a GET is cacheable by an intermediary, which would reintroduce
43
+ # the very bug this endpoint exists to fix.
44
+ def session_request?(env)
45
+ env["REQUEST_METHOD"] == "POST" &&
46
+ env["PATH_INFO"].to_s == SESSION_ENDPOINT_PATH
47
+ end
48
+
49
+ def mint(request)
50
+ context = build_context(request)
51
+ create_session_async(context)
52
+
53
+ [NO_CONTENT_STATUS, response_headers(context, request), []]
54
+ end
55
+
56
+ def build_context(request)
57
+ payload = parse_body(request)
58
+ ip = extract_ip(request)
59
+ user_agent = request.user_agent.to_s
60
+
61
+ {
62
+ visitor_id: resolve_visitor_id(request),
63
+ session_id: SecureRandom.uuid,
64
+ # The page's URL, not ours — a script on the page called us, so our own
65
+ # path would attribute every session to this endpoint.
66
+ url: payload["url"],
67
+ referrer: payload["referrer"],
68
+ ip: ip,
69
+ user_agent: user_agent,
70
+ device_fingerprint: Digest::SHA256.hexdigest("#{ip}|#{user_agent}")[0, 32]
71
+ }.freeze
72
+ end
73
+
74
+ def parse_body(request)
75
+ body = request.body&.read.to_s
76
+ return {} if body.empty?
77
+
78
+ parsed = JSON.parse(body)
79
+ parsed.is_a?(Hash) ? parsed : {}
80
+ rescue JSON::ParserError
81
+ {}
82
+ end
83
+
84
+ def resolve_visitor_id(request)
85
+ request.cookies[VISITOR_COOKIE_NAME] || Visitor::Identifier.generate
86
+ end
87
+
88
+ def create_session_async(context)
89
+ Thread.new do
90
+ Client.session(
91
+ visitor_id: context[:visitor_id],
92
+ session_id: context[:session_id],
93
+ url: context[:url],
94
+ referrer: context[:referrer],
95
+ device_fingerprint: context[:device_fingerprint],
96
+ user_agent: context[:user_agent]
97
+ )
98
+ rescue StandardError => e
99
+ log_error("Session creation failed: #{e.message}") if Mbuzz.config.debug
100
+ end
101
+ end
102
+
103
+ def response_headers(context, request)
104
+ headers = { "cache-control" => "no-store, no-cache, must-revalidate, private" }
105
+ Rack::Utils.set_cookie_header!(headers, VISITOR_COOKIE_NAME, cookie_options(context, request))
106
+ headers
107
+ end
108
+
109
+ def cookie_options(context, request)
110
+ options = {
111
+ value: context[:visitor_id],
112
+ max_age: VISITOR_COOKIE_MAX_AGE,
113
+ path: VISITOR_COOKIE_PATH,
114
+ httponly: true,
115
+ same_site: VISITOR_COOKIE_SAME_SITE
116
+ }
117
+ options[:secure] = true if request.ssl?
118
+ options
119
+ end
120
+
121
+ def extract_ip(request)
122
+ forwarded = request.env["HTTP_X_FORWARDED_FOR"]
123
+ return forwarded.split(",").first.strip if forwarded
124
+
125
+ request.ip
126
+ end
127
+
128
+ def log_error(message)
129
+ return unless defined?(Rails) && Rails.logger
130
+
131
+ Rails.logger.error("[Mbuzz] #{message}")
132
+ end
133
+ end
134
+ end
135
+ end
@@ -15,6 +15,16 @@ module Mbuzz
15
15
  return @app.call(env) if skip_request?(env)
16
16
 
17
17
  request = Rack::Request.new(env)
18
+
19
+ # Only a visitor the browser already holds. This response may be stored
20
+ # by a full-page cache and replayed to everyone, so minting here would
21
+ # put a Set-Cookie in that cache and hand every later visitor the same
22
+ # id — unrelated people merged into one journey. Corruption, not loss:
23
+ # every row still exists, each attributed to the wrong person, and
24
+ # nothing looks missing. A first-time visitor is established a moment
25
+ # later by Middleware::SessionEndpoint, whose POST no cache stores.
26
+ return @app.call(env) unless visitor_id_from_cookie(request)
27
+
18
28
  context = build_request_context(request)
19
29
 
20
30
  env[ENV_VISITOR_ID_KEY] = context[:visitor_id]
@@ -26,9 +36,7 @@ module Mbuzz
26
36
  create_session_async(context, request) if should_create_session?(env)
27
37
 
28
38
  RequestContext.with_context(request: request) do
29
- status, headers, body = @app.call(env)
30
- set_visitor_cookie(headers, context, request)
31
- [status, headers, body]
39
+ @app.call(env)
32
40
  ensure
33
41
  reset_current_attributes
34
42
  end
@@ -87,7 +95,7 @@ module Mbuzz
87
95
  user_agent = request.user_agent.to_s
88
96
 
89
97
  {
90
- visitor_id: resolve_visitor_id(request),
98
+ visitor_id: visitor_id_from_cookie(request),
91
99
  session_id: SecureRandom.uuid,
92
100
  user_id: user_id_from_session(request),
93
101
  url: request.url,
@@ -98,10 +106,6 @@ module Mbuzz
98
106
  }.freeze
99
107
  end
100
108
 
101
- def resolve_visitor_id(request)
102
- visitor_id_from_cookie(request) || Visitor::Identifier.generate
103
- end
104
-
105
109
  def visitor_id_from_cookie(request)
106
110
  request.cookies[VISITOR_COOKIE_NAME]
107
111
  end
@@ -139,33 +143,6 @@ module Mbuzz
139
143
  Rails.logger.error("[Mbuzz] #{message}")
140
144
  end
141
145
 
142
- # Cookie setting - visitor identity only (sessions are server-side)
143
-
144
- def set_visitor_cookie(headers, context, request)
145
- Rack::Utils.set_cookie_header!(
146
- headers,
147
- VISITOR_COOKIE_NAME,
148
- visitor_cookie_options(context, request)
149
- )
150
- end
151
-
152
- def visitor_cookie_options(context, request)
153
- base_cookie_options(request).merge(
154
- value: context[:visitor_id],
155
- max_age: VISITOR_COOKIE_MAX_AGE
156
- )
157
- end
158
-
159
- def base_cookie_options(request)
160
- options = {
161
- path: VISITOR_COOKIE_PATH,
162
- httponly: true,
163
- same_site: VISITOR_COOKIE_SAME_SITE
164
- }
165
- options[:secure] = true if request.ssl?
166
- options
167
- end
168
-
169
146
  # Store context in CurrentAttributes for background job propagation
170
147
  def store_in_current_attributes(context, request)
171
148
  return unless defined?(Mbuzz::Current)
data/lib/mbuzz/railtie.rb CHANGED
@@ -3,6 +3,9 @@
3
3
  module Mbuzz
4
4
  class Railtie < Rails::Railtie
5
5
  initializer "mbuzz.configure_rails" do |app|
6
+ # The endpoint must sit ahead of Tracking: it answers its own path and
7
+ # never falls through, so Tracking should not also process it.
8
+ app.middleware.use Mbuzz::Middleware::SessionEndpoint
6
9
  app.middleware.use Mbuzz::Middleware::Tracking
7
10
 
8
11
  ActiveSupport.on_load(:action_controller) do
data/lib/mbuzz/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Mbuzz
4
- VERSION = "0.8.2"
4
+ VERSION = "0.10.0"
5
5
  end
data/lib/mbuzz.rb CHANGED
@@ -2,11 +2,13 @@
2
2
 
3
3
  require_relative "mbuzz/version"
4
4
  require_relative "mbuzz/configuration"
5
+ require_relative "mbuzz/dropped_call"
5
6
  require_relative "mbuzz/visitor/identifier"
6
7
  require_relative "mbuzz/request_context"
7
8
  require_relative "mbuzz/api"
8
9
  require_relative "mbuzz/client"
9
10
  require_relative "mbuzz/middleware/tracking"
11
+ require_relative "mbuzz/middleware/session_endpoint"
10
12
  require_relative "mbuzz/controller_helpers"
11
13
 
12
14
  # CurrentAttributes for automatic background job context propagation (Rails only)
@@ -27,6 +29,10 @@ module Mbuzz
27
29
  VISITOR_COOKIE_PATH = "/"
28
30
  VISITOR_COOKIE_SAME_SITE = "Lax"
29
31
 
32
+ # The one request a cached page always sends to the app. Kept off /api and
33
+ # /assets so host-app routing and cache rules don't shadow it.
34
+ SESSION_ENDPOINT_PATH = "/_mbuzz/session"
35
+
30
36
  SESSION_USER_ID_KEY = "user_id"
31
37
  ENV_USER_ID_KEY = "mbuzz.user_id"
32
38
  ENV_VISITOR_ID_KEY = "mbuzz.visitor_id"
@@ -110,8 +116,13 @@ module Mbuzz
110
116
  resolved_visitor_id = visitor_id || self.visitor_id
111
117
  resolved_user_id = user_id
112
118
 
113
- # Must have at least one identifier
114
- return false unless resolved_visitor_id || resolved_user_id
119
+ # Must have at least one identifier. Warn rather than drop in silence: with
120
+ # no visitor and no user there is nothing to attribute this to, and behind a
121
+ # full-page cache that is the normal case, not an edge one.
122
+ unless resolved_visitor_id || resolved_user_id
123
+ DroppedCall.warn_missing_identity("event", event_type)
124
+ return false
125
+ end
115
126
 
116
127
  Client.track(
117
128
  visitor_id: resolved_visitor_id,
@@ -161,8 +172,12 @@ module Mbuzz
161
172
  resolved_visitor_id = visitor_id || self.visitor_id
162
173
  resolved_user_id = user_id || self.user_id
163
174
 
164
- # Must have at least one identifier (visitor_id or user_id)
165
- return false unless resolved_visitor_id || resolved_user_id
175
+ # Must have at least one identifier (visitor_id or user_id). A conversion
176
+ # dropped here is lost revenue attribution, so it is never silent.
177
+ unless resolved_visitor_id || resolved_user_id
178
+ DroppedCall.warn_missing_identity("conversion", conversion_type)
179
+ return false
180
+ end
166
181
 
167
182
  Client.conversion(
168
183
  visitor_id: resolved_visitor_id,
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: mbuzz
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.2
4
+ version: 0.10.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - mbuzz team
@@ -32,13 +32,10 @@ executables: []
32
32
  extensions: []
33
33
  extra_rdoc_files: []
34
34
  files:
35
- - ".DS_Store"
36
35
  - CHANGELOG.md
37
- - CHECK_BUG.md
38
36
  - LICENSE.txt
39
37
  - README.md
40
38
  - Rakefile
41
- - lib/.DS_Store
42
39
  - lib/mbuzz.rb
43
40
  - lib/mbuzz/api.rb
44
41
  - lib/mbuzz/client.rb
@@ -49,18 +46,13 @@ files:
49
46
  - lib/mbuzz/configuration.rb
50
47
  - lib/mbuzz/controller_helpers.rb
51
48
  - lib/mbuzz/current.rb
49
+ - lib/mbuzz/dropped_call.rb
50
+ - lib/mbuzz/middleware/session_endpoint.rb
52
51
  - lib/mbuzz/middleware/tracking.rb
53
52
  - lib/mbuzz/railtie.rb
54
53
  - lib/mbuzz/request_context.rb
55
54
  - lib/mbuzz/version.rb
56
55
  - lib/mbuzz/visitor/identifier.rb
57
- - lib/specs/old/SPECIFICATION.md
58
- - lib/specs/old/conversions.md
59
- - lib/specs/old/event_ids_response.md
60
- - lib/specs/old/v0.2.0_breaking_changes.md
61
- - lib/specs/old/v2.0.0_sessions_upgrade.md
62
- - lib/specs/v0.5.0_four_call_model.md
63
- - lib/specs/v0.7.0_deterministic_sessions.md
64
56
  - sig/mbuzz.rbs
65
57
  homepage: https://mbuzz.co
66
58
  licenses:
data/.DS_Store DELETED
Binary file