sentiero-rails 1.0.0.alpha1

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 01ce313521ddbc812513032b99f9167f18888958bce23cae2ca040910fd9a16e
4
+ data.tar.gz: 3395c38e3db3cc713116c59963509bafc619f30ce83701aca67e051daa50a6a5
5
+ SHA512:
6
+ metadata.gz: 77e8e9577a6428b5ec1c913755ffa0a7410349ad87f0bdd0bfc38246aafc2d1cdd9b38a4d1609331ad2e0e649818e3e04cc30b46d0f23118cd1ade7e25c28ad4
7
+ data.tar.gz: 4a9a082240b101a0466cf36bca3a28535e895937926045e53ddf1956edf23e883f9cf1bdf3667dc866be961f1fa0fab3c704e746866f568b17e7c9bedc283d5f
data/LICENSE.txt ADDED
@@ -0,0 +1,7 @@
1
+ Copyright 2026 Stephen Ierodiaconou
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
4
+
5
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6
+
7
+ THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sentiero
4
+ module Rails
5
+ class Configuration
6
+ attr_accessor :events_url, :reporter_middleware
7
+
8
+ def initialize
9
+ @events_url = "/sentiero/events"
10
+ @reporter_middleware = true
11
+ end
12
+ end
13
+
14
+ class << self
15
+ def configuration
16
+ @configuration ||= Configuration.new
17
+ end
18
+
19
+ def configure
20
+ yield(configuration)
21
+ end
22
+
23
+ def reset_configuration!
24
+ @configuration = Configuration.new
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "helpers/script_tag_helper"
4
+ require_relative "../reporter"
5
+ require_relative "../reporter/middleware"
6
+
7
+ module Sentiero
8
+ module Rails
9
+ class Engine < ::Rails::Engine
10
+ initializer "sentiero.helpers" do
11
+ ActiveSupport.on_load(:action_view) do
12
+ include Sentiero::Rails::Helpers::ScriptTagHelper
13
+ end
14
+ end
15
+
16
+ # Reports unhandled exceptions, then re-raises them.
17
+ initializer "sentiero.reporter_middleware" do |app|
18
+ Sentiero::Rails::Engine.insert_reporter_middleware(app)
19
+ end
20
+
21
+ def self.insert_reporter_middleware(app)
22
+ return false unless reporter_middleware_enabled?
23
+
24
+ # Install whenever opted in — not gated on Reporter.active? at boot, since
25
+ # the user's Reporter.configure runs in an initializer after this one. The
26
+ # middleware is a cheap pass-through and Reporter.notify guards on active?
27
+ # at request time, so an unconfigured reporter just does nothing.
28
+ app.middleware.use Sentiero::Reporter::Middleware
29
+ true
30
+ rescue => e
31
+ warn "[Sentiero::Reporter] middleware auto-install failed: #{e.class}: #{e.message}"
32
+ false
33
+ end
34
+
35
+ def self.reporter_middleware_enabled?
36
+ flag = Sentiero::Rails.configuration.reporter_middleware
37
+ return true if flag.nil?
38
+ flag
39
+ end
40
+
41
+ initializer "sentiero.default_store" do
42
+ config.after_initialize do
43
+ if Sentiero.configuration.store.nil?
44
+ require_relative "store"
45
+ Sentiero.configuration.store = Sentiero::Rails::Store.new
46
+ end
47
+ end
48
+ end
49
+
50
+ rake_tasks do
51
+ load File.expand_path("tasks/sentiero.rake", __dir__)
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/active_record"
5
+ require "securerandom"
6
+
7
+ module Sentiero
8
+ module Generators
9
+ class InstallGenerator < ::Rails::Generators::Base
10
+ include ::Rails::Generators::Migration
11
+
12
+ source_root File.expand_path("templates", __dir__)
13
+
14
+ desc "Creates a Sentiero initializer and migration for ActiveRecord storage."
15
+
16
+ def self.next_migration_number(dirname)
17
+ Time.now.utc.strftime("%Y%m%d%H%M%S")
18
+ end
19
+
20
+ def create_migration_file
21
+ migration_template "create_sentiero_tables.rb.erb",
22
+ "db/migrate/create_sentiero_tables.rb"
23
+ end
24
+
25
+ def create_initializer
26
+ template "initializer.rb", "config/initializers/sentiero.rb"
27
+ end
28
+
29
+ def show_route_instructions
30
+ say ""
31
+ say "Sentiero installed successfully!", :green
32
+ say ""
33
+ say "Next steps:", :yellow
34
+ say " 1. Run migrations: rails db:migrate"
35
+ say " 2. Mount the Rack apps in config/routes.rb:"
36
+ say ""
37
+ say " # config/routes.rb"
38
+ say ' mount Sentiero::Web::EventsApp.new, at: "/sentiero/events"'
39
+ say ' mount Sentiero::Web::DashboardApp.new, at: "/sentiero"'
40
+ say ""
41
+ say " 3. Add the script tag to your layout:"
42
+ say " <%= sentiero_script_tag %>"
43
+ say ""
44
+ password = SecureRandom.urlsafe_base64(12)
45
+ say "Dashboard auth is ENABLED by default (HTTP Basic, user \"admin\").", :yellow
46
+ say "Set this generated password in your environment:"
47
+ say ""
48
+ say " export SENTIERO_DASHBOARD_PASSWORD=#{password}"
49
+ say ""
50
+ say "The dashboard refuses to load until SENTIERO_DASHBOARD_PASSWORD is set."
51
+ say "To disable auth, comment out config.basic_auth in config/initializers/sentiero.rb."
52
+ say ""
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,74 @@
1
+ class CreateSentieroTables < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
2
+ def change
3
+ create_table :sentiero_sessions do |t|
4
+ t.string :session_id, null: false
5
+ t.json :metadata
6
+ t.timestamps
7
+ end
8
+
9
+ add_index :sentiero_sessions, :session_id, unique: true
10
+
11
+ create_table :sentiero_events do |t|
12
+ t.string :session_id, null: false
13
+ t.string :window_id, null: false
14
+ t.float :timestamp
15
+ t.json :data
16
+ t.datetime :created_at, null: false
17
+ end
18
+
19
+ add_index :sentiero_events, [:session_id, :window_id, :timestamp],
20
+ name: "index_sentiero_events_on_session_window_timestamp"
21
+ add_index :sentiero_events, :session_id
22
+
23
+ create_table :sentiero_problems do |t|
24
+ t.string :fingerprint, null: false
25
+ t.string :project, null: false
26
+ t.string :exception_class, null: false
27
+ t.string :title, null: false
28
+ t.string :message
29
+ t.integer :count, null: false, default: 0
30
+ t.string :status, null: false, default: "open"
31
+ t.float :first_seen, null: false
32
+ t.float :last_seen, null: false
33
+ t.float :resolved_at
34
+ end
35
+
36
+ add_index :sentiero_problems, :fingerprint, unique: true
37
+ add_index :sentiero_problems, :project
38
+ add_index :sentiero_problems, :status
39
+ add_index :sentiero_problems, :last_seen
40
+
41
+ create_table :sentiero_occurrences do |t|
42
+ t.string :occurrence_id, null: false
43
+ t.string :fingerprint, null: false
44
+ t.string :session_id
45
+ t.float :timestamp, null: false
46
+ t.json :data, null: false
47
+ end
48
+
49
+ add_index :sentiero_occurrences, [:fingerprint, :timestamp],
50
+ name: "index_sentiero_occurrences_on_fingerprint_timestamp"
51
+ add_index :sentiero_occurrences, :session_id
52
+ add_index :sentiero_occurrences, :occurrence_id, unique: true
53
+
54
+ create_table :sentiero_server_events do |t|
55
+ t.string :event_id, null: false
56
+ t.string :project, null: false
57
+ t.string :name, null: false
58
+ t.string :level
59
+ t.string :session_id
60
+ t.float :timestamp, null: false
61
+ t.json :data, null: false
62
+ end
63
+
64
+ add_index :sentiero_server_events, :project
65
+ add_index :sentiero_server_events, :session_id
66
+ add_index :sentiero_server_events, :event_id, unique: true
67
+
68
+ add_check_constraint :sentiero_problems, "status IN ('open', 'resolved', 'ignored')",
69
+ name: "sentiero_problems_status_check"
70
+
71
+ add_foreign_key :sentiero_events, :sentiero_sessions,
72
+ column: :session_id, primary_key: :session_id, on_delete: :cascade
73
+ end
74
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Sentiero configuration
4
+ #
5
+ # For full documentation, see: https://github.com/stevegeek/sentiero
6
+
7
+ Sentiero.configure do |config|
8
+ # Store: defaults to ActiveRecord (Sentiero::Rails::Store) when using sentiero-rails.
9
+ # You can switch to Redis or Memory stores if preferred:
10
+ # config.store = Sentiero::Stores::Memory.new
11
+ # config.store = Sentiero::Stores::Redis.new(redis: Redis.new)
12
+ # config.store = Sentiero::Rails::Store.new
13
+
14
+ # CORS origins: list of allowed origins for the events endpoint.
15
+ # Required if your frontend is on a different domain.
16
+ # config.cors_origins = ["https://yourapp.com"]
17
+
18
+ # Maximum events accepted per single POST request (default: nil = unlimited).
19
+ # config.max_events_per_request = 500
20
+
21
+ # Maximum total events stored per session (oldest dropped when exceeded).
22
+ # Enforced by the ActiveRecord, Memory, File, and SQLite stores. The Redis
23
+ # store ignores it; cap session size there with the store's :ttl option.
24
+ # config.max_events_per_session = 10_000
25
+
26
+ # Maximum sessions stored (oldest evicted when exceeded).
27
+ # Enforced by the ActiveRecord, Memory, File, and SQLite stores. The Redis
28
+ # store ignores it; cap retention there with the store's :ttl option.
29
+ # config.max_sessions = 1_000
30
+
31
+ # Data retention: automatically purge sessions older than a given period.
32
+ # Set the period in seconds, then schedule `rake sentiero:purge` from cron
33
+ # or a job scheduler (e.g. Sidekiq::Cron, Clockwork, whenever).
34
+ # config.retention_period = 90 * 24 * 3600 # 90 days
35
+
36
+ # Recorder flush settings (milliseconds / event count).
37
+ # config.flush_interval_ms = 10_000
38
+ # config.flush_event_threshold = 50
39
+
40
+ # Dashboard authentication (HTTP Basic).
41
+ #
42
+ # Enabled by default. Set the password in your environment:
43
+ # export SENTIERO_DASHBOARD_PASSWORD=...
44
+ # The dashboard refuses to load (raises) until a non-blank password is set.
45
+ # With no auth configured the dashboard fails closed (403); to serve it
46
+ # unauthenticated anyway, remove this block and set
47
+ # `config.allow_insecure_dashboard = true`.
48
+ config.basic_auth = {
49
+ user: "admin",
50
+ password: ENV["SENTIERO_DASHBOARD_PASSWORD"]
51
+ }
52
+
53
+ # Alternative: app-session-based auth instead of HTTP Basic. Comment out
54
+ # config.basic_auth above and set a callback returning true/false:
55
+ #
56
+ # config.auth_callback = ->(env) {
57
+ # env["warden"]&.user&.admin? || false
58
+ # }
59
+ end
60
+
61
+ # Rails-specific configuration
62
+ # Sentiero::Rails.configure do |config|
63
+ # # The URL where EventsApp is mounted (used by the sentiero_script_tag helper).
64
+ # # config.events_url = "/sentiero/events"
65
+ #
66
+ # # Server-side error reporter middleware is auto-inserted when the reporter is
67
+ # # configured below. Set to false to opt out of the auto-install.
68
+ # # config.reporter_middleware = true
69
+ # end
70
+
71
+ # Server-side Error Tracking (reporter)
72
+ #
73
+ # The reporter sends unhandled exceptions and custom events to a Sentiero
74
+ # ingest endpoint. When configured, the Rack middleware is auto-inserted into
75
+ # your app's middleware stack (zero-config capture). It also links server-side
76
+ # errors to front-end session replay via the sentiero_sid / sentiero_wid
77
+ # cookies. Uncomment and fill in to enable:
78
+ #
79
+ # Sentiero::Reporter.configure do |r|
80
+ # r.endpoint = ENV["SENTIERO_ENDPOINT"] # e.g. "https://sentiero.example.com"
81
+ # r.ingest_key = ENV["SENTIERO_INGEST_KEY"] # server-issued ingest key
82
+ # r.project = "my-app" # project identifier
83
+ # r.environment = Rails.env # "production", "staging", ...
84
+ # r.release = ENV["GIT_SHA"] # optional release/version
85
+ #
86
+ # # Don't report these (Class or "String" class-names; ancestors match too):
87
+ # # r.ignore_exceptions = [ActiveRecord::RecordNotFound, "ActionController::RoutingError"]
88
+ #
89
+ # # Mutate or drop a report before it is sent (return false/nil to drop):
90
+ # # r.before_notify = ->(report) {
91
+ # # report["context"].delete("secret")
92
+ # # report
93
+ # # }
94
+ #
95
+ # # Redact context/payload keys before sending. Matching is case-insensitive
96
+ # # and substring-based. filter_keys is added on top of the built-in defaults:
97
+ # # r.filter_keys = [:api_secret, :otp]
98
+ # #
99
+ # # The built-in defaults (password, token, ssn, ...) seed default_filter_keys,
100
+ # # which you can edit to relax the floor:
101
+ # # r.default_filter_keys -= ["ssn"]
102
+ #
103
+ # # Use a non-network transport in development/test:
104
+ # # r.transport = Sentiero::Reporter::LogTransport.new # logs would-be sends
105
+ # # r.transport = Sentiero::Reporter::NullTransport.new # drops everything
106
+ # end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sentiero
4
+ module Rails
5
+ module Helpers
6
+ module ScriptTagHelper
7
+ def sentiero_script_tag(events_url: nil, recorder_url: nil)
8
+ events_url ||= Sentiero::Rails.configuration.events_url
9
+ Sentiero::Web::ScriptTag.render(events_url: events_url, recorder_url: recorder_url).html_safe
10
+ end
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sentiero
4
+ module Rails
5
+ class Event < ::ActiveRecord::Base
6
+ self.table_name = "sentiero_events"
7
+
8
+ belongs_to :session,
9
+ class_name: "Sentiero::Rails::Session",
10
+ primary_key: :session_id,
11
+ inverse_of: false
12
+
13
+ validates :session_id, :window_id, presence: true
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sentiero
4
+ module Rails
5
+ class Occurrence < ::ActiveRecord::Base
6
+ self.table_name = "sentiero_occurrences"
7
+
8
+ validates :occurrence_id, presence: true, uniqueness: true
9
+ validates :fingerprint, presence: true, format: {with: Sentiero::Store::VALID_ID}
10
+ validates :timestamp, presence: true
11
+ validates :data, presence: true
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sentiero
4
+ module Rails
5
+ class Problem < ::ActiveRecord::Base
6
+ self.table_name = "sentiero_problems"
7
+
8
+ validates :fingerprint, presence: true, uniqueness: true,
9
+ format: {with: Sentiero::Store::VALID_ID}
10
+ validates :project, presence: true, format: {with: Sentiero::Store::VALID_ID}
11
+ validates :exception_class, presence: true
12
+ validates :title, presence: true, length: {maximum: Sentiero::Store::PROBLEM_TITLE_MAX}
13
+ validates :status, inclusion: {in: Sentiero::Store::VALID_STATUS}
14
+ validates :count, numericality: {only_integer: true, greater_than_or_equal_to: 0}
15
+ validates :first_seen, :last_seen, presence: true
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sentiero
4
+ module Rails
5
+ class ServerEvent < ::ActiveRecord::Base
6
+ self.table_name = "sentiero_server_events"
7
+
8
+ validates :event_id, presence: true, uniqueness: true
9
+ validates :project, presence: true, format: {with: Sentiero::Store::VALID_ID}
10
+ validates :name, presence: true
11
+ validates :timestamp, presence: true
12
+ validates :data, presence: true
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sentiero
4
+ module Rails
5
+ class Session < ::ActiveRecord::Base
6
+ self.table_name = "sentiero_sessions"
7
+
8
+ has_many :events,
9
+ class_name: "Sentiero::Rails::Event",
10
+ primary_key: :session_id,
11
+ dependent: :delete_all,
12
+ inverse_of: false
13
+
14
+ validates :session_id, presence: true, uniqueness: true
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,534 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require_relative "models/session"
5
+ require_relative "models/event"
6
+ require_relative "models/problem"
7
+ require_relative "models/occurrence"
8
+ require_relative "models/server_event"
9
+
10
+ module Sentiero
11
+ module Rails
12
+ class Store < Sentiero::Store
13
+ def initialize(limits: nil)
14
+ @limits = limits
15
+ end
16
+
17
+ def save_events(ref, events)
18
+ return if events.nil? || events.empty?
19
+ session_id, window_id = ref.session_id, ref.window_id
20
+
21
+ now = Time.now
22
+
23
+ Session.transaction do
24
+ session = find_or_create_session!(session_id, now)
25
+ session.update_column(:updated_at, now) unless session.previously_new_record?
26
+
27
+ rows = events.map { |event|
28
+ {
29
+ session_id: session_id,
30
+ window_id: window_id,
31
+ timestamp: event["timestamp"]&.to_f,
32
+ data: event,
33
+ created_at: now
34
+ }
35
+ }
36
+ Event.insert_all(rows)
37
+
38
+ enforce_max_events_per_session(session_id)
39
+ enforce_max_sessions(session_id)
40
+ end
41
+
42
+ nil
43
+ end
44
+
45
+ # Batched scan: one events query for the whole session page instead of the
46
+ # base's get_session + get_events per window.
47
+ def each_session_events(limit: nil, since: nil, until_time: nil)
48
+ return enum_for(:each_session_events, limit: limit, since: since, until_time: until_time) unless block_given?
49
+
50
+ cap = limit || limits.analytics_max_scan_sessions
51
+ summaries = list_sessions(limit: cap, since: since, until_time: until_time)
52
+ return if summaries.empty?
53
+
54
+ events = events_by_session_window(summaries.map { |summary| summary[:session_id] })
55
+ summaries.each do |summary|
56
+ (events[summary[:session_id]] || {}).each do |window_id, window_events|
57
+ yield summary, window_id, window_events
58
+ end
59
+ end
60
+ end
61
+
62
+ def list_sessions(limit:, offset: 0, since: nil, until_time: nil, sort_by: nil, search: nil)
63
+ scope = filtered_session_scope(since:, until_time:, search:)
64
+ scope = ordered_session_scope(scope, sort_by)
65
+
66
+ sessions = scope.offset(offset).limit(limit).to_a
67
+ return [] if sessions.empty?
68
+
69
+ sids = sessions.map(&:session_id)
70
+ window_ids_by_session = window_ids_for(sids)
71
+ counts_by_session = event_counts_for(sids)
72
+ timestamp_ranges = timestamp_ranges_for(sids)
73
+
74
+ sessions.map { |session|
75
+ session_summary(session, window_ids_by_session, counts_by_session, timestamp_ranges)
76
+ }
77
+ end
78
+
79
+ def get_session(session_id)
80
+ validate_id!(session_id)
81
+ session = Session.find_by(session_id: session_id)
82
+ return nil unless session
83
+
84
+ window_stats = Event.where(session_id: session_id)
85
+ .group(:window_id)
86
+ .pluck(:window_id, Arel.sql("COUNT(*)"), Arel.sql("MIN(timestamp)"), Arel.sql("MAX(timestamp)"))
87
+
88
+ window_data = window_stats.map { |wid, count, first_ts, last_ts|
89
+ entry = {window_id: wid, event_count: count}
90
+ entry[:first_event_at] = first_ts.to_f if first_ts
91
+ entry[:last_event_at] = last_ts.to_f if last_ts
92
+ entry
93
+ }
94
+
95
+ timestamps = Event.where(session_id: session_id)
96
+ .pick(Arel.sql("MIN(timestamp)"), Arel.sql("MAX(timestamp)"))
97
+
98
+ result = {
99
+ session_id: session.session_id,
100
+ windows: window_data,
101
+ created_at: session.created_at.to_f,
102
+ updated_at: session.updated_at.to_f,
103
+ first_event_at: timestamps&.first&.to_f,
104
+ last_event_at: timestamps&.last&.to_f
105
+ }
106
+ if metadata_column? && session.metadata.present?
107
+ result[:metadata] = session.metadata
108
+ end
109
+ result
110
+ end
111
+
112
+ def get_events(ref, after: nil, limit: nil)
113
+ validate_window_ref!(ref)
114
+ session_id, window_id = ref.session_id, ref.window_id
115
+ scope = Event.where(session_id: session_id, window_id: window_id)
116
+ .order(timestamp: :asc)
117
+
118
+ scope = scope.where("timestamp > ?", after.to_f) if after
119
+ scope = scope.limit(limit) if limit
120
+
121
+ scope.map(&:data)
122
+ end
123
+
124
+ def save_metadata(session_id, metadata)
125
+ return unless metadata.is_a?(Hash) && !metadata.empty?
126
+ return unless metadata_column?
127
+ validate_metadata!(metadata)
128
+
129
+ session = Session.find_by(session_id: session_id)
130
+ return unless session
131
+
132
+ # Locked reload-merge-save, mirroring save_occurrence's Problem.lock:
133
+ # an unlocked read-modify-write here drops keys from a concurrent
134
+ # merge (e.g. this same method called for "has_errors" from
135
+ # save_occurrence racing a client-supplied metadata update).
136
+ session.with_lock do
137
+ existing = session.metadata || {}
138
+ session.update_column(:metadata, existing.merge(metadata.transform_keys(&:to_s)))
139
+ end
140
+ nil
141
+ end
142
+
143
+ def delete_session(session_id)
144
+ validate_id!(session_id)
145
+ Session.transaction do
146
+ destroy_session_data(session_id)
147
+ end
148
+ nil
149
+ end
150
+
151
+ def delete_window(ref)
152
+ validate_window_ref!(ref)
153
+ session_id, window_id = ref.session_id, ref.window_id
154
+ Session.transaction do
155
+ Event.where(session_id: session_id, window_id: window_id).delete_all
156
+
157
+ remaining = Event.where(session_id: session_id).exists?
158
+ unless remaining
159
+ Session.where(session_id: session_id).delete_all
160
+ end
161
+ end
162
+ nil
163
+ end
164
+
165
+ # Subquery-based DELETEs avoid loading stale ids into Ruby and large IN (...) lists.
166
+ def purge_older_than(seconds)
167
+ cutoff = Time.at(Time.now.to_f - seconds)
168
+ cutoff_f = cutoff.to_f
169
+ session_count = nil
170
+
171
+ Session.transaction do
172
+ stale = Session.where("updated_at < ?", cutoff)
173
+ Event.where(session_id: stale.select(:session_id)).delete_all
174
+ session_count = stale.delete_all
175
+
176
+ ServerEvent.where("timestamp < ?", cutoff_f).delete_all
177
+ Occurrence.where("timestamp < ?", cutoff_f).delete_all
178
+ stale_fps = Problem.where("last_seen < ?", cutoff_f).pluck(:fingerprint)
179
+ unless stale_fps.empty?
180
+ Occurrence.where(fingerprint: stale_fps).delete_all
181
+ Problem.where(fingerprint: stale_fps).delete_all
182
+ end
183
+ end
184
+
185
+ session_count
186
+ end
187
+
188
+ def save_occurrence(occurrence)
189
+ validate_occurrence!(occurrence)
190
+ fp = occurrence["fingerprint"]
191
+ ts = occurrence["timestamp"].to_f
192
+ occ_id = SecureRandom.uuid
193
+ stored = occurrence.merge("id" => occ_id)
194
+
195
+ # On a concurrent insert of a new fingerprint one writer's save! raises
196
+ # (RecordInvalid from the uniqueness validator, or RecordNotUnique from the
197
+ # DB index); retry so the loser re-runs and takes the "existing" branch.
198
+ attempts = 0
199
+ begin
200
+ Problem.transaction do
201
+ # SELECT ... FOR UPDATE so the count+1 / last_seen read-modify-write is
202
+ # serialized; an unlocked find lost concurrent updates under READ COMMITTED.
203
+ problem = Problem.lock.find_by(fingerprint: fp)
204
+ if problem
205
+ reopening = problem.status == "resolved"
206
+ problem.count += 1
207
+ problem.first_seen = [problem.first_seen, ts].min
208
+ problem.last_seen = [problem.last_seen, ts].max
209
+ problem.message = occurrence["message"]
210
+ if reopening
211
+ problem.status = "open"
212
+ problem.resolved_at = nil
213
+ end
214
+ else
215
+ problem = Problem.new(fingerprint: fp)
216
+ problem.project = occurrence["project"]
217
+ problem.exception_class = occurrence["exception_class"]
218
+ problem.title = build_problem_title(occurrence)
219
+ problem.message = occurrence["message"]
220
+ problem.count = 1
221
+ problem.status = "open"
222
+ problem.first_seen = ts
223
+ problem.last_seen = ts
224
+ problem.resolved_at = nil
225
+ end
226
+ problem.save!
227
+
228
+ Occurrence.create!(
229
+ occurrence_id: occ_id,
230
+ fingerprint: fp,
231
+ session_id: occurrence["session_id"],
232
+ timestamp: ts,
233
+ data: stored
234
+ )
235
+
236
+ enforce_max_problems
237
+ end
238
+ rescue ::ActiveRecord::RecordNotUnique
239
+ attempts += 1
240
+ retry if attempts < 2
241
+ raise
242
+ rescue ::ActiveRecord::RecordInvalid => e
243
+ raise unless e.record.errors[:fingerprint].any?
244
+ attempts += 1
245
+ retry if attempts < 2
246
+ raise
247
+ end
248
+
249
+ save_metadata(occurrence["session_id"], {"has_errors" => true}) if occurrence["session_id"]
250
+ fp
251
+ end
252
+
253
+ def list_problems(project:, limit:, offset: 0, status: nil, sort_by: nil, search: nil, since: nil, until_time: nil)
254
+ scope = Problem.all
255
+ scope = scope.where(project: project) unless project.nil?
256
+ scope = scope.where(status: status) if status
257
+ scope = scope.where("last_seen >= ?", since.to_f) if since
258
+ scope = scope.where("last_seen <= ?", until_time.to_f) if until_time
259
+ if search && !search.empty?
260
+ pattern = "%#{search}%"
261
+ scope = scope.where("title LIKE ? OR exception_class LIKE ?", pattern, pattern)
262
+ end
263
+ scope = case sort_by
264
+ when "first_seen" then scope.order(first_seen: :desc)
265
+ when "count" then scope.order(count: :desc)
266
+ else scope.order(last_seen: :desc)
267
+ end
268
+ scope.offset(offset).limit(limit).map { |p| problem_to_hash(p) }
269
+ end
270
+
271
+ def get_problem(problem_id)
272
+ validate_id!(problem_id)
273
+ problem = Problem.find_by(fingerprint: problem_id)
274
+ problem ? problem_to_hash(problem) : nil
275
+ end
276
+
277
+ def get_occurrences(problem_id, after: nil, limit: nil)
278
+ validate_id!(problem_id)
279
+ scope = Occurrence.where(fingerprint: problem_id).order(timestamp: :asc)
280
+ scope = scope.where("timestamp > ?", after.to_f) if after
281
+ scope = scope.limit(limit) if limit
282
+ scope.map(&:data)
283
+ end
284
+
285
+ # COUNT in SQL instead of materializing every row.
286
+ def count_occurrences(problem_id, after: nil)
287
+ validate_id!(problem_id)
288
+ scope = Occurrence.where(fingerprint: problem_id)
289
+ scope = scope.where("timestamp > ?", after.to_f) if after
290
+ scope.count
291
+ end
292
+
293
+ def update_problem_status(problem_id, status)
294
+ validate_id!(problem_id)
295
+ validate_status!(status)
296
+ resolved_at = (status == "resolved") ? Time.now.to_f : nil
297
+ Problem.where(fingerprint: problem_id).update_all(status: status, resolved_at: resolved_at)
298
+ nil
299
+ end
300
+
301
+ def save_server_event(event)
302
+ validate_server_event!(event)
303
+ ev_id = SecureRandom.uuid
304
+ stored = event.merge("id" => ev_id)
305
+ ServerEvent.transaction do
306
+ ServerEvent.create!(
307
+ event_id: ev_id,
308
+ project: event["project"],
309
+ name: event["name"],
310
+ level: event["level"],
311
+ session_id: event["session_id"],
312
+ timestamp: event["timestamp"].to_f,
313
+ data: stored
314
+ )
315
+ enforce_max_server_events
316
+ end
317
+ nil
318
+ end
319
+
320
+ def get_server_event(event_id)
321
+ validate_id!(event_id)
322
+ ServerEvent.find_by(event_id: event_id)&.data
323
+ end
324
+
325
+ def list_server_events(project:, limit:, name: nil, level: nil, session_id: nil, after: nil)
326
+ scope = ServerEvent.all
327
+ scope = scope.where(project: project) unless project.nil?
328
+ scope = scope.order(timestamp: :asc)
329
+ scope = scope.where(name: name) if name
330
+ scope = scope.where(level: level) if level
331
+ scope = scope.where(session_id: session_id) if session_id
332
+ scope = scope.where("timestamp > ?", after.to_f) if after
333
+ scope.limit(limit).map(&:data)
334
+ end
335
+
336
+ def occurrences_for_session(session_id, limit: nil)
337
+ validate_id!(session_id)
338
+ scope = Occurrence.where(session_id: session_id).order(timestamp: :asc)
339
+ scope = scope.limit(limit) if limit
340
+ scope.map(&:data)
341
+ end
342
+
343
+ def server_events_for_session(session_id, limit: nil)
344
+ validate_id!(session_id)
345
+ scope = ServerEvent.where(session_id: session_id).order(timestamp: :asc)
346
+ scope = scope.limit(limit) if limit
347
+ scope.map(&:data)
348
+ end
349
+
350
+ def session_ids_for_problem(problem_id, limit: nil)
351
+ validate_id!(problem_id)
352
+ scope = Occurrence.where(fingerprint: problem_id)
353
+ .where.not(session_id: nil)
354
+ .group(:session_id)
355
+ .order(Arel.sql("MAX(timestamp) DESC"))
356
+ .pluck(:session_id)
357
+ limit ? scope.first(limit) : scope
358
+ end
359
+
360
+ private
361
+
362
+ def metadata_column?
363
+ Session.column_names.include?("metadata")
364
+ end
365
+
366
+ def filtered_session_scope(since:, until_time:, search:)
367
+ scope = Session.all
368
+ scope = scope.where("updated_at >= ?", Time.at(since.to_f)) if since
369
+ scope = scope.where("updated_at <= ?", Time.at(until_time.to_f)) if until_time
370
+
371
+ if search && !search.empty?
372
+ pattern = "%#{search}%"
373
+ scope = if metadata_column?
374
+ scope.where("session_id LIKE ? OR CAST(metadata AS TEXT) LIKE ?", pattern, pattern)
375
+ else
376
+ scope.where("session_id LIKE ?", pattern)
377
+ end
378
+ end
379
+
380
+ scope
381
+ end
382
+
383
+ def ordered_session_scope(scope, sort_by)
384
+ if sort_by == "event_count"
385
+ scope
386
+ .joins("LEFT JOIN #{Event.table_name} ON #{Event.table_name}.session_id = #{Session.table_name}.session_id")
387
+ .group("#{Session.table_name}.id")
388
+ .order(Arel.sql("COUNT(#{Event.table_name}.id) DESC"))
389
+ else
390
+ sort_column = case sort_by
391
+ when "created_at" then :created_at
392
+ else :updated_at
393
+ end
394
+ scope.order(sort_column => :desc)
395
+ end
396
+ end
397
+
398
+ def window_ids_for(session_ids)
399
+ Event.where(session_id: session_ids)
400
+ .distinct.pluck(:session_id, :window_id)
401
+ .group_by(&:first)
402
+ .transform_values { |pairs| pairs.map(&:last) }
403
+ end
404
+
405
+ def events_by_session_window(session_ids)
406
+ grouped = Hash.new { |h, sid| h[sid] = {} }
407
+ Event.where(session_id: session_ids).order(:timestamp).each do |event|
408
+ (grouped[event.session_id][event.window_id] ||= []) << event.data
409
+ end
410
+ grouped
411
+ end
412
+
413
+ def event_counts_for(session_ids)
414
+ Event.where(session_id: session_ids)
415
+ .group(:session_id).count
416
+ end
417
+
418
+ def timestamp_ranges_for(session_ids)
419
+ Event.where(session_id: session_ids)
420
+ .group(:session_id)
421
+ .pluck(:session_id, Arel.sql("MIN(timestamp)"), Arel.sql("MAX(timestamp)"))
422
+ .to_h { |sid, min_ts, max_ts| [sid, {first: min_ts&.to_f, last: max_ts&.to_f}] }
423
+ end
424
+
425
+ def session_summary(session, window_ids_by_session, counts_by_session, timestamp_ranges)
426
+ range = timestamp_ranges[session.session_id]
427
+ summary_hash(
428
+ session_id: session.session_id,
429
+ window_ids: window_ids_by_session[session.session_id] || [],
430
+ event_count: counts_by_session[session.session_id] || 0,
431
+ created_at: session.created_at.to_f,
432
+ updated_at: session.updated_at.to_f,
433
+ first_event_at: range&.dig(:first),
434
+ last_event_at: range&.dig(:last),
435
+ metadata: metadata_column? ? session.metadata : nil
436
+ )
437
+ end
438
+
439
+ def find_or_create_session!(session_id, now)
440
+ Session.find_or_create_by!(session_id: session_id) do |s|
441
+ s.created_at = now
442
+ s.updated_at = now
443
+ end
444
+ rescue ::ActiveRecord::RecordNotUnique
445
+ Session.find_by!(session_id: session_id)
446
+ end
447
+
448
+ def enforce_max_events_per_session(session_id)
449
+ max_events = limits.max_events_per_session
450
+ return unless max_events
451
+
452
+ total = Event.where(session_id: session_id).count
453
+ return unless total > max_events
454
+
455
+ excess = total - max_events
456
+ oldest_ids = Event.where(session_id: session_id)
457
+ .order(timestamp: :asc)
458
+ .limit(excess)
459
+ .pluck(:id)
460
+ Event.where(id: oldest_ids).delete_all
461
+ end
462
+
463
+ def enforce_max_sessions(protected_session_id)
464
+ max_sessions = limits.max_sessions
465
+ return unless max_sessions
466
+
467
+ total = Session.count
468
+ return unless total > max_sessions
469
+
470
+ to_evict = total - max_sessions
471
+ oldest = Session.where.not(session_id: protected_session_id)
472
+ .order(updated_at: :asc)
473
+ .limit(to_evict)
474
+ .pluck(:session_id)
475
+
476
+ return if oldest.empty?
477
+
478
+ Event.where(session_id: oldest).delete_all
479
+ Session.where(session_id: oldest).delete_all
480
+ end
481
+
482
+ def destroy_session_data(session_id)
483
+ Event.where(session_id: session_id).delete_all
484
+ Session.where(session_id: session_id).delete_all
485
+ # GDPR erasure of session-scoped rows. The Problem aggregate is retained,
486
+ # though its title/message may still hold PII — a known erasure residue.
487
+ Occurrence.where(session_id: session_id).delete_all
488
+ ServerEvent.where(session_id: session_id).delete_all
489
+ end
490
+
491
+ def problem_to_hash(problem)
492
+ {
493
+ id: problem.fingerprint,
494
+ project: problem.project,
495
+ exception_class: problem.exception_class,
496
+ title: problem.title,
497
+ message: problem.message,
498
+ count: problem.count,
499
+ status: problem.status,
500
+ first_seen: problem.first_seen,
501
+ last_seen: problem.last_seen,
502
+ resolved_at: problem.resolved_at
503
+ }
504
+ end
505
+
506
+ def enforce_max_problems
507
+ max = limits.max_problems
508
+ return unless max
509
+
510
+ total = Problem.count
511
+ return unless total > max
512
+
513
+ excess = total - max
514
+ oldest_fps = Problem.order(last_seen: :asc).limit(excess).pluck(:fingerprint)
515
+ return if oldest_fps.empty?
516
+
517
+ Occurrence.where(fingerprint: oldest_fps).delete_all
518
+ Problem.where(fingerprint: oldest_fps).delete_all
519
+ end
520
+
521
+ def enforce_max_server_events
522
+ max = limits.max_server_events
523
+ return unless max
524
+
525
+ total = ServerEvent.count
526
+ return unless total > max
527
+
528
+ excess = total - max
529
+ oldest_ids = ServerEvent.order(timestamp: :asc).limit(excess).pluck(:id)
530
+ ServerEvent.where(id: oldest_ids).delete_all
531
+ end
532
+ end
533
+ end
534
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ namespace :sentiero do
6
+ desc "Purge sessions older than config.retention_period (destructive, irreversible)"
7
+ task purge: :environment do
8
+ deleted = Sentiero.purge_expired!
9
+ if deleted.nil?
10
+ puts "Sentiero: retention_period not configured; nothing purged."
11
+ else
12
+ puts "Sentiero: purged #{deleted} session(s)."
13
+ end
14
+ end
15
+
16
+ desc "Erase sessions by ID or time range (GDPR Art. 17; destructive, irreversible)"
17
+ task erase: :environment do
18
+ ids_env = ENV["SESSION_IDS"] || ENV["SESSION_ID"]
19
+ if ids_env
20
+ deleted = Sentiero.erase_sessions(ids_env.split(","))
21
+ puts "Sentiero: erased #{deleted} session(s)."
22
+ elsif ENV["SINCE"] || ENV["UNTIL"]
23
+ since = ENV["SINCE"] ? Time.parse(ENV["SINCE"]) : nil
24
+ until_time = ENV["UNTIL"] ? Time.parse(ENV["UNTIL"]) : nil
25
+ deleted = Sentiero.erase_where(since: since, until_time: until_time)
26
+ puts "Sentiero: erased #{deleted} session(s)."
27
+ else
28
+ abort "Usage: rake sentiero:erase SESSION_IDS=id1,id2 OR SINCE=YYYY-MM-DD UNTIL=YYYY-MM-DD"
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "sentiero/version"
4
+
5
+ module Sentiero
6
+ module Rails
7
+ VERSION = Sentiero::VERSION
8
+ end
9
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "sentiero"
4
+ require_relative "rails/version"
5
+ require_relative "rails/configuration"
6
+ require_relative "rails/engine"
metadata ADDED
@@ -0,0 +1,114 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: sentiero-rails
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0.alpha1
5
+ platform: ruby
6
+ authors:
7
+ - Stephen Ierodiaconou
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: sentiero
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: 1.0.0.alpha1
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: 1.0.0.alpha1
26
+ - !ruby/object:Gem::Dependency
27
+ name: railties
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '7.0'
33
+ - - "<"
34
+ - !ruby/object:Gem::Version
35
+ version: '9.0'
36
+ type: :runtime
37
+ prerelease: false
38
+ version_requirements: !ruby/object:Gem::Requirement
39
+ requirements:
40
+ - - ">="
41
+ - !ruby/object:Gem::Version
42
+ version: '7.0'
43
+ - - "<"
44
+ - !ruby/object:Gem::Version
45
+ version: '9.0'
46
+ - !ruby/object:Gem::Dependency
47
+ name: activerecord
48
+ requirement: !ruby/object:Gem::Requirement
49
+ requirements:
50
+ - - ">="
51
+ - !ruby/object:Gem::Version
52
+ version: '7.0'
53
+ - - "<"
54
+ - !ruby/object:Gem::Version
55
+ version: '9.0'
56
+ type: :runtime
57
+ prerelease: false
58
+ version_requirements: !ruby/object:Gem::Requirement
59
+ requirements:
60
+ - - ">="
61
+ - !ruby/object:Gem::Version
62
+ version: '7.0'
63
+ - - "<"
64
+ - !ruby/object:Gem::Version
65
+ version: '9.0'
66
+ description: Rails engine providing ActiveRecord storage, view helpers, and generators
67
+ for the Sentiero session recording gem.
68
+ email:
69
+ - stevegeek@gmail.com
70
+ executables: []
71
+ extensions: []
72
+ extra_rdoc_files: []
73
+ files:
74
+ - LICENSE.txt
75
+ - lib/sentiero/rails.rb
76
+ - lib/sentiero/rails/configuration.rb
77
+ - lib/sentiero/rails/engine.rb
78
+ - lib/sentiero/rails/generators/sentiero/install_generator.rb
79
+ - lib/sentiero/rails/generators/sentiero/templates/create_sentiero_tables.rb.erb
80
+ - lib/sentiero/rails/generators/sentiero/templates/initializer.rb
81
+ - lib/sentiero/rails/helpers/script_tag_helper.rb
82
+ - lib/sentiero/rails/models/event.rb
83
+ - lib/sentiero/rails/models/occurrence.rb
84
+ - lib/sentiero/rails/models/problem.rb
85
+ - lib/sentiero/rails/models/server_event.rb
86
+ - lib/sentiero/rails/models/session.rb
87
+ - lib/sentiero/rails/store.rb
88
+ - lib/sentiero/rails/tasks/sentiero.rake
89
+ - lib/sentiero/rails/version.rb
90
+ homepage: https://github.com/stevegeek/sentiero
91
+ licenses:
92
+ - MIT
93
+ metadata:
94
+ homepage_uri: https://github.com/stevegeek/sentiero
95
+ source_code_uri: https://github.com/stevegeek/sentiero
96
+ bug_tracker_uri: https://github.com/stevegeek/sentiero/issues
97
+ rdoc_options: []
98
+ require_paths:
99
+ - lib
100
+ required_ruby_version: !ruby/object:Gem::Requirement
101
+ requirements:
102
+ - - ">="
103
+ - !ruby/object:Gem::Version
104
+ version: 3.3.0
105
+ required_rubygems_version: !ruby/object:Gem::Requirement
106
+ requirements:
107
+ - - ">="
108
+ - !ruby/object:Gem::Version
109
+ version: '0'
110
+ requirements: []
111
+ rubygems_version: 4.0.3
112
+ specification_version: 4
113
+ summary: Rails integration for Sentiero browser session recording
114
+ test_files: []