railscope 0.1.3 → 0.1.5
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 +4 -4
- data/README.md +62 -2
- data/app/models/railscope/application_record.rb +25 -4
- data/app/views/layouts/railscope/application.html.erb +3 -2
- data/client/src/components/Sidebar.tsx +1 -91
- data/lib/railscope/engine.rb +26 -2
- data/lib/railscope/storage/redis_buffer.rb +3 -0
- data/lib/railscope/version.rb +1 -1
- data/public/railscope/assets/app.js +11 -11
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: dc29cf9c10caee093f9522baf082ee343928f114a9eea9490caf1b5107b410e1
|
|
4
|
+
data.tar.gz: e705db77c3b666499073046ed0cccfcdbacc8871024bb6d8393d6a875b55a93b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0d49c922528f889a0c751ff9865da540a92250097b29108f7eb43be36598fd12addad625a6651100cac745fe553d76d4030c90af718611c0d94672fabc14ea87
|
|
7
|
+
data.tar.gz: 78be37fbf93b9e6128be9135bc47995fbd65f6dc60cae1067c42b13ad2ee375e6365d92cb63cc698352d63d797e910869e19f060f352a77dbf42a80b88412b8f
|
data/README.md
CHANGED
|
@@ -13,7 +13,8 @@ Railscope provides insight into the requests, exceptions, database queries, jobs
|
|
|
13
13
|
- **Context Correlation** - Link all events from the same request via `request_id`
|
|
14
14
|
- **Sensitive Data Filtering** - Automatic masking of passwords, tokens, and secrets
|
|
15
15
|
- **Dark Mode UI** - Beautiful GitHub-inspired dark interface
|
|
16
|
-
- **
|
|
16
|
+
- **Storage Backends** - Direct database writes or Redis buffer with batch flush
|
|
17
|
+
- **Zero Dependencies** - Works with any Rails 7+ application (Redis optional)
|
|
17
18
|
|
|
18
19
|
## Installation
|
|
19
20
|
|
|
@@ -55,6 +56,9 @@ Create `config/initializers/railscope.rb`:
|
|
|
55
56
|
|
|
56
57
|
```ruby
|
|
57
58
|
Railscope.configure do |config|
|
|
59
|
+
# Storage backend: :database (default) or :redis (buffer)
|
|
60
|
+
config.storage_backend = :database
|
|
61
|
+
|
|
58
62
|
# Data retention (default: 7 days)
|
|
59
63
|
config.retention_days = 30
|
|
60
64
|
|
|
@@ -66,11 +70,49 @@ Railscope.configure do |config|
|
|
|
66
70
|
end
|
|
67
71
|
```
|
|
68
72
|
|
|
73
|
+
### Storage Backends
|
|
74
|
+
|
|
75
|
+
Railscope supports two storage modes:
|
|
76
|
+
|
|
77
|
+
| Mode | Write | Read | Requires |
|
|
78
|
+
|------|-------|------|----------|
|
|
79
|
+
| `:database` | Direct INSERT (sync) | PostgreSQL | PostgreSQL |
|
|
80
|
+
| `:redis` | Redis buffer (async) | PostgreSQL | PostgreSQL + Redis |
|
|
81
|
+
|
|
82
|
+
**`:database`** (default) -- Entries are written directly to PostgreSQL during the request. Simplest setup, no Redis needed.
|
|
83
|
+
|
|
84
|
+
**`:redis`** -- Entries are buffered in Redis (~0.1ms per write) and batch-flushed to PostgreSQL periodically. Reduces request latency in high-throughput applications.
|
|
85
|
+
|
|
86
|
+
When using `:redis`, you need to flush the buffer periodically:
|
|
87
|
+
|
|
88
|
+
```ruby
|
|
89
|
+
# From a background job (Sidekiq, GoodJob, SolidQueue, etc.)
|
|
90
|
+
class RailscopeFlushJob < ApplicationJob
|
|
91
|
+
queue_as :low
|
|
92
|
+
|
|
93
|
+
def perform
|
|
94
|
+
Railscope::FlushService.call
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# From a cron/scheduler
|
|
99
|
+
every 5.seconds do
|
|
100
|
+
runner "Railscope::FlushService.call"
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Or via rake
|
|
104
|
+
# $ rake railscope:flush
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
> **Note:** Entries only appear in the dashboard after being flushed to PostgreSQL.
|
|
108
|
+
|
|
69
109
|
### Environment Variables
|
|
70
110
|
|
|
71
111
|
| Variable | Description | Default |
|
|
72
112
|
|----------|-------------|---------|
|
|
73
113
|
| `RAILSCOPE_ENABLED` | Enable/disable recording | `false` |
|
|
114
|
+
| `RAILSCOPE_STORAGE` | Storage backend (`database` or `redis`) | `database` |
|
|
115
|
+
| `RAILSCOPE_REDIS_URL` | Redis connection URL | Falls back to `REDIS_URL` |
|
|
74
116
|
| `RAILSCOPE_RETENTION_DAYS` | Days to keep entries | `7` |
|
|
75
117
|
|
|
76
118
|
## Authorization
|
|
@@ -144,6 +186,16 @@ Entries are automatically tagged:
|
|
|
144
186
|
- **Exceptions**: `exception`, exception class name
|
|
145
187
|
- **Jobs**: `job`, `enqueue`/`perform`, queue name, `failed`
|
|
146
188
|
|
|
189
|
+
### Rake Tasks
|
|
190
|
+
|
|
191
|
+
```bash
|
|
192
|
+
# Flush buffered entries from Redis to database (redis mode only)
|
|
193
|
+
rake railscope:flush
|
|
194
|
+
|
|
195
|
+
# Purge expired entries (older than retention_days)
|
|
196
|
+
rake railscope:purge
|
|
197
|
+
```
|
|
198
|
+
|
|
147
199
|
### Purging Old Entries
|
|
148
200
|
|
|
149
201
|
Run the purge job to remove entries older than `retention_days`:
|
|
@@ -212,12 +264,20 @@ Railscope::Entry.expired
|
|
|
212
264
|
|
|
213
265
|
Railscope is designed to have minimal impact:
|
|
214
266
|
|
|
215
|
-
- Events are recorded synchronously but quickly
|
|
216
267
|
- Ignored paths skip all processing
|
|
217
268
|
- Sensitive data filtering is done once before save
|
|
218
269
|
- Purge job removes old entries to control database size
|
|
219
270
|
|
|
271
|
+
**With `:database` backend:**
|
|
272
|
+
- Entries are written synchronously via INSERT during the request
|
|
273
|
+
|
|
274
|
+
**With `:redis` backend:**
|
|
275
|
+
- Writes go to Redis (~0.1ms per entry), near-zero impact on request latency
|
|
276
|
+
- `Entry.insert_all` batches up to 1000 records per flush for efficient persistence
|
|
277
|
+
- Flush is safe to run concurrently (Redis `LPOP` is atomic)
|
|
278
|
+
|
|
220
279
|
For high-traffic production environments, consider:
|
|
280
|
+
- Using `:redis` backend for lower request latency
|
|
221
281
|
- Shorter retention periods
|
|
222
282
|
- Adding high-traffic paths to ignore list
|
|
223
283
|
- Running purge job more frequently
|
|
@@ -4,9 +4,30 @@ module Railscope
|
|
|
4
4
|
class ApplicationRecord < ActiveRecord::Base
|
|
5
5
|
self.abstract_class = true
|
|
6
6
|
|
|
7
|
-
# Support for separate database connection
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
|
|
7
|
+
# Support for separate database connection.
|
|
8
|
+
# Activated when a "railscope" database is defined in database.yml
|
|
9
|
+
# or when RAILSCOPE_DATABASE_URL is set.
|
|
10
|
+
#
|
|
11
|
+
# Example database.yml:
|
|
12
|
+
# development:
|
|
13
|
+
# primary:
|
|
14
|
+
# <<: *default
|
|
15
|
+
# database: myapp_development
|
|
16
|
+
# migrations_paths: db/migrate
|
|
17
|
+
# railscope:
|
|
18
|
+
# <<: *default
|
|
19
|
+
# database: myapp_railscope_development
|
|
20
|
+
# migrations_paths: db/railscope_migrate
|
|
21
|
+
#
|
|
22
|
+
def self.railscope_separate_database?
|
|
23
|
+
return true if ENV["RAILSCOPE_DATABASE_URL"].present?
|
|
24
|
+
|
|
25
|
+
configs = ActiveRecord::Base.configurations.configs_for(env_name: Rails.env)
|
|
26
|
+
configs.any? { |c| c.name == "railscope" }
|
|
27
|
+
rescue StandardError
|
|
28
|
+
false
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
connects_to database: { writing: :railscope, reading: :railscope } if railscope_separate_database?
|
|
11
32
|
end
|
|
12
33
|
end
|
|
@@ -5,10 +5,11 @@
|
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
6
|
<title>Railscope</title>
|
|
7
7
|
<%= csrf_meta_tags %>
|
|
8
|
-
<
|
|
8
|
+
<meta name="railscope-version" content="<%= Railscope::VERSION %>">
|
|
9
|
+
<link rel="stylesheet" href="<%= railscope.root_path %>assets/app.css?v=<%= Railscope::VERSION %>">
|
|
9
10
|
</head>
|
|
10
11
|
<body class="bg-[#0d1117] text-[#c9d1d9]">
|
|
11
12
|
<%= yield %>
|
|
12
|
-
<script type="module" src="<%= railscope.root_path %>assets/app.js"></script>
|
|
13
|
+
<script type="module" src="<%= railscope.root_path %>assets/app.js?v=<%= Railscope::VERSION %>"></script>
|
|
13
14
|
</body>
|
|
14
15
|
</html>
|
|
@@ -4,21 +4,11 @@ import { cn } from '@/lib/utils'
|
|
|
4
4
|
const navigation = [
|
|
5
5
|
{ name: 'Requests', href: '/requests', icon: RequestIcon },
|
|
6
6
|
{ name: 'Commands', href: '/commands', icon: CommandIcon },
|
|
7
|
-
{ name: 'Schedule', href: '/schedule', icon: ScheduleIcon },
|
|
8
7
|
{ name: 'Jobs', href: '/jobs', icon: JobIcon },
|
|
9
8
|
{ name: 'Exceptions', href: '/exceptions', icon: ExceptionIcon },
|
|
10
|
-
{ name: 'Logs', href: '/logs', icon: LogIcon },
|
|
11
|
-
{ name: 'Dumps', href: '/dumps', icon: DumpIcon },
|
|
12
9
|
{ name: 'Queries', href: '/queries', icon: QueryIcon },
|
|
13
10
|
{ name: 'Models', href: '/models', icon: ModelIcon },
|
|
14
|
-
{ name: 'Events', href: '/events', icon: EventIcon },
|
|
15
|
-
{ name: 'Mail', href: '/mail', icon: MailIcon },
|
|
16
|
-
{ name: 'Notifications', href: '/notifications', icon: NotificationIcon },
|
|
17
|
-
{ name: 'Gates', href: '/gates', icon: GateIcon },
|
|
18
|
-
{ name: 'Cache', href: '/cache', icon: CacheIcon },
|
|
19
|
-
{ name: 'Redis', href: '/redis', icon: RedisIcon },
|
|
20
11
|
{ name: 'Views', href: '/views', icon: ViewIcon },
|
|
21
|
-
{ name: 'HTTP Client', href: '/client-requests', icon: ClientRequestIcon },
|
|
22
12
|
]
|
|
23
13
|
|
|
24
14
|
export default function Sidebar() {
|
|
@@ -54,7 +44,7 @@ export default function Sidebar() {
|
|
|
54
44
|
</nav>
|
|
55
45
|
|
|
56
46
|
<div className="p-4 border-t border-dark-border text-xs text-dark-muted">
|
|
57
|
-
Railscope
|
|
47
|
+
Railscope v{document.querySelector('meta[name="railscope-version"]')?.getAttribute('content') || '0.0.0'}
|
|
58
48
|
</div>
|
|
59
49
|
</aside>
|
|
60
50
|
)
|
|
@@ -76,14 +66,6 @@ function CommandIcon({ className }: { className?: string }) {
|
|
|
76
66
|
)
|
|
77
67
|
}
|
|
78
68
|
|
|
79
|
-
function ScheduleIcon({ className }: { className?: string }) {
|
|
80
|
-
return (
|
|
81
|
-
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
82
|
-
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
83
|
-
</svg>
|
|
84
|
-
)
|
|
85
|
-
}
|
|
86
|
-
|
|
87
69
|
function JobIcon({ className }: { className?: string }) {
|
|
88
70
|
return (
|
|
89
71
|
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
@@ -100,22 +82,6 @@ function ExceptionIcon({ className }: { className?: string }) {
|
|
|
100
82
|
)
|
|
101
83
|
}
|
|
102
84
|
|
|
103
|
-
function LogIcon({ className }: { className?: string }) {
|
|
104
|
-
return (
|
|
105
|
-
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
106
|
-
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
|
107
|
-
</svg>
|
|
108
|
-
)
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
function DumpIcon({ className }: { className?: string }) {
|
|
112
|
-
return (
|
|
113
|
-
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
114
|
-
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z" />
|
|
115
|
-
</svg>
|
|
116
|
-
)
|
|
117
|
-
}
|
|
118
|
-
|
|
119
85
|
function QueryIcon({ className }: { className?: string }) {
|
|
120
86
|
return (
|
|
121
87
|
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
@@ -132,54 +98,6 @@ function ModelIcon({ className }: { className?: string }) {
|
|
|
132
98
|
)
|
|
133
99
|
}
|
|
134
100
|
|
|
135
|
-
function EventIcon({ className }: { className?: string }) {
|
|
136
|
-
return (
|
|
137
|
-
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
138
|
-
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
|
139
|
-
</svg>
|
|
140
|
-
)
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
function MailIcon({ className }: { className?: string }) {
|
|
144
|
-
return (
|
|
145
|
-
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
146
|
-
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
|
147
|
-
</svg>
|
|
148
|
-
)
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
function NotificationIcon({ className }: { className?: string }) {
|
|
152
|
-
return (
|
|
153
|
-
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
154
|
-
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
|
|
155
|
-
</svg>
|
|
156
|
-
)
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
function GateIcon({ className }: { className?: string }) {
|
|
160
|
-
return (
|
|
161
|
-
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
162
|
-
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
|
163
|
-
</svg>
|
|
164
|
-
)
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
function CacheIcon({ className }: { className?: string }) {
|
|
168
|
-
return (
|
|
169
|
-
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
170
|
-
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4" />
|
|
171
|
-
</svg>
|
|
172
|
-
)
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
function RedisIcon({ className }: { className?: string }) {
|
|
176
|
-
return (
|
|
177
|
-
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
178
|
-
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4" />
|
|
179
|
-
</svg>
|
|
180
|
-
)
|
|
181
|
-
}
|
|
182
|
-
|
|
183
101
|
function ViewIcon({ className }: { className?: string }) {
|
|
184
102
|
return (
|
|
185
103
|
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
@@ -188,11 +106,3 @@ function ViewIcon({ className }: { className?: string }) {
|
|
|
188
106
|
</svg>
|
|
189
107
|
)
|
|
190
108
|
}
|
|
191
|
-
|
|
192
|
-
function ClientRequestIcon({ className }: { className?: string }) {
|
|
193
|
-
return (
|
|
194
|
-
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
195
|
-
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
|
|
196
|
-
</svg>
|
|
197
|
-
)
|
|
198
|
-
}
|
data/lib/railscope/engine.rb
CHANGED
|
@@ -29,8 +29,23 @@ module Railscope
|
|
|
29
29
|
|
|
30
30
|
initializer "railscope.migrations" do |app|
|
|
31
31
|
unless app.root.to_s.match?(root.to_s)
|
|
32
|
-
|
|
33
|
-
|
|
32
|
+
if railscope_separate_database?
|
|
33
|
+
# Separate database: copy migrations to db/railscope_migrate/
|
|
34
|
+
# so they only run against the railscope database
|
|
35
|
+
target_dir = app.root.join("db", "railscope_migrate")
|
|
36
|
+
config.paths["db/migrate"].expanded.each do |source_dir|
|
|
37
|
+
Dir[File.join(source_dir, "*.rb")].each do |migration|
|
|
38
|
+
target = target_dir.join(File.basename(migration))
|
|
39
|
+
next if target.exist?
|
|
40
|
+
|
|
41
|
+
FileUtils.mkdir_p(target_dir)
|
|
42
|
+
FileUtils.cp(migration, target)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
else
|
|
46
|
+
config.paths["db/migrate"].expanded.each do |expanded_path|
|
|
47
|
+
app.config.paths["db/migrate"] << expanded_path
|
|
48
|
+
end
|
|
34
49
|
end
|
|
35
50
|
end
|
|
36
51
|
end
|
|
@@ -84,5 +99,14 @@ module Railscope
|
|
|
84
99
|
# Subscribe to rake tasks after they're loaded
|
|
85
100
|
Railscope::Subscribers::CommandSubscriber.subscribe
|
|
86
101
|
end
|
|
102
|
+
|
|
103
|
+
private
|
|
104
|
+
|
|
105
|
+
def railscope_separate_database?
|
|
106
|
+
configs = ActiveRecord::Base.configurations.configs_for(env_name: Rails.env)
|
|
107
|
+
configs.any? { |c| c.name == "railscope" }
|
|
108
|
+
rescue StandardError
|
|
109
|
+
false
|
|
110
|
+
end
|
|
87
111
|
end
|
|
88
112
|
end
|
|
@@ -5,11 +5,13 @@ module Railscope
|
|
|
5
5
|
class RedisBuffer < Base
|
|
6
6
|
BUFFER_KEY = "railscope:buffer"
|
|
7
7
|
UPDATES_KEY = "railscope:buffer:updates"
|
|
8
|
+
BUFFER_TTL = 4.hours.to_i
|
|
8
9
|
|
|
9
10
|
# WRITE → Redis (fast, ~0.1ms)
|
|
10
11
|
def write(attributes)
|
|
11
12
|
entry = build_entry(attributes)
|
|
12
13
|
redis.rpush(BUFFER_KEY, entry.to_json)
|
|
14
|
+
redis.expire(BUFFER_KEY, BUFFER_TTL)
|
|
13
15
|
entry
|
|
14
16
|
end
|
|
15
17
|
|
|
@@ -21,6 +23,7 @@ module Railscope
|
|
|
21
23
|
payload_updates: payload_updates
|
|
22
24
|
}
|
|
23
25
|
redis.rpush(UPDATES_KEY, update.to_json)
|
|
26
|
+
redis.expire(UPDATES_KEY, BUFFER_TTL)
|
|
24
27
|
end
|
|
25
28
|
|
|
26
29
|
# READ → Database (source of truth)
|
data/lib/railscope/version.rb
CHANGED