mbuzz 0.8.1 → 0.9.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: a3725da0e1a4fb06b393abd356cf408a7bc2a1ba27aeed2e4bc49b9047469b77
4
- data.tar.gz: 926572f620afc1108fb902b3270404b79b35e112285efd032fd449c4c7f84164
3
+ metadata.gz: 81970794a962b276527d5aa05efb59703e1e3507598218c66f3eb3c238a96b6a
4
+ data.tar.gz: 391e3993a7aa60f3ad5fe50dfeff1394c9f3282538d3074de12f7a7a284d8e15
5
5
  SHA512:
6
- metadata.gz: '0718ffe4e0ee5ca2aa499aefdf7ef7c79046f99ab35ab19f8cd9209ba8b6c75f42be031e82f1cef4a875249cd2e846a74c5401a798b3036bad96386ade2ce5b6'
7
- data.tar.gz: 9d9952f1adfdfd15320946b6416ae84a5c1f10c1b2a7a81eb972dc184e36f18684674cc7129afaa1a97b877c546febf412112ddec6e74b64278111cdabc30456
6
+ metadata.gz: 2187c1959502e1dcc183e6f3fd2d77b46b90e87427bb79078c04cac35715891ec89cf274514cf8e8f327de2d8176e0d381875c961b9f61b37fee18f313c8ff9e
7
+ data.tar.gz: eb34d5989f34c7e83d5315c57000dff733af12a26494255817a414002d0448a1d8ee8f71143a94348f1b5118df743acf97cdeffd2347584e0cbe561b73b396e3
data/CHANGELOG.md CHANGED
@@ -5,6 +5,24 @@ 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.9.0] - 2026-09-02
9
+
10
+ ### Added
11
+
12
+ - **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.
13
+ - **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.
14
+
15
+ ### Notes
16
+
17
+ - 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.
18
+ - 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.
19
+
20
+ ## [0.8.2] - 2026-03-15
21
+
22
+ ### Changed
23
+
24
+ - **Removed `api_url` from `init()` parameters** — the proxy URL (`https://api.mbuzz.co/api/v1`) is now hardcoded. This prevents accidental bypass of the edge ingest proxy. For development, `Mbuzz.config.api_url` can still be set directly after init.
25
+
8
26
  ## [0.8.1] - 2026-03-16
9
27
 
10
28
  ### Fixed
data/README.md CHANGED
@@ -188,10 +188,47 @@ 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
197
+
198
+ If pages are served from a full-page cache (Cloudflare, Varnish, nginx, Rack::Cache, a CDN),
199
+ the cache answers the request **without entering the Rack stack**, so the tracking middleware
200
+ never runs, no visitor cookie is set, and every later event is dropped for having no one to
201
+ attribute it to. The page renders perfectly and nothing is logged — the failure is silent.
202
+
203
+ `Mbuzz::Middleware::SessionEndpoint` fixes this. It answers `POST /_mbuzz/session`, a path
204
+ caches don't store, and the **server** sets the cookie on that response. Rails mounts it for
205
+ you; Rack and Sinatra apps add it ahead of `Tracking` as shown above.
206
+
207
+ Then call it once per page, from your layout:
208
+
209
+ ```html
210
+ <script>
211
+ fetch('/_mbuzz/session', {
212
+ method: 'POST',
213
+ headers: { 'Content-Type': 'application/json' },
214
+ body: JSON.stringify({ url: location.href, referrer: document.referrer || '' }),
215
+ credentials: 'same-origin',
216
+ keepalive: true
217
+ }).catch(function () {});
218
+ </script>
219
+ ```
220
+
221
+ Two things to keep as they are:
222
+
223
+ - **Inline the script, don't enqueue a file.** Asset optimisers (WP Rocket, LiteSpeed, and the
224
+ Rails equivalents) delay external scripts until the visitor first interacts. A visitor who
225
+ lands and converts without clicking anything first would never be established.
226
+ - **`credentials: 'same-origin'` is required**, or the cookie never comes back.
227
+
228
+ The visitor id is never created or read in JavaScript. It stays `HttpOnly` and server-set, which
229
+ is what preserves its full two-year life — a cookie written by `document.cookie` is capped at
230
+ 7 days under Safari's ITP, and 24 hours after an ad click.
231
+
195
232
  ## Configuration Options
196
233
 
197
234
  ```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
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.1"
4
+ VERSION = "0.9.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"
@@ -42,14 +48,12 @@ module Mbuzz
42
48
 
43
49
  # New simplified configuration method (v0.5.0)
44
50
  # @param api_key [String] Your mbuzz API key
45
- # @param api_url [String, nil] Override API URL (defaults to https://api.mbuzz.co/api/v1)
46
51
  # @param session_timeout [Integer, nil] Session timeout in seconds
47
52
  # @param debug [Boolean, nil] Enable debug logging
48
53
  # @param skip_paths [Array<String>, nil] Additional paths to skip tracking (e.g., ["/admin", "/internal"])
49
54
  # @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)
55
+ def self.init(api_key:, session_timeout: nil, debug: nil, skip_paths: nil, skip_extensions: nil)
51
56
  config.api_key = api_key
52
- config.api_url = api_url if api_url
53
57
  config.session_timeout = session_timeout if session_timeout
54
58
  config.debug = debug unless debug.nil?
55
59
  config.skip_paths = skip_paths if skip_paths
@@ -112,8 +116,13 @@ module Mbuzz
112
116
  resolved_visitor_id = visitor_id || self.visitor_id
113
117
  resolved_user_id = user_id
114
118
 
115
- # Must have at least one identifier
116
- 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
117
126
 
118
127
  Client.track(
119
128
  visitor_id: resolved_visitor_id,
@@ -163,8 +172,12 @@ module Mbuzz
163
172
  resolved_visitor_id = visitor_id || self.visitor_id
164
173
  resolved_user_id = user_id || self.user_id
165
174
 
166
- # Must have at least one identifier (visitor_id or user_id)
167
- 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
168
181
 
169
182
  Client.conversion(
170
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.1
4
+ version: 0.9.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
data/CHECK_BUG.md DELETED
@@ -1,168 +0,0 @@
1
- # Thread-Safety Bug Fix Verification
2
-
3
- ## Bug Summary
4
-
5
- **Issue**: Middleware used instance variables (`@session_id`, `@visitor_id`, `@request`) shared across concurrent requests in multi-threaded servers (Puma). This caused race conditions where session/visitor IDs leaked between requests.
6
-
7
- **Impact**: Pet Resorts Australia had **178,428 sessions** for only **172,000 visitors**. Some visitors had 1,500+ sessions because cookies were set with wrong session_ids under concurrent load.
8
-
9
- **Root Cause**: Rack middleware is instantiated once and shared across all requests. Instance variables are not thread-safe.
10
-
11
- ## Fix Details
12
-
13
- | Field | Value |
14
- |-------|-------|
15
- | **Fixed in version** | 0.6.3 |
16
- | **Commit** | `bdf4c64` |
17
- | **Fix deployed** | 2025-12-22 ~21:30 UTC (2025-12-23 ~08:30 AEDT) |
18
- | **Gem published** | 2025-12-23 |
19
-
20
- ## Verification Checklist
21
-
22
- ### After 24-48 hours (by 2025-12-25):
23
-
24
- Run these queries in production Rails console:
25
-
26
- ```ruby
27
- # 1. Check session creation rate AFTER fix
28
- # Should see dramatically fewer sessions per hour
29
- cutoff = Time.parse("2025-12-23 08:30:00 UTC") # Adjust to actual deploy time
30
-
31
- puts "Sessions BEFORE fix (last 24h before deploy):"
32
- before_sessions = Session.where(created_at: (cutoff - 24.hours)..cutoff).count
33
- puts " Count: #{before_sessions}"
34
-
35
- puts "\nSessions AFTER fix (24h after deploy):"
36
- after_sessions = Session.where(created_at: cutoff..(cutoff + 24.hours)).count
37
- puts " Count: #{after_sessions}"
38
-
39
- puts "\nReduction: #{((before_sessions - after_sessions).to_f / before_sessions * 100).round(1)}%"
40
- ```
41
-
42
- ```ruby
43
- # 2. Check sessions per visitor ratio
44
- # Should be close to 1.0-1.5 for new visitors (was 1.05 overall but outliers had 1500+)
45
- cutoff = Time.parse("2025-12-23 08:30:00 UTC")
46
-
47
- new_visitors = Visitor.where("created_at > ?", cutoff)
48
- new_visitor_ids = new_visitors.pluck(:id)
49
-
50
- sessions_for_new = Session.where(visitor_id: new_visitor_ids).count
51
- puts "New visitors since fix: #{new_visitors.count}"
52
- puts "Sessions for new visitors: #{sessions_for_new}"
53
- puts "Ratio: #{(sessions_for_new.to_f / new_visitors.count).round(2)}"
54
- ```
55
-
56
- ```ruby
57
- # 3. Check for any new outliers (visitors with 10+ sessions in 24h)
58
- cutoff = Time.parse("2025-12-23 08:30:00 UTC")
59
-
60
- outliers = Session.where("created_at > ?", cutoff)
61
- .group(:visitor_id)
62
- .having("count(*) > 10")
63
- .count
64
-
65
- puts "Visitors with 10+ sessions since fix: #{outliers.count}"
66
- outliers.sort_by { |_, v| -v }.first(5).each do |vid, count|
67
- puts " Visitor #{vid}: #{count} sessions"
68
- end
69
- ```
70
-
71
- ### Expected Results After Fix:
72
-
73
- - [ ] Session creation rate drops by 90%+
74
- - [ ] Sessions per new visitor ratio < 2.0
75
- - [ ] No new outliers with 100+ sessions
76
- - [ ] Cookie session_id matches env session_id (verified by tests)
77
-
78
- ---
79
-
80
- ## Other SDKs to Review
81
-
82
- **CRITICAL**: Check all other SDKs for the same thread-safety bug!
83
-
84
- ### SDK Review Checklist:
85
-
86
- | SDK | Location | Status | Reviewed By | Date |
87
- |-----|----------|--------|-------------|------|
88
- | mbuzz-ruby | `/Users/vlad/code/m/mbuzz-ruby` | FIXED | Claude | 2025-12-22 |
89
- | mbuzz-python | `/Users/vlad/code/m/mbuzz-python` | SAFE | Claude | 2025-12-22 |
90
- | mbuzz-php | `/Users/vlad/code/m/mbuzz-php` | SAFE | Claude | 2025-12-22 |
91
- | mbuzz-node | `/Users/vlad/code/m/mbuzz-node` | SAFE | Claude | 2025-12-22 |
92
-
93
- ### Review Results:
94
-
95
- **mbuzz-python**: SAFE
96
- - Uses `contextvars.ContextVar` for thread-safe context storage
97
- - Uses Flask's `g` object for request-scoped storage
98
- - Local variables used throughout middleware
99
- - Async session creation captures values in local variables before spawning thread
100
-
101
- **mbuzz-php**: SAFE
102
- - PHP is single-process per request by default
103
- - No shared state between requests
104
- - Each request gets fresh instance of everything
105
-
106
- **mbuzz-node**: SAFE
107
- - Uses `AsyncLocalStorage` from `node:async_hooks` for async request isolation
108
- - Express middleware uses local variables (`visitor`, `session`, `secure`)
109
- - Attaches data to request-scoped `req.mbuzz` object
110
- - `createSessionAsync` captures values as function parameters before `setImmediate`
111
- - Node.js is single-threaded, so race conditions are inherently less likely
112
-
113
- ### What to Look For:
114
-
115
- 1. **Middleware/Handler using instance variables or class variables for request-specific data**
116
- - BAD: `self.session_id = ...` or `@session_id = ...`
117
- - GOOD: Local variables passed through function calls
118
-
119
- 2. **Mutable shared state**
120
- - BAD: Global or class-level dicts/hashes storing request data
121
- - GOOD: Request-scoped context objects or local variables
122
-
123
- 3. **Thread-local storage without proper cleanup**
124
- - Check that thread-local data is cleared after each request
125
-
126
- ### Python-specific concerns:
127
- - Check for module-level variables
128
- - Check Flask/Django middleware for shared state
129
- - WSGI apps can have similar issues with global state
130
-
131
- ### PHP-specific concerns:
132
- - PHP is typically single-threaded per request, so likely SAFE
133
- - But check for any persistent worker modes (Swoole, RoadRunner, FrankenPHP)
134
-
135
- ### Node.js-specific concerns:
136
- - Node is single-threaded, so likely SAFE
137
- - But check for any shared state in closures or module scope
138
-
139
- ---
140
-
141
- ## Data Cleanup (Optional)
142
-
143
- After verifying the fix works, consider cleaning up the bad data:
144
-
145
- ```ruby
146
- # Find sessions with no events (likely created by the bug)
147
- # BE CAREFUL - only run after thorough analysis
148
-
149
- # Count empty sessions by account
150
- Account.find_each do |account|
151
- session_ids_with_events = account.events.distinct.pluck(:session_id)
152
- empty_sessions = account.sessions.where.not(session_id: session_ids_with_events).count
153
- total_sessions = account.sessions.count
154
-
155
- next if empty_sessions == 0
156
-
157
- puts "#{account.name}: #{empty_sessions}/#{total_sessions} empty sessions (#{(empty_sessions.to_f/total_sessions*100).round(1)}%)"
158
- end
159
- ```
160
-
161
- ---
162
-
163
- ## Notes
164
-
165
- - Bug was discovered via dashboard metrics investigation (avg visits showing 28.5 with 0.6 avg days)
166
- - Traced to Pet Resorts Australia account (PetPro360)
167
- - Logs showed session creation every few seconds with different session_ids
168
- - Test added: `test_race_condition_with_slow_app` - 49/50 failures before fix, 0 after
data/lib/.DS_Store DELETED
Binary file