fractal-ruby 0.1.0 → 0.1.1

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: fb85bbae2629a4f35ac315bda48069d46f952a44391422d162e88d05814d6724
4
- data.tar.gz: 68f9802f6b2e0db444ce472670269dab8e626d13370adcd47037c362430831ce
3
+ metadata.gz: 615b951995ae41b5f65de476b803939adbe4f7961cbfb74386a3aaeabe3b553f
4
+ data.tar.gz: a1f0e0c5ea5d68348b8909ac43671dd9b1e17a0824250adf4060f914adb59920
5
5
  SHA512:
6
- metadata.gz: a228762b9ee48fe5e87c4d12b4dcda604a0138b57610c9e7ff5cbdcd0f194b001437fc2e4c51ca18824a3ad98c4f5371d18f1d51b5eb77db4729e664a0bf8229
7
- data.tar.gz: b43092221f112e42367e98c4f8cebb200ce6369c4f2a598395aab3abeb0474e411fe95177c822e465d9a63a970a4b882ad71b416b5c7465a294cbd5e7b147593
6
+ metadata.gz: b2d2d7f93927d401c308962682cfc37d0b3f28f50285bce8c0689f412474197ac0776086c6cfe343042c14e4c2da0857271e4dda729aa320b9dfd0dfb8660eff
7
+ data.tar.gz: 275083871077a6fb4b2c9d0babd6a1849a28c1deccf6fe699feb942222f7a844bfd99ccd341d1730e65b35fe4ef06f269069870e9d1a2d5512c68c89e0427c78
data/CHANGELOG.md CHANGED
@@ -3,10 +3,16 @@
3
3
  All notable changes to the `fractal-ruby` gem. The version lives in `VERSION`;
4
4
  `ci/check_version_sync.sh` keeps `lib/fractal/version.rb`, this file and the lockfiles in step.
5
5
 
6
+ ## 0.1.1
7
+
8
+ - **402 wording:** a `402 free_plan_locked` from the collector (the Free plan is locked and the
9
+ organization is on it, #555, ADR 0016) is now logged as that, instead of "this plan has no
10
+ error tracking". Error reporting still stops for the rest of the process on either 402.
11
+
6
12
  ## 0.1.0
7
13
 
8
- First version (#495, ADR 0003). Not yet published to rubygems.org (#497); Fractal's own app uses it
9
- from the path.
14
+ First version (#495, ADR 0003). Published to rubygems.org on 2026-10-12 (#497); Fractal's own app
15
+ uses it from the path.
10
16
 
11
17
  - **Errors**, extracted from Fractal's in-repo self-reporter (`lib/self_report/`, #426): a Railtie
12
18
  adds an outermost Rack middleware and a `Rails.error` subscriber; events are built from an
data/README.md CHANGED
@@ -8,7 +8,7 @@ same conformance check.
8
8
  Requires Ruby 3.2+ and Rails 7.1+ (ADR 0003). The core (transport, buffers, scrubber, fingerprint,
9
9
  `track`) is plain Ruby; the Railtie is the supported entry point for the first release.
10
10
 
11
- > Not published yet (#497). Fractal's own app uses it from the path:
11
+ > Published on rubygems.org as `fractal-ruby`. Fractal's own app uses it from the path:
12
12
  > `gem "fractal-ruby", path: "sdks/ruby"`.
13
13
 
14
14
  ## Setup
@@ -81,6 +81,7 @@ Delivery is **at least once**: a request whose response is lost is retried and s
81
81
  | | Default | Override |
82
82
  | --- | --- | --- |
83
83
  | Player | the scope's user (`Fractal.set_user`, or Warden), else `user_resolver`, else `"anonymous"` inside a request and `"system"` outside one (jobs, rake, boot) | `track(..., player_id:)`, `anonymous_player_id`, `system_player_id` |
84
+ | Account | the same id as the player whenever a user is known; never sent for the `anonymous` and `system` buckets. A game client that calls `set_account_id` with the same id counts as the same player in the dashboard | not separately configurable |
84
85
  | Session | a hash of the Rack session id (the raw id is never sent); with no session (jobs, cookie-less APIs), one session per player per UTC day | `track(..., session_token:)`, `Fractal.set_session(token)`, `session_resolver` |
85
86
  | End-user address | the request's address (`request.remote_ip`, which honours your trusted proxies) | `track(..., client_ip:)`, `send_client_ip = false` |
86
87
 
@@ -137,13 +138,6 @@ tags and skips the push with a warning; re-run it by hand (`workflow_dispatch`)
137
138
  set. The key needs the "push rubygem" scope, on an account whose MFA level is "UI and gem signin":
138
139
  at "UI and API" every push asks for an OTP that CI cannot give.
139
140
 
140
- **Before the first deploy that advertises the gem.** The Settings quick start and the help article
141
- (`content/help/{en,es}/sdk-integration/ruby.md`) tell customers to add `gem "fractal-ruby"`, and
142
- the name is not registered on rubygems.org until the first push. Push 0.1.0 by hand from the
143
- owning account (or set the secret and dispatch the workflow) before running `bin/deploy`: until
144
- then the install line fails and the name is free for anyone to claim. Shipping the copy ahead of
145
- the push was an owner decision (2026-10-12), not an oversight.
146
-
147
141
  `spec/fractal/package_spec.rb` builds the gem the same way and checks what is inside it.
148
142
 
149
143
  ## License
data/VERSION CHANGED
@@ -1 +1 @@
1
- 0.1.0
1
+ 0.1.1
@@ -175,6 +175,7 @@ module Fractal
175
175
  "player_id" => player,
176
176
  "session_token" => session_token.to_s.empty? ? session_for(scope, player) : session_token.to_s
177
177
  }
178
+ event["account_id"] = player if @identity.account?(player)
178
179
  ip = client_ip.to_s.empty? ? client_ip_for(scope) : client_ip.to_s
179
180
  ip = serialisable_ip(ip) if ip
180
181
  event["client_ip"] = ip if ip
@@ -59,8 +59,8 @@ module Fractal
59
59
  log(:error, "report failed: #{e.class}")
60
60
  end
61
61
 
62
- # Delivers up to MAX_POSTS_PER_FLUSH batches (one per player: /v1/errors has no per-event
63
- # player). Never raises.
62
+ # Delivers up to MAX_POSTS_PER_FLUSH batches, one per player with the player in the context
63
+ # (the gem's choice: /v1/errors also takes a per-error player_id). Never raises.
64
64
  def flush(deadline: nil)
65
65
  reset_after_fork
66
66
  unless enabled?
@@ -234,7 +234,7 @@ module Fractal
234
234
  when 402
235
235
  @mutex.synchronize { @disabled = true }
236
236
  discard_buffer
237
- log(:warn, "collector answered 402: this plan has no error tracking. Error reporting is off for the rest of this process")
237
+ log(:warn, "collector answered 402: #{payment_required_reason(response.body)}. Error reporting is off for the rest of this process")
238
238
  when 429, 500..599
239
239
  failed(entries, requeue: true)
240
240
  log(:warn, "collector answered #{response.code}; will retry")
@@ -244,6 +244,19 @@ module Fractal
244
244
  end
245
245
  end
246
246
 
247
+ # The collector answers 402 for two reasons; the handling is the same, the wording is not.
248
+ def payment_required_reason(body)
249
+ json = JSON.parse(body.to_s)
250
+ code = json["code"] if json.is_a?(Hash)
251
+ if code == "free_plan_locked"
252
+ "the Free plan is locked and this organization is on it (ADR 0016); choose a paid plan to send data"
253
+ else
254
+ "this plan has no error tracking"
255
+ end
256
+ rescue JSON::ParserError
257
+ "this plan has no error tracking"
258
+ end
259
+
247
260
  def succeeded(entries, body)
248
261
  @breaker.success!
249
262
  bump(:sent_events, entries.size)
@@ -268,13 +281,15 @@ module Fractal
268
281
 
269
282
  def payload(player_id, entries)
270
283
  os = Etc.uname
284
+ context = {
285
+ player_id: player_id, session_token: "", platform: "server", app_version: @config.app_version.to_s,
286
+ environment: @config.environment, runtime_version: "ruby #{RUBY_VERSION}", os_name: os[:sysname],
287
+ os_version: os[:release], sdk_version: Fractal.sdk_version
288
+ }
289
+ context[:account_id] = player_id if Identity.new(@config).account?(player_id)
271
290
  {
272
291
  sent_at: Time.now.utc.iso8601(3),
273
- context: {
274
- player_id: player_id, session_token: "", platform: "server", app_version: @config.app_version.to_s,
275
- environment: @config.environment, runtime_version: "ruby #{RUBY_VERSION}", os_name: os[:sysname],
276
- os_version: os[:release], sdk_version: Fractal.sdk_version
277
- },
292
+ context: context,
278
293
  errors: entries.map do |entry|
279
294
  entry.event.merge("occurrence_count" => entry.count, "timestamp" => entry.first_at.iso8601(3))
280
295
  end
@@ -68,7 +68,8 @@ module Fractal
68
68
  text.scrub("?")
69
69
  end
70
70
 
71
- # environment rides in tags until the collector stores it (#485).
71
+ # environment is also a tag: the collector stores the error's own environment as a column
72
+ # since #485 (ADR 0017), and the tag is kept for errors ingested before that.
72
73
  def tags(source, controller, job)
73
74
  tags = { "environment" => @config.environment, "source" => source.to_s }
74
75
  tags["controller_action"] = "#{controller.controller_path}##{controller.action_name}" if controller.respond_to?(:controller_path)
@@ -53,6 +53,50 @@ module Fractal
53
53
  RUBY_LINE_PATTERN = /\A((?:\[(?:gem|ruby|other)\] )?\S+\.rb):\d+(:in )/
54
54
  RUBY_ERB_LINE_PATTERN = /\A((?:\[(?:gem|ruby|other)\] )?\S+\.erb):\d+:in .*\z/
55
55
 
56
+ # minidump-stackwalk (rust-minidump) human report: "Thread 3 {name} (crashed) - tid: 7" (an
57
+ # unnamed thread leaves a double space), then frames
58
+ # " 0 module!function [file.cc : 58 + 0x3]", " 0 module!function + 0x2c" or " 0 module + 0x429e".
59
+ # A Rails backend never reports one; the extractor is here so this port matches every shared
60
+ # vector. Behaviour for Ruby traces is unchanged, so it needs no gem release of its own.
61
+ STACKWALK_CRASHED_THREAD_PATTERN = /\AThread \d+ .*\(crashed\) - tid:/
62
+ STACKWALK_FRAME_INDEX_PATTERN = /\A\d+\s+/
63
+ STACKWALK_SOURCE_LINE_PATTERN = / : \d+(?: \+ 0x[0-9a-fA-F]+)?\]\z/
64
+ STACKWALK_FUNC_OFFSET_PATTERN = /\A(\S+!.+?) \+ 0x[0-9a-fA-F]+\z/
65
+
66
+ # A Rails backend never reports a C# trace either; the extractor is here for the same reason
67
+ # as the stackwalk one: this port must match every shared vector (the Unity SDK, ADR 0023 §9).
68
+ # Behaviour for Ruby traces is unchanged, so it needs no gem release of its own.
69
+ # C# frames come in three shapes (ADR 0023 §9). A shape is STRONG when it ends in a tail that no
70
+ # other format prints, and only a strong frame makes csharp_frame claim a trace:
71
+ # Mono "at Game.Player.TakeDamage (System.Int32 amount) [0x0001a] in /proj/Player.cs:42"
72
+ # (IL2CPP: "[0x00000] in <000...>:0"): the " in <path>:N" tail.
73
+ # CoreCLR "at Game.Player.TakeDamage(Int32 amount) in /src/Player.cs:line 42": the
74
+ # " in <path>:line N" tail.
75
+ # Unity "Game.Player.TakeDamage (System.Int32 amount) (at Assets/Player.cs:42)": the
76
+ # "(at <path>:N)" tail. Also strong, with no tail: "UnityEngine.Debug:LogError (object)",
77
+ # a "Type:Method" frame of the engine itself, which starts every Debug.Log trace.
78
+ # The same shapes without their tail are WEAK, because other formats print them too: a JVM frame
79
+ # reads "at pkg.Type.method(SourceFile)", and "word:word (text)" is also a message line or a gin
80
+ # "path.go:42 (0x14a2b3c)" line. A weak line is a frame only inside a trace that a strong frame claims.
81
+ # Explicit character classes only (no \s, \w, \d), so Go's RE2 and Onigmo cannot disagree.
82
+ CSHARP_MONO_FRAME_PATTERN = /\Aat (?:\(wrapper [a-z-]+\) )?([^ ()]+) ?\(([^()]*)\) (?:(?:\[0x[0-9a-fA-F]+\]|<0x[0-9a-fA-F]+(?: \+ 0x[0-9a-fA-F]+)?>) )?in .+:[0-9]+\z/
83
+ CSHARP_CORECLR_FRAME_PATTERN = /\Aat ([^ ():]*[.][^ ():]+)\(([^():]*)\)( in .+:line [0-9]+)?\z/
84
+ CSHARP_UNITY_FRAME_PATTERN = /\A(?:\(wrapper [a-z-]+\) )?([^ ():]+(?::[^ ():]+)?) ?\(([^()]*)\)( \(at .+:[0-9]+\))?\z/
85
+
86
+ # A tail-less "Type:Method (args)" frame under one of these is a strong marker.
87
+ CSHARP_UNITY_ENGINE_COLON_PREFIXES = %w[UnityEngine. UnityEditor.].freeze
88
+
89
+ # What the JVM prints between the parentheses of a frame that has no line number, and the endings
90
+ # of its bare source file name ("(Foo.java)").
91
+ CSHARP_JVM_LOCATIONS = [ "SourceFile", "Native Method", "Unknown Source" ].freeze
92
+ CSHARP_JVM_SOURCE_SUFFIXES = %w[.java .kt .kts .scala .groovy].freeze
93
+
94
+ # Engine, runtime and library namespaces, plus the SDK's own ("Fractal." and its facade class
95
+ # "FractalSDK."). The trailing "." keeps a game namespace such as "UnityChan." in-app.
96
+ CSHARP_NOT_IN_APP_PREFIXES = %w[
97
+ UnityEngine. UnityEditor. Unity. System. Mono. TMPro. Microsoft. Cysharp. Fractal. FractalSDK.
98
+ ].freeze
99
+
56
100
  module_function
57
101
 
58
102
  # generate_fingerprint is the SHA256 fingerprint used to group errors,
@@ -126,9 +170,12 @@ module Fractal
126
170
  def normalize_top_frame(stack_trace)
127
171
  return "" if stack_trace.nil? || stack_trace.empty?
128
172
 
129
- # ruby_frame precedes js_frame: js_stack_trace? fires on any " at ", which a Ruby 3.3+ eval frame
130
- # ("(eval at app/x.rb:3):1:in 'm'") contains. Keep in sync with frameExtractors in Go.
131
- %i[godot_frame ruby_frame js_frame go_frame lua_frame].each do |extractor|
173
+ # stackwalk_frame sits first: a native crash report lists loaded modules and their paths, which
174
+ # no other detector should get a chance to misread. ruby_frame and csharp_frame precede
175
+ # js_frame: js_stack_trace? fires on any " at ", which a Ruby 3.3+ eval frame
176
+ # ("(eval at app/x.rb:3):1:in 'm'") contains and every Mono and .NET frame starts with.
177
+ # Keep in sync with frameExtractors in Go.
178
+ %i[stackwalk_frame godot_frame ruby_frame csharp_frame js_frame go_frame lua_frame].each do |extractor|
132
179
  frame, matched = send(extractor, stack_trace)
133
180
  return frame if matched
134
181
  end
@@ -153,6 +200,27 @@ module Fractal
153
200
  ""
154
201
  end
155
202
 
203
+ # Frame 0 of the crashed thread in a minidump-stackwalk report, without its frame index, source
204
+ # line or a symbolicated function's "+ 0x" offset. An unsymbolicated "module + 0x..." keeps its
205
+ # offset: it is all that identifies the frame.
206
+ def stackwalk_frame(stack_trace)
207
+ in_crashed = false
208
+ stack_trace.split("\n").each do |line|
209
+ trimmed = line.strip
210
+ unless in_crashed
211
+ in_crashed = STACKWALK_CRASHED_THREAD_PATTERN.match?(trimmed)
212
+ next
213
+ end
214
+ next unless STACKWALK_FRAME_INDEX_PATTERN.match?(trimmed)
215
+
216
+ frame = trimmed.sub(STACKWALK_FRAME_INDEX_PATTERN, "")
217
+ frame = frame.sub(STACKWALK_SOURCE_LINE_PATTERN, "]")
218
+ frame = frame.sub(STACKWALK_FUNC_OFFSET_PATTERN, '\\1')
219
+ return [ frame, true ]
220
+ end
221
+ [ "", false ]
222
+ end
223
+
156
224
  def godot_stack_trace?(stack_trace)
157
225
  stack_trace.include?("GDScript backtrace") || stack_trace.include?("res://")
158
226
  end
@@ -280,6 +348,64 @@ module Fractal
280
348
  [ first, true ]
281
349
  end
282
350
 
351
+ # Unity log, Mono and .NET (CoreCLR) traces. Claims a trace only when one line is a strong C#
352
+ # frame; then the first frame, strong or weak, outside CSHARP_NOT_IN_APP_PREFIXES, else the first
353
+ # frame, as "Type.Method(args)". A claimed trace with no in-app frame still matches, so it never
354
+ # reaches js_frame, which would keep the IL offset, the path and the line number. One pass over
355
+ # the lines, so the cost is linear in the size of the trace.
356
+ def csharp_frame(stack_trace)
357
+ first = nil
358
+ first_in_app = nil
359
+ claimed = false
360
+ stack_trace.split("\n").each do |line|
361
+ frame, strong = csharp_parse_frame(line.strip)
362
+ next if frame.nil?
363
+
364
+ claimed ||= strong
365
+ first ||= frame
366
+ first_in_app ||= frame if CSHARP_NOT_IN_APP_PREFIXES.none? { |prefix| frame.start_with?(prefix) }
367
+ return [ first_in_app, true ] if claimed && first_in_app
368
+ end
369
+ claimed ? [ first, true ] : [ "", false ]
370
+ end
371
+
372
+ # One trimmed line as [normalized C# frame, strong], or nil when it is not a frame (a message
373
+ # line, "Rethrow as X: msg", "--- End of stack trace ... ---", a frame of another language).
374
+ # strong says whether the line alone proves a C# trace.
375
+ def csharp_parse_frame(line)
376
+ if (m = CSHARP_MONO_FRAME_PATTERN.match(line))
377
+ [ csharp_normalize_frame(m[1], m[2]), true ]
378
+ elsif (m = CSHARP_CORECLR_FRAME_PATTERN.match(line))
379
+ # With no tail the parentheses can hold a JVM location in place of an argument list. Such a
380
+ # line is a Java frame even inside a trace that Mono frames claim (Unity on Android).
381
+ return nil if m[3].nil? && csharp_jvm_location?(m[2])
382
+
383
+ [ csharp_normalize_frame(m[1], m[2]), !m[3].nil? ]
384
+ elsif (m = CSHARP_UNITY_FRAME_PATTERN.match(line))
385
+ # Without the "(at path:N)" tail only the "Type:Method" colon marks a Unity frame.
386
+ return nil if m[3].nil? && !m[1].include?(":")
387
+
388
+ strong = !m[3].nil? || CSHARP_UNITY_ENGINE_COLON_PREFIXES.any? { |prefix| m[1].start_with?(prefix) }
389
+ [ csharp_normalize_frame(m[1], m[2]), strong ]
390
+ end
391
+ end
392
+
393
+ # "SourceFile" (R8 / ProGuard), "Native Method", "Unknown Source", or a bare source file name.
394
+ def csharp_jvm_location?(args)
395
+ return true if CSHARP_JVM_LOCATIONS.include?(args)
396
+ return false if args.include?(" ") || args.include?(",")
397
+
398
+ CSHARP_JVM_SOURCE_SUFFIXES.any? { |suffix| args.end_with?(suffix) }
399
+ end
400
+
401
+ # "Type.Method(args)": Unity's "Type:Method" becomes "Type.Method", no space before "(", one
402
+ # space after each comma, single spaces inside an argument. The argument list stays: it tells
403
+ # overloads apart. split(",", -1) keeps a trailing empty argument, as Go's strings.Split does.
404
+ def csharp_normalize_frame(name, args)
405
+ parts = args.split(",", -1).map { |part| part.split(/ +/).reject(&:empty?).join(" ") }
406
+ "#{name.gsub(":", ".")}(#{parts.join(", ")})"
407
+ end
408
+
283
409
  def strip_line_col(line)
284
410
  line.sub(LINE_COL_PATTERN) { $1 }
285
411
  end
@@ -17,6 +17,13 @@ module Fractal
17
17
  resolve(controller) || bucket(scope: scope, controller: controller)
18
18
  end
19
19
 
20
+ # A server has no install, so a known user is sent as the account id as well as the player id,
21
+ # and a backend and a game client that use the same id count as one player (ADR 0004 §7). The
22
+ # two buckets are not accounts: nobody is signed in.
23
+ def account?(player_id)
24
+ !player_id.empty? && player_id != @config.anonymous_player_id && player_id != @config.system_player_id
25
+ end
26
+
20
27
  # Who the work belongs to when no user is known: anonymous inside a request, system outside.
21
28
  def bucket(scope:, controller: nil)
22
29
  controller || scope.request? ? @config.anonymous_player_id : @config.system_player_id
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Fractal
4
4
  # Kept in step with sdks/ruby/VERSION by ci/check_version_sync.sh.
5
- VERSION = "0.1.0"
5
+ VERSION = "0.1.1"
6
6
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: fractal-ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Fractal
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2026-10-12 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies: []
12
12
  description: Reports a Rails app's exceptions to Fractal's /v1/errors and sends server-side
13
13
  analytics events to /v1/batch, from a background thread that never slows a request.
@@ -57,7 +57,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
57
57
  - !ruby/object:Gem::Version
58
58
  version: '0'
59
59
  requirements: []
60
- rubygems_version: 3.6.2
60
+ rubygems_version: 3.6.9
61
61
  specification_version: 4
62
62
  summary: Error tracking and analytics for Ruby on Rails backends, reporting to Fractal.
63
63
  test_files: []