activeusage 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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +16 -0
- data/LICENSE.txt +21 -0
- data/README.md +69 -0
- data/lib/active_usage/adapters/base.rb +19 -0
- data/lib/active_usage/adapters/http.rb +58 -0
- data/lib/active_usage/configuration.rb +18 -0
- data/lib/active_usage/event.rb +19 -0
- data/lib/active_usage/event_queue.rb +45 -0
- data/lib/active_usage/instrumentation/active_job_hook.rb +32 -0
- data/lib/active_usage/instrumentation/runtime_state.rb +85 -0
- data/lib/active_usage/instrumentation/subscriber.rb +72 -0
- data/lib/active_usage/middleware.rb +16 -0
- data/lib/active_usage/railtie.rb +19 -0
- data/lib/active_usage/recorder.rb +22 -0
- data/lib/active_usage/store.rb +77 -0
- data/lib/active_usage/tags.rb +23 -0
- data/lib/active_usage/tracker.rb +45 -0
- data/lib/active_usage/type/sql_queries.rb +15 -0
- data/lib/active_usage/type/tags.rb +15 -0
- data/lib/active_usage/version.rb +5 -0
- data/lib/active_usage/window_started_at.rb +20 -0
- data/lib/active_usage/worker.rb +41 -0
- data/lib/active_usage.rb +75 -0
- data/lib/activeusage.rb +3 -0
- metadata +97 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 5a3a473fcb948b9b5cde6090665a070a1900e5627bb624b51119b1c19cd8461d
|
|
4
|
+
data.tar.gz: ab79d115a01c4e395fb9547a71813dbbefed37fc5d31301c7e15fd94048faa52
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 7f413eb559b86bdf5091c2b751ea1659f582137703d371b8db45dd21f57a13782de2f8308c2172305d7dc746903accc96896631a15b156af30ae8c3d40677490
|
|
7
|
+
data.tar.gz: 614c765005e2543cf3bf68aee7af650fca2c5b9b1596dcdb5276faebf079a7015faae55e0ec5528a8b44d63943b0cbf1b34406b63a7ba7dcb6493f5864c2ef01
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
## [Unreleased]
|
|
2
|
+
|
|
3
|
+
## [0.1.0] - 2026-04-21
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- Rails auto-instrumentation for requests (`process_action.action_controller`) and jobs (`ActiveJob`)
|
|
8
|
+
- Manual task tracking via `ActiveUsage.track`
|
|
9
|
+
- Thread-local tag management with per-request and per-job flush
|
|
10
|
+
- SQL query fingerprinting with normalization, aggregation by pattern, and top-N ranking (up to 20 per event)
|
|
11
|
+
- HTTP adapter with Bearer token auth and JSON batch delivery
|
|
12
|
+
- Custom adapter interface (`ActiveUsage::Adapters::Base`)
|
|
13
|
+
- Background worker with configurable flush interval and graceful shutdown
|
|
14
|
+
- Thread-safe event queue with batch flushing and dropped-event tracking
|
|
15
|
+
- Configurable logger, window size, global tags, and adapter via `ActiveUsage.configure`
|
|
16
|
+
- Railtie for zero-config Rails integration (middleware + ActiveJob hook)
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Tomasz Kowalewski
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# ActiveUsage
|
|
2
|
+
|
|
3
|
+
Cost observability core for Ruby and Rails workloads. ActiveUsage turns runtime signals — request timing, SQL queries, job execution — into structured events you can ship to any backend for cost analysis.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
Add to your Gemfile:
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
gem "activeusage"
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Configuration
|
|
14
|
+
|
|
15
|
+
```ruby
|
|
16
|
+
ActiveUsage.configure do |config|
|
|
17
|
+
config.adapter = ActiveUsage::Adapters::Http.new("https://your-backend.example.com/events", "your-api-key")
|
|
18
|
+
config.tags = { env: Rails.env }
|
|
19
|
+
config.logger = Rails.logger
|
|
20
|
+
end
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Rails integration
|
|
24
|
+
|
|
25
|
+
With Rails, ActiveUsage auto-instruments requests and jobs via a Railtie — no extra setup needed.
|
|
26
|
+
|
|
27
|
+
**Requests** — every `process_action.action_controller` notification produces a `:request` event with timing, allocations, controller/action tags, and SQL query breakdown.
|
|
28
|
+
|
|
29
|
+
**Jobs** — every `ActiveJob` execution produces a `:job` event with timing, retry count, queue name, and SQL query breakdown.
|
|
30
|
+
|
|
31
|
+
**Middleware** — `ActiveUsage::Middleware` is inserted automatically to flush per-request tags.
|
|
32
|
+
|
|
33
|
+
## Manual tracking
|
|
34
|
+
|
|
35
|
+
Use `ActiveUsage.track` to instrument arbitrary blocks:
|
|
36
|
+
|
|
37
|
+
```ruby
|
|
38
|
+
result = ActiveUsage.track("reports.generate", tags: { user_id: current_user.id }) do
|
|
39
|
+
ReportGenerator.call(params)
|
|
40
|
+
end
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
This produces a `:task` event with timing and SQL queries captured within the block.
|
|
44
|
+
|
|
45
|
+
## Tags
|
|
46
|
+
|
|
47
|
+
Tags are thread-local and merged into every event recorded on the current thread:
|
|
48
|
+
|
|
49
|
+
```ruby
|
|
50
|
+
ActiveUsage.tags.tag(user_id: current_user.id, tenant: current_tenant.slug)
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Tags set on the thread are flushed automatically at the end of each request by the middleware and at the start/end of each job by the hook.
|
|
54
|
+
|
|
55
|
+
## Event payload
|
|
56
|
+
|
|
57
|
+
Every event includes:
|
|
58
|
+
|
|
59
|
+
| Field | Description |
|
|
60
|
+
|---|---|
|
|
61
|
+
| `type` | `:request`, `:job`, or `:task` |
|
|
62
|
+
| `name` | controller action, job class name, or task name |
|
|
63
|
+
| `started_at` | start timestamp |
|
|
64
|
+
| `finished_at` | end timestamp |
|
|
65
|
+
| `allocations` | object allocations (requests only) |
|
|
66
|
+
| `retry_count` | retry count (jobs only) |
|
|
67
|
+
| `tags` | merged thread-local and per-event tags |
|
|
68
|
+
| `window_started_at` | start of the aggregation window bucket |
|
|
69
|
+
| `sql_queries` | top SQL query fingerprints with timing and call counts |
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveUsage
|
|
4
|
+
module Adapters
|
|
5
|
+
class Base
|
|
6
|
+
def record(_events)
|
|
7
|
+
raise NotImplementedError
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def clear!
|
|
11
|
+
raise NotImplementedError
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def shutdown!
|
|
15
|
+
raise NotImplementedError
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveUsage
|
|
4
|
+
module Adapters
|
|
5
|
+
class Http < Base
|
|
6
|
+
class Client
|
|
7
|
+
def initialize(url, api_key, events)
|
|
8
|
+
@uri = URI(url)
|
|
9
|
+
@api_key = api_key
|
|
10
|
+
@events = events
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def call
|
|
14
|
+
Net::HTTP.start(@uri.host, @uri.port, options) do |http|
|
|
15
|
+
http.request(request).is_a?(Net::HTTPSuccess)
|
|
16
|
+
end
|
|
17
|
+
rescue StandardError
|
|
18
|
+
false
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def request
|
|
24
|
+
request = Net::HTTP::Post.new(@uri)
|
|
25
|
+
request["Content-Type"] = "application/json"
|
|
26
|
+
request["Authorization"] = "Bearer #{@api_key}"
|
|
27
|
+
request.body = JSON.generate(
|
|
28
|
+
events: @events.map(&:attributes)
|
|
29
|
+
)
|
|
30
|
+
request
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def options
|
|
34
|
+
{
|
|
35
|
+
use_ssl: @uri.scheme == "https",
|
|
36
|
+
read_timeout: 2.0,
|
|
37
|
+
open_timeout: 2.0
|
|
38
|
+
}
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def initialize(url, api_key)
|
|
43
|
+
@url = url
|
|
44
|
+
@api_key = api_key
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def record(events)
|
|
48
|
+
return true if events.empty?
|
|
49
|
+
|
|
50
|
+
Client.new(@url, @api_key, events).call
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def clear!; end
|
|
54
|
+
|
|
55
|
+
def shutdown!; end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveUsage
|
|
4
|
+
# Holds configuration options for the ActiveUsage gem.
|
|
5
|
+
class Configuration
|
|
6
|
+
attr_accessor :adapter, :tags, :window_size
|
|
7
|
+
attr_writer :logger
|
|
8
|
+
|
|
9
|
+
def initialize
|
|
10
|
+
@tags = {}
|
|
11
|
+
@window_size = 300
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def logger
|
|
15
|
+
@logger || (defined?(Rails.logger) && Rails.logger) || Logger.new($stderr)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveUsage
|
|
4
|
+
# Represents a single tracked usage event with timing, resource.
|
|
5
|
+
class Event
|
|
6
|
+
include ActiveModel::Model
|
|
7
|
+
include ActiveModel::Attributes
|
|
8
|
+
|
|
9
|
+
attribute :type, :string
|
|
10
|
+
attribute :name, :string
|
|
11
|
+
attribute :started_at, :datetime
|
|
12
|
+
attribute :finished_at, :datetime
|
|
13
|
+
attribute :allocations, :integer
|
|
14
|
+
attribute :retry_count, :integer
|
|
15
|
+
attribute :tags, :tags
|
|
16
|
+
attribute :window_started_at, :datetime
|
|
17
|
+
attribute :sql_queries, :sql_queries
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveUsage
|
|
4
|
+
class EventQueue
|
|
5
|
+
def initialize(max_size, batch_size)
|
|
6
|
+
@max_size = max_size
|
|
7
|
+
@batch_size = batch_size
|
|
8
|
+
@queue = Queue.new
|
|
9
|
+
@mutex = Mutex.new
|
|
10
|
+
@dropped_count = 0
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def push(event)
|
|
14
|
+
@mutex.synchronize do
|
|
15
|
+
if @queue.size >= @max_size
|
|
16
|
+
@dropped_count += 1
|
|
17
|
+
return
|
|
18
|
+
end
|
|
19
|
+
@queue << event
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def drain
|
|
24
|
+
items = []
|
|
25
|
+
@mutex.synchronize do
|
|
26
|
+
items << @queue.pop(true) while items.size < @batch_size && !@queue.empty?
|
|
27
|
+
rescue ThreadError
|
|
28
|
+
nil
|
|
29
|
+
end
|
|
30
|
+
items
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def size
|
|
34
|
+
@mutex.synchronize { @queue.size }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def flush_ready?
|
|
38
|
+
size >= @batch_size
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def dropped_count
|
|
42
|
+
@mutex.synchronize { @dropped_count }
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveUsage
|
|
4
|
+
module Instrumentation
|
|
5
|
+
module ActiveJobHooks
|
|
6
|
+
extend ActiveSupport::Concern
|
|
7
|
+
|
|
8
|
+
included do
|
|
9
|
+
around_perform do |job, block|
|
|
10
|
+
ActiveUsage.tags.flush
|
|
11
|
+
ActiveUsage::Instrumentation::RuntimeState.clear_sql_state
|
|
12
|
+
started_at = Time.current
|
|
13
|
+
|
|
14
|
+
block.call
|
|
15
|
+
ensure
|
|
16
|
+
ActiveUsage.record(
|
|
17
|
+
type: :job,
|
|
18
|
+
name: job.class.name,
|
|
19
|
+
started_at: started_at,
|
|
20
|
+
finished_at: Time.current,
|
|
21
|
+
retry_count: job.respond_to?(:executions) ? job.executions.to_i - 1 : 0,
|
|
22
|
+
tags: { queue: job.queue_name },
|
|
23
|
+
sql_queries: ActiveUsage::Instrumentation::RuntimeState.consume_sql_queries
|
|
24
|
+
)
|
|
25
|
+
ActiveUsage.store.flush!
|
|
26
|
+
ActiveUsage::Instrumentation::RuntimeState.clear_sql_state
|
|
27
|
+
ActiveUsage.tags.flush
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveUsage
|
|
4
|
+
module Instrumentation
|
|
5
|
+
module RuntimeState
|
|
6
|
+
SQL_FINGERPRINTS_KEY = :activeusage_sql_fingerprints
|
|
7
|
+
MAX_SQL_QUERIES_PER_EVENT = 20
|
|
8
|
+
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def sql_fingerprints
|
|
12
|
+
ActiveSupport::IsolatedExecutionState[SQL_FINGERPRINTS_KEY] || {}
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def add_sql_event(payload, started_at:, finished_at:)
|
|
16
|
+
duration_ms = ((finished_at - started_at) * 1000.0).round(3)
|
|
17
|
+
ActiveSupport::IsolatedExecutionState[SQL_FINGERPRINTS_KEY] = accumulate_sql_fingerprint(
|
|
18
|
+
sql_fingerprints,
|
|
19
|
+
payload: payload,
|
|
20
|
+
duration_ms: duration_ms
|
|
21
|
+
)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def clear_sql_state
|
|
25
|
+
ActiveSupport::IsolatedExecutionState[SQL_FINGERPRINTS_KEY] = {}
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def consume_sql_queries
|
|
29
|
+
queries = sql_fingerprints
|
|
30
|
+
ActiveSupport::IsolatedExecutionState[SQL_FINGERPRINTS_KEY] = {}
|
|
31
|
+
queries.values
|
|
32
|
+
.sort_by { |query| -query[:total_duration_ms].to_f }
|
|
33
|
+
.first(MAX_SQL_QUERIES_PER_EVENT)
|
|
34
|
+
.map { |query| format_sql_query(query) }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def accumulate_sql_fingerprint(fingerprints, payload:, duration_ms:)
|
|
38
|
+
fingerprint = normalize_sql(payload[:sql].to_s)
|
|
39
|
+
return fingerprints if fingerprint.empty?
|
|
40
|
+
|
|
41
|
+
entry = find_or_build_entry(fingerprints, fingerprint, payload)
|
|
42
|
+
update_entry(entry, duration_ms, payload)
|
|
43
|
+
fingerprints.merge(fingerprint => entry)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def normalize_sql(sql)
|
|
47
|
+
normalized = sql.dup
|
|
48
|
+
normalized.gsub!(/'(?:[^']|'')*'/, "?")
|
|
49
|
+
normalized.gsub!(/"([^"]*)"/, '\1')
|
|
50
|
+
normalized.gsub!(/\$\d+/, "?")
|
|
51
|
+
normalized.gsub!(/\b\d+(?:\.\d+)?\b/, "?")
|
|
52
|
+
normalized.gsub!(/\(\s*\?(?:\s*,\s*\?)+\s*\)/, "(?)")
|
|
53
|
+
normalized.gsub!(/\s+/, " ")
|
|
54
|
+
normalized.strip!
|
|
55
|
+
normalized
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def format_sql_query(query)
|
|
59
|
+
{
|
|
60
|
+
fingerprint: query[:fingerprint],
|
|
61
|
+
total_duration_ms: query[:total_duration_ms].to_f.round(3),
|
|
62
|
+
calls: query[:calls].to_i,
|
|
63
|
+
adapter_name: query[:adapter_name]
|
|
64
|
+
}
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def find_or_build_entry(fingerprints, fingerprint, payload)
|
|
68
|
+
fingerprints[fingerprint] || {
|
|
69
|
+
fingerprint: fingerprint,
|
|
70
|
+
total_duration_ms: 0.0,
|
|
71
|
+
calls: 0,
|
|
72
|
+
adapter_name: payload[:name].to_s
|
|
73
|
+
}
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def update_entry(entry, duration_ms, payload)
|
|
77
|
+
entry[:total_duration_ms] += duration_ms.to_f
|
|
78
|
+
entry[:calls] += 1
|
|
79
|
+
entry[:adapter_name] = payload[:name].to_s if entry[:adapter_name].to_s.empty?
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private_class_method :format_sql_query, :find_or_build_entry, :update_entry
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveUsage
|
|
4
|
+
module Instrumentation
|
|
5
|
+
class Subscriber
|
|
6
|
+
ACTION_CONTROLLER_EVENT = "process_action.action_controller"
|
|
7
|
+
|
|
8
|
+
@subscriptions = []
|
|
9
|
+
|
|
10
|
+
class << self
|
|
11
|
+
def unsubscribe_all
|
|
12
|
+
@subscriptions.each { |s| ActiveSupport::Notifications.unsubscribe(s) }
|
|
13
|
+
@subscriptions = []
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def track(subscriptions)
|
|
17
|
+
@subscriptions = subscriptions
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def call
|
|
22
|
+
self.class.unsubscribe_all
|
|
23
|
+
self.class.track([subscribe_to_sql, subscribe_to_actions])
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def subscribe_to_sql
|
|
29
|
+
ActiveSupport::Notifications.subscribe("sql.active_record") do |_name, started, finished, _id, payload|
|
|
30
|
+
next if payload[:cached]
|
|
31
|
+
|
|
32
|
+
ActiveUsage::Instrumentation::RuntimeState.add_sql_event(
|
|
33
|
+
payload,
|
|
34
|
+
started_at: started,
|
|
35
|
+
finished_at: finished
|
|
36
|
+
)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def subscribe_to_actions
|
|
41
|
+
ActiveSupport::Notifications.subscribe(ACTION_CONTROLLER_EVENT) do |_name, started, finished, _id, payload|
|
|
42
|
+
handle_action(started, finished, payload)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def handle_action(started, finished, payload)
|
|
47
|
+
ActiveUsage.record(**action_event_attributes(started, finished, payload))
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def action_event_attributes(started, finished, payload)
|
|
51
|
+
{
|
|
52
|
+
type: :request,
|
|
53
|
+
name: controller_action_name(payload),
|
|
54
|
+
started_at: started,
|
|
55
|
+
finished_at: finished,
|
|
56
|
+
allocations: payload[:allocations].to_i,
|
|
57
|
+
tags: { controller: payload[:controller], action: payload[:action] }
|
|
58
|
+
}.merge(sql_event_attributes)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def sql_event_attributes
|
|
62
|
+
{
|
|
63
|
+
sql_queries: ActiveUsage::Instrumentation::RuntimeState.consume_sql_queries
|
|
64
|
+
}
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def controller_action_name(payload)
|
|
68
|
+
[payload[:controller], payload[:action]].compact.join("#")
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveUsage
|
|
4
|
+
class Railtie < Rails::Railtie
|
|
5
|
+
initializer "activeusage.middleware" do |app|
|
|
6
|
+
app.middleware.use ActiveUsage::Middleware
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
initializer "activeusage.active_job_hooks" do
|
|
10
|
+
ActiveSupport.on_load(:active_job) do
|
|
11
|
+
include ActiveUsage::Instrumentation::ActiveJobHooks
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
config.after_initialize do
|
|
16
|
+
ActiveUsage::Instrumentation::Subscriber.new.call
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveUsage
|
|
4
|
+
class Recorder
|
|
5
|
+
def initialize(attributes)
|
|
6
|
+
@attributes = attributes
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def call(store)
|
|
10
|
+
store.record(event)
|
|
11
|
+
ActiveSupport::Notifications.instrument("activeusage.event_recorded", event: event)
|
|
12
|
+
|
|
13
|
+
event
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
def event
|
|
19
|
+
@event ||= Event.new(@attributes)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveUsage
|
|
4
|
+
class Store
|
|
5
|
+
@instances_mutex = Mutex.new
|
|
6
|
+
@instances = []
|
|
7
|
+
@exit_hook_installed = false
|
|
8
|
+
|
|
9
|
+
class << self
|
|
10
|
+
def track(instance)
|
|
11
|
+
@instances_mutex.synchronize do
|
|
12
|
+
unless @exit_hook_installed
|
|
13
|
+
at_exit do
|
|
14
|
+
live = @instances_mutex.synchronize { @instances.dup }
|
|
15
|
+
live.each(&:shutdown!)
|
|
16
|
+
end
|
|
17
|
+
@exit_hook_installed = true
|
|
18
|
+
end
|
|
19
|
+
@instances << instance
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def untrack(instance)
|
|
24
|
+
@instances_mutex.synchronize { @instances.delete(instance) }
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def initialize(adapter, batch_size: 100, flush_interval: 1.0, max_queue_size: 10_000)
|
|
29
|
+
@adapter = adapter
|
|
30
|
+
@queue = EventQueue.new(max_queue_size, batch_size)
|
|
31
|
+
@flush_mutex = Mutex.new
|
|
32
|
+
@shutdown_mutex = Mutex.new
|
|
33
|
+
@shutdown = false
|
|
34
|
+
@worker = Worker.new(flush_interval) { flush! }
|
|
35
|
+
self.class.track(self)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def record(event)
|
|
39
|
+
@queue.push(event)
|
|
40
|
+
flush! if @queue.flush_ready?
|
|
41
|
+
event
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def clear!
|
|
45
|
+
flush!
|
|
46
|
+
@adapter.clear!
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def flush!
|
|
50
|
+
@flush_mutex.synchronize do
|
|
51
|
+
batch = @queue.drain
|
|
52
|
+
return if batch.empty?
|
|
53
|
+
|
|
54
|
+
begin
|
|
55
|
+
@adapter.record(batch)
|
|
56
|
+
rescue StandardError
|
|
57
|
+
batch.each { |event| @queue.push(event) }
|
|
58
|
+
raise
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def shutdown!
|
|
64
|
+
@shutdown_mutex.synchronize do
|
|
65
|
+
return if @shutdown
|
|
66
|
+
|
|
67
|
+
@shutdown = true
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
@worker.stop!
|
|
71
|
+
@worker.join(0.5)
|
|
72
|
+
flush!
|
|
73
|
+
@adapter.shutdown!
|
|
74
|
+
self.class.untrack(self)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveUsage
|
|
4
|
+
class Tags
|
|
5
|
+
KEY = :activeusage_tags
|
|
6
|
+
|
|
7
|
+
def initialize(tags)
|
|
8
|
+
@tags = tags
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def tag(**tags)
|
|
12
|
+
ActiveSupport::IsolatedExecutionState[KEY] = current.merge(tags)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def current
|
|
16
|
+
ActiveSupport::IsolatedExecutionState[KEY] || @tags
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def flush
|
|
20
|
+
ActiveSupport::IsolatedExecutionState[KEY] = @tags
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveUsage
|
|
4
|
+
class Tracker
|
|
5
|
+
def initialize(name, tags)
|
|
6
|
+
@name = name
|
|
7
|
+
@tags = tags
|
|
8
|
+
@started_at = Time.current
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def call(&)
|
|
12
|
+
Instrumentation::RuntimeState.clear_sql_state
|
|
13
|
+
result = ActiveSupport::Notifications.subscribed(sql_listener, "sql.active_record", &)
|
|
14
|
+
ActiveUsage.record(**attributes)
|
|
15
|
+
result
|
|
16
|
+
ensure
|
|
17
|
+
Instrumentation::RuntimeState.clear_sql_state
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def sql_listener
|
|
23
|
+
lambda do |_name, started, finished, _id, payload|
|
|
24
|
+
next if payload[:cached]
|
|
25
|
+
|
|
26
|
+
Instrumentation::RuntimeState.add_sql_event(
|
|
27
|
+
payload,
|
|
28
|
+
started_at: started,
|
|
29
|
+
finished_at: finished
|
|
30
|
+
)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def attributes
|
|
35
|
+
{
|
|
36
|
+
type: :task,
|
|
37
|
+
name: @name,
|
|
38
|
+
started_at: @started_at,
|
|
39
|
+
finished_at: Time.current,
|
|
40
|
+
tags: @tags,
|
|
41
|
+
sql_queries: Instrumentation::RuntimeState.consume_sql_queries
|
|
42
|
+
}
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveUsage
|
|
4
|
+
module Type
|
|
5
|
+
class SqlQueries < ActiveModel::Type::Value
|
|
6
|
+
def cast(value)
|
|
7
|
+
return [] unless value.is_a?(::Array)
|
|
8
|
+
|
|
9
|
+
value
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
ActiveModel::Type.register(:sql_queries, ActiveUsage::Type::SqlQueries)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveUsage
|
|
4
|
+
module Type
|
|
5
|
+
class Tags < ActiveModel::Type::Value
|
|
6
|
+
def cast(value)
|
|
7
|
+
return nil unless value.is_a?(::Hash)
|
|
8
|
+
|
|
9
|
+
value.transform_keys(&:to_sym)
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
ActiveModel::Type.register(:tags, ActiveUsage::Type::Tags)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveUsage
|
|
4
|
+
class WindowStartedAt
|
|
5
|
+
def initialize(finished_at, size)
|
|
6
|
+
@finished_at = finished_at
|
|
7
|
+
@size = size
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def call
|
|
11
|
+
Time.at(epoch - (epoch % @size))
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
def epoch
|
|
17
|
+
@epoch ||= @finished_at.to_i
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveUsage
|
|
4
|
+
class Worker
|
|
5
|
+
def initialize(interval, &block)
|
|
6
|
+
@interval = interval
|
|
7
|
+
@block = block
|
|
8
|
+
@mutex = Mutex.new
|
|
9
|
+
@running = true
|
|
10
|
+
start!
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def stop!
|
|
14
|
+
@mutex.synchronize { @running = false }
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def join(timeout)
|
|
18
|
+
@thread&.join(timeout)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def running?
|
|
24
|
+
@mutex.synchronize { @running }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def start!
|
|
28
|
+
@thread = Thread.new do
|
|
29
|
+
Thread.current.name = "activeusage.worker" if Thread.current.respond_to?(:name=)
|
|
30
|
+
tick while running?
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def tick
|
|
35
|
+
sleep @interval
|
|
36
|
+
@block.call
|
|
37
|
+
rescue StandardError => e
|
|
38
|
+
ActiveUsage.logger.error("[ActiveUsage::Worker] #{e.class}: #{e.message}")
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
data/lib/active_usage.rb
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_model"
|
|
4
|
+
require "active_support"
|
|
5
|
+
|
|
6
|
+
require "json"
|
|
7
|
+
require "net/http"
|
|
8
|
+
require "uri"
|
|
9
|
+
require "logger"
|
|
10
|
+
|
|
11
|
+
require_relative "active_usage/version"
|
|
12
|
+
require_relative "active_usage/configuration"
|
|
13
|
+
require_relative "active_usage/type/tags"
|
|
14
|
+
require_relative "active_usage/type/sql_queries"
|
|
15
|
+
require_relative "active_usage/event"
|
|
16
|
+
require_relative "active_usage/recorder"
|
|
17
|
+
require_relative "active_usage/tags"
|
|
18
|
+
require_relative "active_usage/window_started_at"
|
|
19
|
+
require_relative "active_usage/event_queue"
|
|
20
|
+
require_relative "active_usage/worker"
|
|
21
|
+
require_relative "active_usage/store"
|
|
22
|
+
require_relative "active_usage/adapters/base"
|
|
23
|
+
require_relative "active_usage/adapters/http"
|
|
24
|
+
require_relative "active_usage/tracker"
|
|
25
|
+
require_relative "active_usage/middleware"
|
|
26
|
+
require_relative "active_usage/instrumentation/active_job_hook"
|
|
27
|
+
require_relative "active_usage/instrumentation/runtime_state"
|
|
28
|
+
require_relative "active_usage/instrumentation/subscriber"
|
|
29
|
+
|
|
30
|
+
# Top-level namespace for the ActiveUsage gem.
|
|
31
|
+
module ActiveUsage
|
|
32
|
+
@configuration_mutex = Mutex.new
|
|
33
|
+
@store_mutex = Mutex.new
|
|
34
|
+
@tags_mutex = Mutex.new
|
|
35
|
+
|
|
36
|
+
class << self
|
|
37
|
+
def configuration
|
|
38
|
+
@configuration || @configuration_mutex.synchronize { @configuration ||= Configuration.new }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def configure
|
|
42
|
+
yield configuration
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def logger
|
|
46
|
+
configuration.logger
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def record(type:, name:, started_at:, finished_at:, **attributes)
|
|
50
|
+
Recorder.new(
|
|
51
|
+
type: type,
|
|
52
|
+
name: name,
|
|
53
|
+
started_at: started_at,
|
|
54
|
+
finished_at: finished_at,
|
|
55
|
+
tags: tags.current.merge(attributes.delete(:tags) || {}),
|
|
56
|
+
window_started_at: WindowStartedAt.new(finished_at, configuration.window_size).call,
|
|
57
|
+
**attributes
|
|
58
|
+
).call(store)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def track(name, tags: {}, &)
|
|
62
|
+
Tracker.new(name, tags).call(&)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def tags
|
|
66
|
+
@tags || @tags_mutex.synchronize { @tags ||= Tags.new(configuration.tags) }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def store
|
|
70
|
+
@store || @store_mutex.synchronize { @store ||= Store.new(configuration.adapter) }
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
require "active_usage/railtie" if defined?(Rails::Railtie)
|
data/lib/activeusage.rb
ADDED
metadata
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: activeusage
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Tomasz Kowalewski
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 2026-05-12 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: activemodel
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '7.2'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '7.2'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: activesupport
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - ">="
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '7.2'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - ">="
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '7.2'
|
|
40
|
+
description: ActiveUsage turns runtime signals into practical cost estimates for requests,
|
|
41
|
+
jobs, and tasks.
|
|
42
|
+
email:
|
|
43
|
+
- me@tkowalewski.pl
|
|
44
|
+
executables: []
|
|
45
|
+
extensions: []
|
|
46
|
+
extra_rdoc_files: []
|
|
47
|
+
files:
|
|
48
|
+
- CHANGELOG.md
|
|
49
|
+
- LICENSE.txt
|
|
50
|
+
- README.md
|
|
51
|
+
- lib/active_usage.rb
|
|
52
|
+
- lib/active_usage/adapters/base.rb
|
|
53
|
+
- lib/active_usage/adapters/http.rb
|
|
54
|
+
- lib/active_usage/configuration.rb
|
|
55
|
+
- lib/active_usage/event.rb
|
|
56
|
+
- lib/active_usage/event_queue.rb
|
|
57
|
+
- lib/active_usage/instrumentation/active_job_hook.rb
|
|
58
|
+
- lib/active_usage/instrumentation/runtime_state.rb
|
|
59
|
+
- lib/active_usage/instrumentation/subscriber.rb
|
|
60
|
+
- lib/active_usage/middleware.rb
|
|
61
|
+
- lib/active_usage/railtie.rb
|
|
62
|
+
- lib/active_usage/recorder.rb
|
|
63
|
+
- lib/active_usage/store.rb
|
|
64
|
+
- lib/active_usage/tags.rb
|
|
65
|
+
- lib/active_usage/tracker.rb
|
|
66
|
+
- lib/active_usage/type/sql_queries.rb
|
|
67
|
+
- lib/active_usage/type/tags.rb
|
|
68
|
+
- lib/active_usage/version.rb
|
|
69
|
+
- lib/active_usage/window_started_at.rb
|
|
70
|
+
- lib/active_usage/worker.rb
|
|
71
|
+
- lib/activeusage.rb
|
|
72
|
+
homepage: https://activeusage.com
|
|
73
|
+
licenses:
|
|
74
|
+
- MIT
|
|
75
|
+
metadata:
|
|
76
|
+
homepage_uri: https://activeusage.com
|
|
77
|
+
source_code_uri: https://github.com/ActiveUsage/activeusage/tree/0.1.0
|
|
78
|
+
changelog_uri: https://github.com/ActiveUsage/activeusage/tree/0.1.0/CHANGELOG.md
|
|
79
|
+
rubygems_mfa_required: 'true'
|
|
80
|
+
rdoc_options: []
|
|
81
|
+
require_paths:
|
|
82
|
+
- lib
|
|
83
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
84
|
+
requirements:
|
|
85
|
+
- - ">="
|
|
86
|
+
- !ruby/object:Gem::Version
|
|
87
|
+
version: 3.1.0
|
|
88
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
89
|
+
requirements:
|
|
90
|
+
- - ">="
|
|
91
|
+
- !ruby/object:Gem::Version
|
|
92
|
+
version: '0'
|
|
93
|
+
requirements: []
|
|
94
|
+
rubygems_version: 3.6.2
|
|
95
|
+
specification_version: 4
|
|
96
|
+
summary: Cost observability core for Ruby and Rails workloads.
|
|
97
|
+
test_files: []
|