mbuzz 0.8.2 → 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 +4 -4
- data/CHANGELOG.md +12 -0
- data/README.md +37 -0
- data/lib/mbuzz/client/conversion_request.rb +6 -1
- data/lib/mbuzz/client/track_request.rb +8 -1
- data/lib/mbuzz/dropped_call.rb +41 -0
- data/lib/mbuzz/middleware/session_endpoint.rb +135 -0
- data/lib/mbuzz/railtie.rb +3 -0
- data/lib/mbuzz/version.rb +1 -1
- data/lib/mbuzz.rb +19 -4
- metadata +3 -11
- data/.DS_Store +0 -0
- data/CHECK_BUG.md +0 -168
- data/lib/.DS_Store +0 -0
- data/lib/specs/old/SPECIFICATION.md +0 -695
- data/lib/specs/old/conversions.md +0 -585
- data/lib/specs/old/event_ids_response.md +0 -346
- data/lib/specs/old/v0.2.0_breaking_changes.md +0 -519
- data/lib/specs/old/v2.0.0_sessions_upgrade.md +0 -265
- data/lib/specs/v0.5.0_four_call_model.md +0 -505
- data/lib/specs/v0.7.0_deterministic_sessions.md +0 -647
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 81970794a962b276527d5aa05efb59703e1e3507598218c66f3eb3c238a96b6a
|
|
4
|
+
data.tar.gz: 391e3993a7aa60f3ad5fe50dfeff1394c9f3282538d3074de12f7a7a284d8e15
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2187c1959502e1dcc183e6f3fd2d77b46b90e87427bb79078c04cac35715891ec89cf274514cf8e8f327de2d8176e0d381875c961b9f61b37fee18f313c8ff9e
|
|
7
|
+
data.tar.gz: eb34d5989f34c7e83d5315c57000dff733af12a26494255817a414002d0448a1d8ee8f71143a94348f1b5118df743acf97cdeffd2347584e0cbe561b73b396e3
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,18 @@ 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
|
+
|
|
8
20
|
## [0.8.2] - 2026-03-15
|
|
9
21
|
|
|
10
22
|
### Changed
|
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
|
|
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
|
|
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
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|