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 +7 -0
- data/LICENSE.txt +7 -0
- data/lib/sentiero/rails/configuration.rb +28 -0
- data/lib/sentiero/rails/engine.rb +55 -0
- data/lib/sentiero/rails/generators/sentiero/install_generator.rb +56 -0
- data/lib/sentiero/rails/generators/sentiero/templates/create_sentiero_tables.rb.erb +74 -0
- data/lib/sentiero/rails/generators/sentiero/templates/initializer.rb +106 -0
- data/lib/sentiero/rails/helpers/script_tag_helper.rb +14 -0
- data/lib/sentiero/rails/models/event.rb +16 -0
- data/lib/sentiero/rails/models/occurrence.rb +14 -0
- data/lib/sentiero/rails/models/problem.rb +18 -0
- data/lib/sentiero/rails/models/server_event.rb +15 -0
- data/lib/sentiero/rails/models/session.rb +17 -0
- data/lib/sentiero/rails/store.rb +534 -0
- data/lib/sentiero/rails/tasks/sentiero.rake +31 -0
- data/lib/sentiero/rails/version.rb +9 -0
- data/lib/sentiero/rails.rb +6 -0
- metadata +114 -0
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
|
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: []
|