railscope 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 (95) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +5 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +227 -0
  5. data/Rakefile +12 -0
  6. data/app/assets/stylesheets/railscope/application.css +504 -0
  7. data/app/controllers/railscope/api/entries_controller.rb +103 -0
  8. data/app/controllers/railscope/application_controller.rb +12 -0
  9. data/app/controllers/railscope/dashboard_controller.rb +33 -0
  10. data/app/controllers/railscope/entries_controller.rb +29 -0
  11. data/app/helpers/railscope/dashboard_helper.rb +157 -0
  12. data/app/jobs/railscope/application_job.rb +6 -0
  13. data/app/jobs/railscope/purge_job.rb +15 -0
  14. data/app/models/railscope/application_record.rb +12 -0
  15. data/app/models/railscope/entry.rb +51 -0
  16. data/app/views/layouts/railscope/application.html.erb +14 -0
  17. data/app/views/railscope/application/index.html.erb +1 -0
  18. data/app/views/railscope/dashboard/index.html.erb +70 -0
  19. data/app/views/railscope/entries/show.html.erb +93 -0
  20. data/client/.gitignore +1 -0
  21. data/client/index.html +12 -0
  22. data/client/package-lock.json +2735 -0
  23. data/client/package.json +28 -0
  24. data/client/postcss.config.js +6 -0
  25. data/client/src/App.tsx +60 -0
  26. data/client/src/api/client.ts +25 -0
  27. data/client/src/api/entries.ts +36 -0
  28. data/client/src/components/Layout.tsx +17 -0
  29. data/client/src/components/PlaceholderPage.tsx +32 -0
  30. data/client/src/components/Sidebar.tsx +198 -0
  31. data/client/src/components/ui/Badge.tsx +67 -0
  32. data/client/src/components/ui/Card.tsx +38 -0
  33. data/client/src/components/ui/JsonViewer.tsx +80 -0
  34. data/client/src/components/ui/Pagination.tsx +45 -0
  35. data/client/src/components/ui/SearchInput.tsx +70 -0
  36. data/client/src/components/ui/Table.tsx +68 -0
  37. data/client/src/index.css +28 -0
  38. data/client/src/lib/hooks.ts +37 -0
  39. data/client/src/lib/types.ts +61 -0
  40. data/client/src/lib/utils.ts +38 -0
  41. data/client/src/main.tsx +13 -0
  42. data/client/src/screens/cache/Index.tsx +15 -0
  43. data/client/src/screens/client-requests/Index.tsx +15 -0
  44. data/client/src/screens/commands/Index.tsx +133 -0
  45. data/client/src/screens/commands/Show.tsx +395 -0
  46. data/client/src/screens/dumps/Index.tsx +15 -0
  47. data/client/src/screens/events/Index.tsx +15 -0
  48. data/client/src/screens/exceptions/Index.tsx +155 -0
  49. data/client/src/screens/exceptions/Show.tsx +480 -0
  50. data/client/src/screens/gates/Index.tsx +15 -0
  51. data/client/src/screens/jobs/Index.tsx +153 -0
  52. data/client/src/screens/jobs/Show.tsx +529 -0
  53. data/client/src/screens/logs/Index.tsx +15 -0
  54. data/client/src/screens/mail/Index.tsx +15 -0
  55. data/client/src/screens/models/Index.tsx +15 -0
  56. data/client/src/screens/notifications/Index.tsx +15 -0
  57. data/client/src/screens/queries/Index.tsx +159 -0
  58. data/client/src/screens/queries/Show.tsx +346 -0
  59. data/client/src/screens/redis/Index.tsx +15 -0
  60. data/client/src/screens/requests/Index.tsx +123 -0
  61. data/client/src/screens/requests/Show.tsx +395 -0
  62. data/client/src/screens/schedule/Index.tsx +15 -0
  63. data/client/src/screens/views/Index.tsx +141 -0
  64. data/client/src/screens/views/Show.tsx +337 -0
  65. data/client/tailwind.config.js +22 -0
  66. data/client/tsconfig.json +25 -0
  67. data/client/tsconfig.node.json +10 -0
  68. data/client/vite.config.ts +37 -0
  69. data/config/routes.rb +17 -0
  70. data/db/migrate/20260131023242_create_railscope_entries.rb +41 -0
  71. data/lib/generators/railscope/install_generator.rb +33 -0
  72. data/lib/generators/railscope/templates/initializer.rb +34 -0
  73. data/lib/railscope/context.rb +91 -0
  74. data/lib/railscope/engine.rb +85 -0
  75. data/lib/railscope/entry_data.rb +112 -0
  76. data/lib/railscope/filter.rb +113 -0
  77. data/lib/railscope/middleware.rb +162 -0
  78. data/lib/railscope/storage/base.rb +90 -0
  79. data/lib/railscope/storage/database.rb +83 -0
  80. data/lib/railscope/storage/redis_storage.rb +314 -0
  81. data/lib/railscope/subscribers/base_subscriber.rb +52 -0
  82. data/lib/railscope/subscribers/command_subscriber.rb +237 -0
  83. data/lib/railscope/subscribers/exception_subscriber.rb +113 -0
  84. data/lib/railscope/subscribers/job_subscriber.rb +249 -0
  85. data/lib/railscope/subscribers/query_subscriber.rb +130 -0
  86. data/lib/railscope/subscribers/request_subscriber.rb +121 -0
  87. data/lib/railscope/subscribers/view_subscriber.rb +201 -0
  88. data/lib/railscope/version.rb +5 -0
  89. data/lib/railscope.rb +145 -0
  90. data/lib/tasks/railscope_sample.rake +30 -0
  91. data/public/railscope/assets/app.css +1 -0
  92. data/public/railscope/assets/app.js +70 -0
  93. data/public/railscope/assets/index.html +13 -0
  94. data/sig/railscope.rbs +4 -0
  95. metadata +157 -0
@@ -0,0 +1,337 @@
1
+ import { useState, useEffect } from 'react'
2
+ import { useParams, useNavigate } from 'react-router-dom'
3
+ import { getEntry } from '@/api/entries'
4
+ import { Entry } from '@/lib/types'
5
+ import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card'
6
+ import { JsonViewer } from '@/components/ui/JsonViewer'
7
+
8
+ type TabType = 'data' | 'properties'
9
+
10
+ export default function ViewsShow() {
11
+ const { id } = useParams()
12
+ const navigate = useNavigate()
13
+ const [entry, setEntry] = useState<Entry | null>(null)
14
+ const [batch, setBatch] = useState<Entry[]>([])
15
+ const [loading, setLoading] = useState(true)
16
+ const [activeTab, setActiveTab] = useState<TabType>('data')
17
+
18
+ useEffect(() => {
19
+ loadEntry()
20
+ }, [id])
21
+
22
+ async function loadEntry() {
23
+ if (!id) return
24
+ setLoading(true)
25
+ try {
26
+ const response = await getEntry(id)
27
+ setEntry(response.data)
28
+ setBatch(response.batch || [])
29
+ } catch (error) {
30
+ console.error('Failed to load entry:', error)
31
+ } finally {
32
+ setLoading(false)
33
+ }
34
+ }
35
+
36
+ if (loading) {
37
+ return (
38
+ <div className="p-6">
39
+ <Card>
40
+ <CardContent className="py-12 text-center text-dark-muted">
41
+ Loading...
42
+ </CardContent>
43
+ </Card>
44
+ </div>
45
+ )
46
+ }
47
+
48
+ if (!entry) {
49
+ return (
50
+ <div className="p-6">
51
+ <Card>
52
+ <CardContent className="py-12 text-center text-dark-muted">
53
+ View not found.
54
+ </CardContent>
55
+ </Card>
56
+ </div>
57
+ )
58
+ }
59
+
60
+ const payload = entry.payload as Record<string, unknown>
61
+
62
+ const formattedTime = new Date(entry.occurred_at).toLocaleString('en-US', {
63
+ year: 'numeric',
64
+ month: 'long',
65
+ day: 'numeric',
66
+ hour: 'numeric',
67
+ minute: '2-digit',
68
+ second: '2-digit',
69
+ hour12: true
70
+ })
71
+ const timeAgoStr = getTimeAgo(entry.occurred_at)
72
+
73
+ const viewTypeColors: Record<string, string> = {
74
+ template: 'bg-blue-500/20 text-blue-400',
75
+ partial: 'bg-purple-500/20 text-purple-400',
76
+ layout: 'bg-green-500/20 text-green-400'
77
+ }
78
+
79
+ const getTabContent = () => {
80
+ if (activeTab === 'data') {
81
+ return payload.data || {}
82
+ }
83
+ return {
84
+ name: payload.name,
85
+ path: payload.path,
86
+ full_path: payload.full_path,
87
+ view_type: payload.view_type,
88
+ layout: payload.layout,
89
+ duration: payload.duration
90
+ }
91
+ }
92
+
93
+ return (
94
+ <div className="p-6 space-y-5">
95
+ {/* View Details Card */}
96
+ <Card>
97
+ <CardHeader>
98
+ <CardTitle>View Details</CardTitle>
99
+ </CardHeader>
100
+ <CardContent className="p-0">
101
+ <table className="w-full">
102
+ <tbody>
103
+ <tr className="border-t border-dark-border">
104
+ <td className="px-4 py-3 text-dark-muted whitespace-nowrap w-32">Time</td>
105
+ <td className="px-4 py-3">{formattedTime} ({timeAgoStr})</td>
106
+ </tr>
107
+ <tr className="border-t border-dark-border">
108
+ <td className="px-4 py-3 text-dark-muted whitespace-nowrap">Type</td>
109
+ <td className="px-4 py-3">
110
+ <span className={`px-2 py-0.5 rounded text-xs font-medium ${viewTypeColors[String(payload.view_type)] || 'bg-gray-500/20 text-gray-400'}`}>
111
+ {String(payload.view_type)}
112
+ </span>
113
+ </td>
114
+ </tr>
115
+ <tr className="border-t border-dark-border">
116
+ <td className="px-4 py-3 text-dark-muted whitespace-nowrap">Name</td>
117
+ <td className="px-4 py-3 font-mono text-sm text-white">
118
+ {String(payload.name)}
119
+ </td>
120
+ </tr>
121
+ <tr className="border-t border-dark-border">
122
+ <td className="px-4 py-3 text-dark-muted whitespace-nowrap">Path</td>
123
+ <td className="px-4 py-3 font-mono text-sm">{String(payload.path)}</td>
124
+ </tr>
125
+ <tr className="border-t border-dark-border">
126
+ <td className="px-4 py-3 text-dark-muted whitespace-nowrap">Full Path</td>
127
+ <td className="px-4 py-3 font-mono text-xs text-dark-muted break-all">{String(payload.full_path)}</td>
128
+ </tr>
129
+ {Boolean(payload.layout) && (
130
+ <tr className="border-t border-dark-border">
131
+ <td className="px-4 py-3 text-dark-muted whitespace-nowrap">Layout</td>
132
+ <td className="px-4 py-3 font-mono text-sm">{String(payload.layout)}</td>
133
+ </tr>
134
+ )}
135
+ <tr className="border-t border-dark-border">
136
+ <td className="px-4 py-3 text-dark-muted whitespace-nowrap">Duration</td>
137
+ <td className="px-4 py-3">{String(payload.duration ?? '-')}ms</td>
138
+ </tr>
139
+ </tbody>
140
+ </table>
141
+ </CardContent>
142
+ </Card>
143
+
144
+ {/* Data Card with Tabs */}
145
+ <Card>
146
+ <div className="flex border-b border-dark-border">
147
+ <button
148
+ onClick={() => setActiveTab('data')}
149
+ className={`px-4 py-2.5 text-sm font-medium ${
150
+ activeTab === 'data'
151
+ ? 'bg-blue-500 text-white'
152
+ : 'text-dark-muted hover:text-dark-text'
153
+ }`}
154
+ >
155
+ View Data
156
+ </button>
157
+ <button
158
+ onClick={() => setActiveTab('properties')}
159
+ className={`px-4 py-2.5 text-sm font-medium ${
160
+ activeTab === 'properties'
161
+ ? 'bg-blue-500 text-white'
162
+ : 'text-dark-muted hover:text-dark-text'
163
+ }`}
164
+ >
165
+ Properties
166
+ </button>
167
+ </div>
168
+ <div className="bg-[#1a1a2e]">
169
+ <JsonViewer data={getTabContent()} className="border-0 bg-transparent" />
170
+ </div>
171
+ </Card>
172
+
173
+ {/* Related Entries */}
174
+ {batch.length > 0 && <RelatedEntries entries={batch} currentEntryId={entry.id} navigate={navigate} />}
175
+ </div>
176
+ )
177
+ }
178
+
179
+ function getTimeAgo(date: string): string {
180
+ const seconds = Math.floor((new Date().getTime() - new Date(date).getTime()) / 1000)
181
+ if (seconds < 60) return `${seconds}s ago`
182
+ if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`
183
+ if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`
184
+ return `${Math.floor(seconds / 86400)}d ago`
185
+ }
186
+
187
+ interface RelatedEntriesProps {
188
+ entries: Entry[]
189
+ currentEntryId: number
190
+ navigate: ReturnType<typeof useNavigate>
191
+ }
192
+
193
+ function RelatedEntries({ entries, currentEntryId, navigate }: RelatedEntriesProps) {
194
+ const [currentTab, setCurrentTab] = useState<string>('')
195
+
196
+ // Filter out the current entry from related entries
197
+ const filteredEntries = entries.filter(e => e.id !== currentEntryId)
198
+
199
+ const groupedEntries = filteredEntries.reduce((acc, entry) => {
200
+ const type = entry.entry_type
201
+ if (!acc[type]) acc[type] = []
202
+ acc[type].push(entry)
203
+ return acc
204
+ }, {} as Record<string, Entry[]>)
205
+
206
+ const tabs = Object.entries(groupedEntries).map(([type, items]) => ({
207
+ type,
208
+ label: getTypeLabel(type),
209
+ count: items.length
210
+ }))
211
+
212
+ useEffect(() => {
213
+ if (tabs.length > 0 && !currentTab) {
214
+ setCurrentTab(tabs[0].type)
215
+ }
216
+ }, [tabs.length])
217
+
218
+ if (tabs.length === 0) return null
219
+
220
+ const currentEntries = groupedEntries[currentTab] || []
221
+
222
+ return (
223
+ <Card>
224
+ <CardHeader>
225
+ <CardTitle>Related Entries</CardTitle>
226
+ </CardHeader>
227
+ <div className="flex flex-wrap border-b border-dark-border">
228
+ {tabs.map((tab) => (
229
+ <button
230
+ key={tab.type}
231
+ onClick={() => setCurrentTab(tab.type)}
232
+ className={`px-4 py-2.5 text-sm font-medium ${
233
+ currentTab === tab.type
234
+ ? 'bg-blue-500 text-white'
235
+ : 'text-dark-muted hover:text-dark-text'
236
+ }`}
237
+ >
238
+ {tab.label} ({tab.count})
239
+ </button>
240
+ ))}
241
+ </div>
242
+ <table className="w-full">
243
+ <tbody>
244
+ {currentEntries.map((relatedEntry) => {
245
+ const relPayload = relatedEntry.payload as Record<string, unknown>
246
+ const path = getEntryPath(relatedEntry)
247
+
248
+ return (
249
+ <tr
250
+ key={relatedEntry.id}
251
+ onClick={() => navigate(path)}
252
+ className="border-b border-dark-border hover:bg-white/[0.02] cursor-pointer"
253
+ >
254
+ <td className="px-4 py-3">
255
+ <span className="text-dark-text text-sm">{getEntryDescription(relatedEntry, relPayload)}</span>
256
+ </td>
257
+ <td className="px-4 py-3 text-right text-dark-muted text-xs">
258
+ {getTimeAgo(relatedEntry.occurred_at)}
259
+ </td>
260
+ <td className="px-4 py-3 w-12">
261
+ <ArrowIcon />
262
+ </td>
263
+ </tr>
264
+ )
265
+ })}
266
+ </tbody>
267
+ </table>
268
+ </Card>
269
+ )
270
+ }
271
+
272
+ function ArrowIcon() {
273
+ return (
274
+ <svg className="w-5 h-5 text-dark-muted" viewBox="0 0 20 20" fill="currentColor">
275
+ <path
276
+ fillRule="evenodd"
277
+ d="M10 18a8 8 0 100-16 8 8 0 000 16zM6.75 9.25a.75.75 0 000 1.5h4.59l-2.1 1.95a.75.75 0 001.02 1.1l3.5-3.25a.75.75 0 000-1.1l-3.5-3.25a.75.75 0 10-1.02 1.1l2.1 1.95H6.75z"
278
+ clipRule="evenodd"
279
+ />
280
+ </svg>
281
+ )
282
+ }
283
+
284
+ function getEntryPath(entry: Entry): string {
285
+ switch (entry.entry_type) {
286
+ case 'query': return `/queries/${entry.id}`
287
+ case 'exception': return `/exceptions/${entry.id}`
288
+ case 'job_enqueue':
289
+ case 'job_perform': return `/jobs/${entry.id}`
290
+ case 'request': return `/requests/${entry.id}`
291
+ case 'command': return `/commands/${entry.id}`
292
+ case 'view': return `/views/${entry.id}`
293
+ default: return `/${entry.entry_type}s/${entry.id}`
294
+ }
295
+ }
296
+
297
+ function getEntryDescription(entry: Entry, payload: Record<string, unknown>): string {
298
+ switch (entry.entry_type) {
299
+ case 'query':
300
+ return String(payload.sql || '').substring(0, 100)
301
+ case 'request':
302
+ return `${payload.method} ${payload.path}`
303
+ case 'command':
304
+ return `Command: ${payload.command}`
305
+ case 'exception':
306
+ return `${payload.class}: ${String(payload.message || '').substring(0, 50)}`
307
+ case 'job_enqueue':
308
+ case 'job_perform':
309
+ return `Job: ${payload.job_class}`
310
+ case 'view':
311
+ return `${payload.view_type}: ${payload.name || payload.path}`
312
+ default:
313
+ return entry.entry_type
314
+ }
315
+ }
316
+
317
+ function getTypeLabel(type: string): string {
318
+ const labels: Record<string, string> = {
319
+ query: 'Queries',
320
+ exception: 'Exceptions',
321
+ job_enqueue: 'Enqueued',
322
+ job_perform: 'Performed',
323
+ request: 'Requests',
324
+ command: 'Commands',
325
+ log: 'Logs',
326
+ cache: 'Cache',
327
+ event: 'Events',
328
+ mail: 'Mail',
329
+ notification: 'Notifications',
330
+ model: 'Models',
331
+ gate: 'Gates',
332
+ redis: 'Redis',
333
+ view: 'Views',
334
+ client_request: 'HTTP Client'
335
+ }
336
+ return labels[type] || type
337
+ }
@@ -0,0 +1,22 @@
1
+ /** @type {import('tailwindcss').Config} */
2
+ export default {
3
+ content: [
4
+ "./index.html",
5
+ "./src/**/*.{js,ts,jsx,tsx}",
6
+ ],
7
+ darkMode: 'class',
8
+ theme: {
9
+ extend: {
10
+ colors: {
11
+ dark: {
12
+ bg: '#0d1117',
13
+ surface: '#161b22',
14
+ border: '#30363d',
15
+ text: '#c9d1d9',
16
+ muted: '#8b949e',
17
+ }
18
+ }
19
+ },
20
+ },
21
+ plugins: [],
22
+ }
@@ -0,0 +1,25 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2020",
4
+ "useDefineForClassFields": true,
5
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
6
+ "module": "ESNext",
7
+ "skipLibCheck": true,
8
+ "moduleResolution": "bundler",
9
+ "allowImportingTsExtensions": true,
10
+ "resolveJsonModule": true,
11
+ "isolatedModules": true,
12
+ "noEmit": true,
13
+ "jsx": "react-jsx",
14
+ "strict": true,
15
+ "noUnusedLocals": true,
16
+ "noUnusedParameters": true,
17
+ "noFallthroughCasesInSwitch": true,
18
+ "baseUrl": ".",
19
+ "paths": {
20
+ "@/*": ["./src/*"]
21
+ }
22
+ },
23
+ "include": ["src"],
24
+ "references": [{ "path": "./tsconfig.node.json" }]
25
+ }
@@ -0,0 +1,10 @@
1
+ {
2
+ "compilerOptions": {
3
+ "composite": true,
4
+ "skipLibCheck": true,
5
+ "module": "ESNext",
6
+ "moduleResolution": "bundler",
7
+ "allowSyntheticDefaultImports": true
8
+ },
9
+ "include": ["vite.config.ts"]
10
+ }
@@ -0,0 +1,37 @@
1
+ import { defineConfig } from 'vite'
2
+ import react from '@vitejs/plugin-react'
3
+ import path from 'path'
4
+
5
+ export default defineConfig({
6
+ plugins: [react()],
7
+ base: '/railscope/assets/',
8
+ build: {
9
+ outDir: '../public/railscope/assets',
10
+ emptyOutDir: true,
11
+ rollupOptions: {
12
+ output: {
13
+ entryFileNames: 'app.js',
14
+ chunkFileNames: 'app-[hash].js',
15
+ assetFileNames: (assetInfo) => {
16
+ if (assetInfo.name?.endsWith('.css')) {
17
+ return 'app.css'
18
+ }
19
+ return 'assets/[name]-[hash][extname]'
20
+ }
21
+ }
22
+ }
23
+ },
24
+ resolve: {
25
+ alias: {
26
+ '@': path.resolve(__dirname, './src')
27
+ }
28
+ },
29
+ server: {
30
+ proxy: {
31
+ '/railscope/api': {
32
+ target: 'http://localhost:3000',
33
+ changeOrigin: true
34
+ }
35
+ }
36
+ }
37
+ })
data/config/routes.rb ADDED
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ Railscope::Engine.routes.draw do
4
+ # API endpoints
5
+ namespace :api do
6
+ resources :entries, only: %i[index show destroy] do
7
+ collection do
8
+ get "batch/:batch_id", action: :batch, as: :batch
9
+ get "family/:family_hash", action: :family, as: :family
10
+ end
11
+ end
12
+ end
13
+
14
+ # Serve React SPA for all other routes
15
+ get "*path", to: "application#index", constraints: ->(req) { !req.path.start_with?("/api") }
16
+ root to: "application#index"
17
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateRailscopeEntries < ActiveRecord::Migration[7.0]
4
+ def change
5
+ create_table :railscope_entries do |t|
6
+ # UUID as public identifier
7
+ t.uuid :uuid, default: "gen_random_uuid()", null: false
8
+
9
+ # Batch ID groups all entries from a single request/job
10
+ t.uuid :batch_id
11
+
12
+ # Family hash groups similar entries (e.g., same SQL pattern)
13
+ t.string :family_hash
14
+
15
+ # Entry type (request, query, exception, job_perform, etc.)
16
+ t.string :entry_type, null: false
17
+
18
+ # Payload data as JSONB
19
+ t.jsonb :payload, default: {}
20
+
21
+ # Tags array for categorization
22
+ t.string :tags, array: true, default: []
23
+
24
+ # Control visibility on index pages
25
+ t.boolean :should_display_on_index, default: true, null: false
26
+
27
+ # When the event occurred
28
+ t.datetime :occurred_at, null: false
29
+
30
+ t.timestamps
31
+ end
32
+
33
+ add_index :railscope_entries, :uuid, unique: true
34
+ add_index :railscope_entries, :batch_id
35
+ add_index :railscope_entries, :family_hash
36
+ add_index :railscope_entries, :entry_type
37
+ add_index :railscope_entries, :occurred_at
38
+ add_index :railscope_entries, :tags, using: :gin
39
+ add_index :railscope_entries, %i[entry_type should_display_on_index], name: "idx_railscope_type_displayable"
40
+ end
41
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/base"
5
+
6
+ module Railscope
7
+ module Generators
8
+ class InstallGenerator < Rails::Generators::Base
9
+ source_root File.expand_path("templates", __dir__)
10
+
11
+ desc "Creates a Railscope initializer and mounts the engine."
12
+
13
+ def copy_initializer
14
+ template "initializer.rb", "config/initializers/railscope.rb"
15
+ end
16
+
17
+ def mount_engine
18
+ route 'mount Railscope::Engine, at: "/railscope"'
19
+ end
20
+
21
+ def show_post_install_message
22
+ say ""
23
+ say "Railscope installed successfully!", :green
24
+ say ""
25
+ say "Next steps:"
26
+ say " 1. Run migrations: rails db:migrate"
27
+ say " 2. Enable Railscope: add RAILSCOPE_ENABLED=true to your .env"
28
+ say " 3. Start your server and visit /railscope"
29
+ say ""
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Railscope Configuration
4
+ # =======================
5
+ #
6
+ # Railscope is disabled by default. Enable it by setting
7
+ # the RAILSCOPE_ENABLED environment variable:
8
+ #
9
+ # RAILSCOPE_ENABLED=true
10
+ #
11
+
12
+ Railscope.configure do |config|
13
+ # Retention Period
14
+ # ----------------
15
+ # Number of days to keep entries before purging.
16
+ # Can also be set via RAILSCOPE_RETENTION_DAYS env var.
17
+ #
18
+ # config.retention_days = 7
19
+
20
+ # Ignored Paths
21
+ # -------------
22
+ # Requests to these paths will not be recorded.
23
+ # Default: /railscope, /assets, /packs, /cable
24
+ #
25
+ # config.add_ignore_paths("/health", "/ping", "/metrics")
26
+
27
+ # Sensitive Keys
28
+ # --------------
29
+ # Additional parameter names to filter from payloads.
30
+ # By default, common sensitive keys are filtered (password, token, etc.)
31
+ # plus everything in Rails.application.config.filter_parameters
32
+ #
33
+ # config.add_sensitive_keys(:cpf, :ssn, :bank_account)
34
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railscope
4
+ class Context
5
+ THREAD_KEY = :railscope_context
6
+
7
+ class << self
8
+ def current
9
+ Thread.current[THREAD_KEY] ||= new
10
+ end
11
+
12
+ def clear!
13
+ Thread.current[THREAD_KEY] = nil
14
+ end
15
+
16
+ def with(**attributes)
17
+ previous = current.to_h.dup
18
+ attributes.each { |key, value| current[key] = value }
19
+ yield
20
+ ensure
21
+ clear!
22
+ previous.each { |key, value| current[key] = value }
23
+ end
24
+ end
25
+
26
+ def initialize
27
+ @store = {}
28
+ end
29
+
30
+ def []=(key, value)
31
+ @store[key.to_sym] = value
32
+ end
33
+
34
+ def [](key)
35
+ @store[key.to_sym]
36
+ end
37
+
38
+ def fetch(key, default = nil)
39
+ @store.fetch(key.to_sym, default)
40
+ end
41
+
42
+ def merge!(hash)
43
+ hash.each { |key, value| self[key] = value }
44
+ self
45
+ end
46
+
47
+ def to_h
48
+ @store.dup
49
+ end
50
+
51
+ # Batch ID groups all entries from a single request/job
52
+ def batch_id
53
+ self[:batch_id] ||= SecureRandom.uuid
54
+ end
55
+
56
+ def batch_id=(value)
57
+ self[:batch_id] = value
58
+ end
59
+
60
+ # Request ID from Rails (for correlation with Rails logs)
61
+ def request_id
62
+ self[:request_id]
63
+ end
64
+
65
+ def request_id=(value)
66
+ self[:request_id] = value
67
+ end
68
+
69
+ def tags
70
+ self[:tags] ||= []
71
+ end
72
+
73
+ def add_tag(tag)
74
+ tags << tag unless tags.include?(tag)
75
+ end
76
+
77
+ def add_tags(*new_tags)
78
+ new_tags.flatten.each { |tag| add_tag(tag) }
79
+ end
80
+
81
+ def user_id
82
+ self[:user_id]
83
+ end
84
+
85
+ def user_id=(value)
86
+ self[:user_id] = value
87
+ end
88
+
89
+ delegate :empty?, to: :@store
90
+ end
91
+ end