trainspotter 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +103 -0
  4. data/Rakefile +11 -0
  5. data/app/controllers/trainspotter/application_controller.rb +11 -0
  6. data/app/controllers/trainspotter/requests_controller.rb +46 -0
  7. data/app/controllers/trainspotter/sessions_controller.rb +30 -0
  8. data/app/engine_assets/javascripts/application.js +7 -0
  9. data/app/engine_assets/javascripts/controllers/requests_controller.js +67 -0
  10. data/app/engine_assets/javascripts/controllers/sessions_controller.js +43 -0
  11. data/app/engine_assets/stylesheets/application.css +549 -0
  12. data/app/helpers/trainspotter/ansi_to_html.rb +72 -0
  13. data/app/helpers/trainspotter/application_helper.rb +9 -0
  14. data/app/jobs/trainspotter/ingest/line.rb +44 -0
  15. data/app/jobs/trainspotter/ingest/params_parser.rb +36 -0
  16. data/app/jobs/trainspotter/ingest/parser.rb +194 -0
  17. data/app/jobs/trainspotter/ingest/processor.rb +70 -0
  18. data/app/jobs/trainspotter/ingest/reader.rb +84 -0
  19. data/app/jobs/trainspotter/ingest/session_builder.rb +52 -0
  20. data/app/jobs/trainspotter/ingest_job.rb +10 -0
  21. data/app/models/trainspotter/file_position_record.rb +17 -0
  22. data/app/models/trainspotter/record.rb +103 -0
  23. data/app/models/trainspotter/request.rb +108 -0
  24. data/app/models/trainspotter/request_record.rb +133 -0
  25. data/app/models/trainspotter/session_record.rb +71 -0
  26. data/app/views/layouts/trainspotter/application.html.erb +20 -0
  27. data/app/views/trainspotter/requests/_request.html.erb +51 -0
  28. data/app/views/trainspotter/requests/index.html.erb +49 -0
  29. data/app/views/trainspotter/sessions/_session.html.erb +28 -0
  30. data/app/views/trainspotter/sessions/index.html.erb +42 -0
  31. data/config/cucumber.yml +8 -0
  32. data/config/routes.rb +15 -0
  33. data/lib/trainspotter/background_worker.rb +74 -0
  34. data/lib/trainspotter/configuration.rb +68 -0
  35. data/lib/trainspotter/engine.rb +45 -0
  36. data/lib/trainspotter/version.rb +3 -0
  37. data/lib/trainspotter.rb +30 -0
  38. metadata +150 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: f16e8da81b6991459274f9b8833de22ec79efb5fa2766ebf81e63b6e1546d578
4
+ data.tar.gz: 3af436892b6ee8a664710e22b28d9e2a58790462f9861cff8a2ae0cc0dea69be
5
+ SHA512:
6
+ metadata.gz: 0a6a38c16936cd01883a74ed208e163faec3829f19fbf5f01d7c0b003efc8e5bfca4e015236ce0aec7253cb912822f26c28d77b32fb1e92b69b49bdbba09e74d
7
+ data.tar.gz: c754879f16a6706abfabfd59dc7b9ad89f06713047f375a9912cb32d3abfaab91a7673b29cd2e110d53e854d2d8f869f3f66bb28adb3dcac83052661feac7aac
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright Micah Geisel
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,103 @@
1
+ # Trainspotter
2
+
3
+ A zero-config, web-based Rails log viewer with request grouping and session tracking. See your Rails logs in a beautiful, organized interface right in your browser.
4
+
5
+ ## Features
6
+
7
+ - **Zero configuration** - Just mount the engine and go
8
+ - **Request grouping** - See HTTP requests with their associated SQL queries and view renders
9
+ - **Session tracking** - Group requests by user session with automatic login/logout detection
10
+ - **Real-time updates** - New requests appear automatically via polling
11
+ - **Background processing** - Log ingestion runs in a background job
12
+ - **SQLite storage** - Separate database for fast queries without impacting your app
13
+ - **Dark/light mode** - Respects your system preference
14
+ - **Performance at a glance** - See request duration, query count, and render count
15
+
16
+ ## Installation
17
+
18
+ Add this line to your application's Gemfile:
19
+
20
+ ```ruby
21
+ gem "trainspotter"
22
+ ```
23
+
24
+ And then execute:
25
+
26
+ ```bash
27
+ bundle install
28
+ ```
29
+
30
+ ## Usage
31
+
32
+ Mount the engine in your `config/routes.rb`:
33
+
34
+ ```ruby
35
+ Rails.application.routes.draw do
36
+ mount Trainspotter::Engine => "/admin/logs"
37
+
38
+ # Your other routes...
39
+ end
40
+ ```
41
+
42
+ That's it! Visit `/trainspotter` in your browser to see your logs.
43
+
44
+ ### Restricting Access
45
+
46
+ In production, you'll likely want to restrict access. Here are some options:
47
+
48
+ **With Devise:**
49
+
50
+ ```ruby
51
+ authenticate :user, ->(u) { u.admin? } do
52
+ mount Trainspotter::Engine => "/trainspotter"
53
+ end
54
+ ```
55
+
56
+ **With HTTP Basic Auth:**
57
+
58
+ ```ruby
59
+ mount Trainspotter::Engine => "/trainspotter", constraints: ->(req) {
60
+ Rack::Auth::Basic::Request.new(req.env).provided? &&
61
+ Rack::Auth::Basic::Request.new(req.env).credentials == ["admin", ENV["TRAINSPOTTER_PASSWORD"]]
62
+ }
63
+ ```
64
+
65
+ **Development only:**
66
+
67
+ ```ruby
68
+ if Rails.env.development?
69
+ mount Trainspotter::Engine => "/trainspotter"
70
+ end
71
+ ```
72
+
73
+ ## How It Works
74
+
75
+ Trainspotter reads your Rails log files and parses the standard Rails log format. A background job (`IngestJob`) processes new log entries and stores them in a separate SQLite database.
76
+
77
+ **Requests View:**
78
+ - HTTP method and path
79
+ - Controller and action
80
+ - Response status (color-coded)
81
+ - Total duration
82
+ - Number of SQL queries and view renders
83
+ - Click to expand and see SQL queries and renders
84
+
85
+ **Sessions View:**
86
+ - Groups requests by user session (based on IP + time window)
87
+ - Automatic login/logout detection
88
+ - See all requests made during a session
89
+
90
+ Trainspotter supports tagged logging (`config.log_tags = [:request_id]`) for accurate request grouping in multi-threaded environments.
91
+
92
+ ## Requirements
93
+
94
+ - Rails 8.0+
95
+ - Ruby 3.3+
96
+
97
+ ## Contributing
98
+
99
+ Bug reports and pull requests are welcome on GitHub at https://github.com/botandrose/trainspotter.
100
+
101
+ ## License
102
+
103
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,11 @@
1
+ require "bundler/setup"
2
+
3
+ require "bundler/gem_tasks"
4
+
5
+ require "rspec/core/rake_task"
6
+ require "cucumber/rake/task"
7
+
8
+ RSpec::Core::RakeTask.new(:spec)
9
+ Cucumber::Rake::Task.new(:cucumber)
10
+
11
+ task default: %i[spec cucumber]
@@ -0,0 +1,11 @@
1
+ module Trainspotter
2
+ class ApplicationController < ActionController::Base
3
+ before_action :ensure_trainspotter_connected
4
+
5
+ private
6
+
7
+ def ensure_trainspotter_connected
8
+ Record.ensure_connected
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,46 @@
1
+ module Trainspotter
2
+ class RequestsController < ApplicationController
3
+ def index
4
+ @current_log_file = params[:log_file] || Trainspotter.default_log_file
5
+ @current_ip = params[:ip].presence
6
+ @available_log_files = Trainspotter.available_log_files
7
+
8
+ all_requests = filter_requests(RequestRecord.recent(log_file: @current_log_file))
9
+ @available_ips = RequestRecord.unique_ips(log_file: @current_log_file)
10
+ @requests = filter_by_ip(all_requests).first(50)
11
+ end
12
+
13
+ def poll
14
+ log_file = params[:log_file] || Trainspotter.default_log_file
15
+ ip_filter = params[:ip].presence
16
+ since_id = params[:since_id].presence
17
+
18
+ new_requests = filter_by_ip(
19
+ filter_requests(RequestRecord.poll_for_changes(log_file: log_file, since_id: since_id)),
20
+ ip_filter
21
+ )
22
+
23
+ render json: {
24
+ requests: new_requests.map { |r| render_request_html(r) },
25
+ since_id: new_requests.last&.id
26
+ }
27
+ end
28
+
29
+ private
30
+
31
+ def render_request_html(request)
32
+ render_to_string(partial: "trainspotter/requests/request", locals: { request: request })
33
+ end
34
+
35
+ def filter_requests(requests)
36
+ requests.reject do |request|
37
+ Trainspotter.filter_request?(request.path) || Trainspotter.internal_request?(request)
38
+ end
39
+ end
40
+
41
+ def filter_by_ip(requests, ip = @current_ip)
42
+ return requests if ip.blank?
43
+ requests.select { |request| request.ip == ip }
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,30 @@
1
+ module Trainspotter
2
+ class SessionsController < ApplicationController
3
+ def index
4
+ @current_log_file = params[:log_file] || Trainspotter.default_log_file
5
+ @available_log_files = Trainspotter.available_log_files
6
+ @show_anonymous = params[:show_anonymous] == "1"
7
+
8
+ @sessions = SessionRecord.recent(
9
+ log_file: @current_log_file,
10
+ include_anonymous: @show_anonymous,
11
+ limit: 50
12
+ )
13
+ end
14
+
15
+ def requests
16
+ requests = RequestRecord.for_session(params[:id], limit: 200)
17
+ @requests = filter_requests(requests)
18
+
19
+ render json: {
20
+ requests: @requests.map { |r| render_to_string(partial: "trainspotter/requests/request", locals: { request: r }) }
21
+ }
22
+ end
23
+
24
+ private
25
+
26
+ def filter_requests(requests)
27
+ requests.reject { |r| Trainspotter.filter_request?(r.path) || Trainspotter.internal_request?(r) }
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,7 @@
1
+ import { Application } from "@hotwired/stimulus"
2
+ import RequestsController from "trainspotter/controllers/requests_controller"
3
+ import SessionsController from "trainspotter/controllers/sessions_controller"
4
+
5
+ const application = Application.start()
6
+ application.register("requests", RequestsController)
7
+ application.register("sessions", SessionsController)
@@ -0,0 +1,67 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["list", "status", "logFile", "ipFilter", "autoScroll"]
5
+ static values = {
6
+ pollUrl: String,
7
+ rootUrl: String,
8
+ sinceId: String,
9
+ logFile: String,
10
+ ip: String
11
+ }
12
+
13
+ connect() {
14
+ this.scrollToBottom()
15
+ this.poll()
16
+ }
17
+
18
+ poll() {
19
+ const params = new URLSearchParams()
20
+ params.set("log_file", this.logFileValue)
21
+ if (this.sinceIdValue) params.set("since_id", this.sinceIdValue)
22
+ if (this.ipValue) params.set("ip", this.ipValue)
23
+
24
+ fetch(`${this.pollUrlValue}?${params}`)
25
+ .then(response => response.json())
26
+ .then(data => {
27
+ if (data.requests?.length > 0) {
28
+ data.requests.forEach(html => {
29
+ this.listTarget.insertAdjacentHTML("beforeend", html)
30
+ })
31
+ this.scrollToBottom()
32
+ this.element.querySelector(".empty-state")?.remove()
33
+ }
34
+ if (data.since_id) this.sinceIdValue = data.since_id
35
+ this.statusTarget.classList.remove("disconnected")
36
+ this.statusTarget.classList.add("connected")
37
+ })
38
+ .catch(() => {
39
+ this.statusTarget.classList.remove("connected")
40
+ this.statusTarget.classList.add("disconnected")
41
+ })
42
+ .finally(() => setTimeout(() => this.poll(), 1000))
43
+ }
44
+
45
+ scrollToBottom() {
46
+ if (this.autoScrollTarget.checked) {
47
+ this.listTarget.scrollTop = this.listTarget.scrollHeight
48
+ }
49
+ }
50
+
51
+ changeLogFile() {
52
+ this.logFileValue = this.logFileTarget.value
53
+ window.location.href = this.buildUrl()
54
+ }
55
+
56
+ changeIp() {
57
+ this.ipValue = this.ipFilterTarget.value
58
+ window.location.href = this.buildUrl()
59
+ }
60
+
61
+ buildUrl() {
62
+ const params = new URLSearchParams()
63
+ params.set("log_file", this.logFileValue)
64
+ if (this.ipValue) params.set("ip", this.ipValue)
65
+ return `${this.rootUrlValue}?${params}`
66
+ }
67
+ }
@@ -0,0 +1,43 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["logFile", "showAnonymous"]
5
+ static values = { sessionsUrl: String }
6
+
7
+ changeLogFile() {
8
+ window.location.href = this.buildUrl()
9
+ }
10
+
11
+ changeShowAnonymous() {
12
+ window.location.href = this.buildUrl()
13
+ }
14
+
15
+ buildUrl() {
16
+ const params = new URLSearchParams()
17
+ if (this.hasLogFileTarget) params.set("log_file", this.logFileTarget.value)
18
+ if (this.hasShowAnonymousTarget && this.showAnonymousTarget.checked) {
19
+ params.set("show_anonymous", "1")
20
+ }
21
+ return `${this.sessionsUrlValue}?${params}`
22
+ }
23
+
24
+ loadSession(event) {
25
+ const details = event.currentTarget
26
+ if (!details.open || details.dataset.loaded) return
27
+
28
+ details.dataset.loaded = "true"
29
+ const sessionId = details.dataset.sessionId
30
+ const content = details.querySelector(".session-requests")
31
+
32
+ fetch(`${this.sessionsUrlValue}/${sessionId}/requests`)
33
+ .then(response => response.json())
34
+ .then(data => {
35
+ content.innerHTML = data.requests?.length > 0
36
+ ? data.requests.join("")
37
+ : '<p class="empty-hint">No requests in this session.</p>'
38
+ })
39
+ .catch(() => {
40
+ content.innerHTML = '<p class="error-hint">Failed to load requests.</p>'
41
+ })
42
+ }
43
+ }