railswatch_gem 0.1.5 → 0.2.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 +4 -4
- data/README.md +61 -93
- data/lib/railswatch_gem/configuration.rb +6 -44
- data/lib/railswatch_gem/helpers.rb +45 -54
- data/lib/railswatch_gem/instrumentation/ai_helper.rb +45 -0
- data/lib/railswatch_gem/instrumentation/anthropic.rb +61 -0
- data/lib/railswatch_gem/instrumentation/openai.rb +61 -0
- data/lib/railswatch_gem/railtie.rb +5 -12
- data/lib/railswatch_gem/version.rb +1 -1
- data/lib/railswatch_gem.rb +46 -63
- metadata +67 -29
- data/CHANGELOG.md +0 -5
- data/LICENSE.txt +0 -21
- data/Rakefile +0 -8
- data/lib/railswatch_gem/client.rb +0 -158
- data/lib/railswatch_gem/instrumentation/cache_instrumenter.rb +0 -69
- data/lib/railswatch_gem/instrumentation/commands_instrumenter.rb +0 -89
- data/lib/railswatch_gem/instrumentation/errors_instrumenter.rb +0 -68
- data/lib/railswatch_gem/instrumentation/jobs_instrumenter.rb +0 -82
- data/lib/railswatch_gem/instrumentation/logs_instrumenter.rb +0 -112
- data/lib/railswatch_gem/instrumentation/mail_instrumenter.rb +0 -65
- data/lib/railswatch_gem/instrumentation/models_instrumenter.rb +0 -90
- data/lib/railswatch_gem/instrumentation/notifications_instrumenter.rb +0 -63
- data/lib/railswatch_gem/instrumentation/outgoing_requests_instrumenter.rb +0 -99
- data/lib/railswatch_gem/instrumentation/queries_instrumenter.rb +0 -69
- data/lib/railswatch_gem/instrumentation/registry.rb +0 -49
- data/lib/railswatch_gem/instrumentation/requests_instrumenter.rb +0 -159
- data/lib/railswatch_gem/instrumentation/scheduled_tasks_instrumenter.rb +0 -85
- data/lib/railswatch_gem/middleware/request_context.rb +0 -28
- data/manual_test.rb +0 -103
- data/sig/railswatch_gem.rbs +0 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 540f88538c4fa2d5a156e22d680839b8768ea224eff4719729c3548731fadc67
|
|
4
|
+
data.tar.gz: 40e65222f075c543739f096dd197e9e1aef3fa8d66568d8af9447cb44ff928a9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c3cda587349e79a8908db25a6d6599bc6b08d0d57d5ac398cf4d8e108cc62290f4e281ee374d883076a50a13ae306c6088327b8efca9567a921b0a62d8980398
|
|
7
|
+
data.tar.gz: 718a3a2eff1bd4c83913ccdbdee9106ac0d372e2937d426ddca35c6805d0db90047c84f7eaa8e1225db4fc632c80f483326d1a113e0758f825d17bbb0b6cb509
|
data/README.md
CHANGED
|
@@ -1,130 +1,98 @@
|
|
|
1
1
|
# RailsWatch Gem
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
**Instant, zero-config observability for Ruby on Rails.**
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
The \`railswatch_gem\` automatically instruments your Rails application using **OpenTelemetry**. It captures request traces, database queries, background jobs, and—most importantly—provides deep visibility into your AI integration costs and latency (OpenAI & Anthropic).
|
|
6
6
|
|
|
7
|
-
##
|
|
7
|
+
## Features
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
* **Auto-Instrumentation:** Zero code required to track Rails requests, Sidekiq jobs, Postgres queries, Redis commands, and Net::HTTP calls.
|
|
10
|
+
* **AI Monitoring:** Automatically tracks token usage, costs, model names, and latency for \`ruby-openai\` and \`anthropic\` gems.
|
|
11
|
+
* **Debug Dump:** A global \`rw()\` helper to instantly dump variables into your trace timeline without clogging your stdout logs.
|
|
12
|
+
* **Standard OTLP:** Uses the industry-standard OpenTelemetry Protocol.
|
|
10
13
|
|
|
11
|
-
|
|
12
|
-
- HTTP Requests (Headers, Session, Params)
|
|
13
|
-
- Database Queries (SQL, Duration)
|
|
14
|
-
- Background Jobs (Sidekiq, ActiveJob)
|
|
15
|
-
- Mail Deliveries
|
|
16
|
-
- Cache Hits/Misses (Redis, Memcached)
|
|
17
|
-
- Outgoing HTTP Requests (Net::HTTP)
|
|
18
|
-
- Rake Tasks & CLI Commands
|
|
14
|
+
## Installation
|
|
19
15
|
|
|
20
|
-
|
|
16
|
+
Add this line to your application'\''s Gemfile:
|
|
21
17
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
- **Safe**: Handles thread-safety and swallows internal errors to prevent bringing down your app.
|
|
25
|
-
|
|
26
|
-
## 📦 Installation
|
|
27
|
-
|
|
28
|
-
Add this line to your application's Gemfile:
|
|
29
|
-
|
|
30
|
-
```ruby
|
|
31
|
-
gem 'railswatch_gem'
|
|
32
|
-
```
|
|
18
|
+
gem '\''railswatch_gem'\''
|
|
33
19
|
|
|
34
20
|
And then execute:
|
|
35
21
|
|
|
36
|
-
|
|
37
|
-
bundle install
|
|
38
|
-
```
|
|
22
|
+
bundle install
|
|
39
23
|
|
|
40
|
-
##
|
|
24
|
+
## Configuration
|
|
41
25
|
|
|
42
|
-
|
|
26
|
+
The gem is configured entirely via environment variables. You do not need an initializer file.
|
|
43
27
|
|
|
44
|
-
|
|
45
|
-
# config/initializers/railswatch.rb
|
|
28
|
+
Add these to your \`.env\` file or production environment:
|
|
46
29
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
30
|
+
# Required
|
|
31
|
+
RAILSWATCH_API_KEY=rw_live_...
|
|
32
|
+
RAILSWATCH_SERVICE_NAME=my-rails-app
|
|
50
33
|
|
|
51
|
-
|
|
52
|
-
|
|
34
|
+
# Optional (Defaults to standard RailsWatch ingest)
|
|
35
|
+
# RAILSWATCH_INGEST_URL=https://ingest.railswatch.com/v1/traces
|
|
53
36
|
|
|
54
|
-
|
|
55
|
-
# config.batch_size = 100 # Max events to send in one HTTP request
|
|
56
|
-
# config.flush_interval = 2.0 # Send data every 2 seconds
|
|
57
|
-
end
|
|
58
|
-
```
|
|
37
|
+
Once these variables are present, \`railswatch_gem\` will automatically start when Rails boots.
|
|
59
38
|
|
|
60
|
-
|
|
39
|
+
## Usage
|
|
61
40
|
|
|
62
|
-
|
|
41
|
+
### 1. Standard Observability
|
|
42
|
+
Just run your app!
|
|
43
|
+
* **Controllers:** Every request is traced.
|
|
44
|
+
* **Database:** SQL queries are captured as spans.
|
|
45
|
+
* **Jobs:** Sidekiq jobs are linked to the web requests that enqueued them.
|
|
63
46
|
|
|
64
|
-
###
|
|
47
|
+
### 2. AI Monitoring
|
|
48
|
+
If you use the \`ruby-openai\` or \`anthropic\` gems, we automatically patch them to record rich telemetry.
|
|
65
49
|
|
|
66
|
-
|
|
50
|
+
**Supported Libraries:**
|
|
51
|
+
* \`ruby-openai\`
|
|
52
|
+
* \`anthropic\`
|
|
67
53
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
54
|
+
**What we capture:**
|
|
55
|
+
* Model Name (e.g., \`gpt-4\`, \`claude-3-sonnet\`)
|
|
56
|
+
* Token Usage (Prompt, Completion, and Total)
|
|
57
|
+
* Latency
|
|
58
|
+
* System Fingerprints
|
|
72
59
|
|
|
73
|
-
|
|
74
|
-
rw(@user)
|
|
60
|
+
**Example:**
|
|
75
61
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
end
|
|
80
|
-
end
|
|
81
|
-
```
|
|
82
|
-
|
|
83
|
-
It returns the original object, so you can wrap expressions transparently:
|
|
62
|
+
# This API call will automatically appear in your RailsWatch dashboard
|
|
63
|
+
client = OpenAI::Client.new
|
|
64
|
+
client.chat(parameters: { model: "gpt-4", messages: [...] })
|
|
84
65
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
result = rw(ComplexCalculation.perform(x, y))
|
|
88
|
-
```
|
|
66
|
+
### 3. The \`rw()\` Debug Helper
|
|
67
|
+
Stop using \`puts\` or \`Rails.logger.info\` for debugging complex objects. Use the global \`rw()\` helper.
|
|
89
68
|
|
|
90
|
-
|
|
69
|
+
It dumps the object into the **current trace timeline**, allowing you to see exactly *when* a variable had a specific value relative to your DB queries and API calls.
|
|
91
70
|
|
|
92
|
-
|
|
71
|
+
def index
|
|
72
|
+
@users = User.all
|
|
93
73
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
event_type: "custom_alert",
|
|
97
|
-
message: "Something interesting happened",
|
|
98
|
-
metadata: { user_id: 123 }
|
|
99
|
-
})
|
|
100
|
-
```
|
|
74
|
+
# Dumps the array to RailsWatch without stopping execution
|
|
75
|
+
rw(@users)
|
|
101
76
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
77
|
+
# You can also pass through values (returns the argument)
|
|
78
|
+
@user = rw(User.first)
|
|
79
|
+
end
|
|
105
80
|
|
|
106
|
-
|
|
81
|
+
## Troubleshooting
|
|
107
82
|
|
|
108
|
-
|
|
83
|
+
If you don'\''t see data in your dashboard:
|
|
109
84
|
|
|
110
|
-
|
|
85
|
+
1. **Check the Logs:**
|
|
86
|
+
On boot, you should see: \`[Railswatch] Observability started for my-rails-app\`.
|
|
111
87
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
| Requests | Controller actions, status codes, paths, IP addresses. |
|
|
115
|
-
| Queries | ActiveRecord SQL queries (filters out SCHEMA/EXPLAIN). |
|
|
116
|
-
| Jobs | ActiveJob & Sidekiq execution, latency, and retries. |
|
|
117
|
-
| Mail | ActionMailer deliveries, recipients, and subjects. |
|
|
118
|
-
| Cache | ActiveSupport::Cache reads, writes, and hit rates. |
|
|
119
|
-
| Outbound | Net::HTTP requests to external APIs. |
|
|
120
|
-
| Models | ActiveRecord Create/Update/Destroy events with changesets. |
|
|
121
|
-
| Exceptions | Unhandled exceptions and Rails.error.report calls. |
|
|
122
|
-
| Commands | Rake tasks and Thor CLI commands. |
|
|
88
|
+
2. **Verify Environment Variables:**
|
|
89
|
+
Ensure \`RAILSWATCH_API_KEY\` is available in your shell/environment.
|
|
123
90
|
|
|
124
|
-
|
|
91
|
+
3. **OpenTelemetry Debugging:**
|
|
92
|
+
To see exactly what OTel is doing, enable the internal logger:
|
|
125
93
|
|
|
126
|
-
|
|
94
|
+
OTEL_LOG_LEVEL=debug rails s
|
|
127
95
|
|
|
128
|
-
##
|
|
96
|
+
## License
|
|
129
97
|
|
|
130
|
-
The gem is available as open source under the terms of the MIT License.
|
|
98
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
|
@@ -2,52 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
module RailswatchGem
|
|
4
4
|
class Configuration
|
|
5
|
-
attr_accessor :
|
|
6
|
-
attr_reader :enabled_events, :custom_event_blocks
|
|
7
|
-
|
|
8
|
-
DEFAULT_EVENTS = %i[
|
|
9
|
-
requests
|
|
10
|
-
queries
|
|
11
|
-
outgoing_requests
|
|
12
|
-
jobs
|
|
13
|
-
scheduled_tasks
|
|
14
|
-
commands
|
|
15
|
-
cache
|
|
16
|
-
mail
|
|
17
|
-
notifications
|
|
18
|
-
logs
|
|
19
|
-
errors
|
|
20
|
-
models
|
|
21
|
-
].freeze
|
|
22
|
-
|
|
23
|
-
DEFAULT_INGEST_URL = "https://api.railswatch.com/ingest".freeze
|
|
5
|
+
attr_accessor :api_key, :service_name, :ingest_url, :enabled
|
|
24
6
|
|
|
25
7
|
def initialize
|
|
26
|
-
@
|
|
27
|
-
|
|
28
|
-
@
|
|
29
|
-
@ingest_url =
|
|
30
|
-
@
|
|
31
|
-
|
|
32
|
-
# all default event families enabled
|
|
33
|
-
@enabled_events = DEFAULT_EVENTS.dup
|
|
34
|
-
|
|
35
|
-
# user-supplied instrumentation hooks
|
|
36
|
-
@custom_event_blocks = []
|
|
37
|
-
end
|
|
38
|
-
|
|
39
|
-
def enable_event(name)
|
|
40
|
-
name = name.to_sym
|
|
41
|
-
@enabled_events << name unless @enabled_events.include?(name)
|
|
42
|
-
end
|
|
43
|
-
|
|
44
|
-
def disable_event(name)
|
|
45
|
-
@enabled_events.delete(name.to_sym)
|
|
46
|
-
end
|
|
47
|
-
|
|
48
|
-
# for custom subscriptions using the client
|
|
49
|
-
def on_custom_event(&block)
|
|
50
|
-
@custom_event_blocks << block if block
|
|
8
|
+
@api_key = ENV["RAILSWATCH_API_KEY"]
|
|
9
|
+
default_name = defined?(::Rails) ? ::Rails.application.class.module_parent_name.underscore.dasherize : "rails-app"
|
|
10
|
+
@service_name = ENV["RAILSWATCH_SERVICE_NAME"] || default_name
|
|
11
|
+
@ingest_url = ENV["RAILSWATCH_INGEST_URL"] || "https://ingest.railswatch.com/v1/traces"
|
|
12
|
+
@enabled = true
|
|
51
13
|
end
|
|
52
14
|
end
|
|
53
15
|
end
|
|
@@ -1,71 +1,62 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
3
5
|
module RailswatchGem
|
|
4
6
|
module Helpers
|
|
5
|
-
#
|
|
7
|
+
# Types that can be safely serialized to JSON without inspection
|
|
8
|
+
SAFE_JSON_TYPES = [Hash, Array, String, Integer, Float, TrueClass, FalseClass, NilClass].freeze
|
|
9
|
+
MAX_PAYLOAD_SIZE = 4096
|
|
10
|
+
|
|
6
11
|
def railswatch_dump(*args)
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
values: formatted_args,
|
|
22
|
-
location: call_site,
|
|
23
|
-
request_id: request_id
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
# Send to client
|
|
27
|
-
RailswatchGem.record(payload)
|
|
28
|
-
|
|
29
|
-
# 4. Return original args to allow debugging without breaking chains:
|
|
30
|
-
# return rw(result)
|
|
31
|
-
if args.size == 1
|
|
32
|
-
args.first
|
|
33
|
-
else
|
|
34
|
-
args
|
|
35
|
-
end
|
|
12
|
+
span = OpenTelemetry::Trace.current_span
|
|
13
|
+
return return_value(args) unless span.recording?
|
|
14
|
+
|
|
15
|
+
file, line = parse_caller_location
|
|
16
|
+
|
|
17
|
+
json_content = serialize_args(args)
|
|
18
|
+
|
|
19
|
+
span.add_event("railswatch.dump", attributes: {
|
|
20
|
+
"code.filepath" => file,
|
|
21
|
+
"code.lineno" => line,
|
|
22
|
+
"railswatch.dump.content" => truncate_payload(json_content)
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
return_value(args)
|
|
36
26
|
rescue => e
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
args.size == 1 ? args.first : args
|
|
27
|
+
# Fail silently, return original args
|
|
28
|
+
return_value(args)
|
|
40
29
|
end
|
|
41
30
|
|
|
42
|
-
# Short alias for ease of use
|
|
43
31
|
alias_method :rw, :railswatch_dump
|
|
44
32
|
|
|
45
33
|
private
|
|
46
34
|
|
|
47
|
-
def
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
35
|
+
def return_value(args)
|
|
36
|
+
args.size == 1 ? args.first : args
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def parse_caller_location
|
|
40
|
+
# Returns ["/path/to/file.rb", "45"]
|
|
41
|
+
caller(2, 1).first.to_s.split(":")[0..1]
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def serialize_args(args)
|
|
45
|
+
args.map { |arg| sanitize_arg(arg) }.to_json
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def sanitize_arg(arg)
|
|
49
|
+
return arg if SAFE_JSON_TYPES.any? { |t| arg.is_a?(t) }
|
|
50
|
+
|
|
51
|
+
# Fallback for complex objects (ActiveRecord, Custom Classes)
|
|
52
|
+
arg.inspect
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def truncate_payload(json)
|
|
56
|
+
return json if json.bytesize <= MAX_PAYLOAD_SIZE
|
|
57
|
+
json.byteslice(0, MAX_PAYLOAD_SIZE - 3) + "..."
|
|
65
58
|
end
|
|
66
59
|
end
|
|
67
60
|
end
|
|
68
61
|
|
|
69
|
-
# Automatically mix into the top-level Object so it's available everywhere
|
|
70
|
-
# (Models, Controllers, Views, Console, Irb)
|
|
71
62
|
Object.include(RailswatchGem::Helpers) if defined?(Object)
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailswatchGem
|
|
4
|
+
module Instrumentation
|
|
5
|
+
module AiHelper
|
|
6
|
+
MAX_CONTENT_SIZE = 4096
|
|
7
|
+
|
|
8
|
+
def trace_ai_request(tracer, span_name, attributes = {})
|
|
9
|
+
span = tracer.start_span(span_name, kind: :client)
|
|
10
|
+
|
|
11
|
+
OpenTelemetry::Trace.with_span(span) do
|
|
12
|
+
# 1. Set Standard Request Attributes
|
|
13
|
+
span.set_attribute("gen_ai.system", attributes[:system])
|
|
14
|
+
span.set_attribute("gen_ai.request.model", attributes[:model].to_s)
|
|
15
|
+
|
|
16
|
+
if attributes[:prompt_role]
|
|
17
|
+
span.set_attribute("gen_ai.prompt.role", attributes[:prompt_role].to_s)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
if attributes[:prompt_content]
|
|
21
|
+
span.set_attribute("gen_ai.prompt.content", truncate(attributes[:prompt_content].to_s))
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# 2. Yield span to the block so the provider can set its own stats
|
|
25
|
+
response = yield(span)
|
|
26
|
+
|
|
27
|
+
response
|
|
28
|
+
rescue => e
|
|
29
|
+
span.record_exception(e)
|
|
30
|
+
span.status = OpenTelemetry::Trace::Status.error("#{attributes[:system]} Request Failed: #{e.message}")
|
|
31
|
+
raise e
|
|
32
|
+
ensure
|
|
33
|
+
span.finish
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def truncate(str)
|
|
40
|
+
return str if str.bytesize <= MAX_CONTENT_SIZE
|
|
41
|
+
str.byteslice(0, MAX_CONTENT_SIZE - 3) + "..."
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "railswatch_gem/instrumentation/ai_helper"
|
|
4
|
+
|
|
5
|
+
module RailswatchGem
|
|
6
|
+
module Instrumentation
|
|
7
|
+
module Anthropic
|
|
8
|
+
extend AiHelper
|
|
9
|
+
|
|
10
|
+
def self.install
|
|
11
|
+
return unless defined?(::Anthropic::Client)
|
|
12
|
+
::Anthropic::Client.prepend(Patches)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
module Patches
|
|
16
|
+
def messages(parameters = {})
|
|
17
|
+
model = parameters[:model] || parameters["model"]
|
|
18
|
+
messages = parameters[:messages] || parameters["messages"]
|
|
19
|
+
last_msg = messages&.last
|
|
20
|
+
|
|
21
|
+
prompt_role = last_msg.is_a?(Hash) ? (last_msg[:role] || last_msg["role"]) : nil
|
|
22
|
+
prompt_content = last_msg.is_a?(Hash) ? (last_msg[:content] || last_msg["content"]) : nil
|
|
23
|
+
|
|
24
|
+
RailswatchGem::Instrumentation::Anthropic.trace_ai_request(
|
|
25
|
+
tracer,
|
|
26
|
+
"gen_ai.anthropic.messages",
|
|
27
|
+
system: "anthropic",
|
|
28
|
+
model: model,
|
|
29
|
+
prompt_role: prompt_role,
|
|
30
|
+
prompt_content: prompt_content
|
|
31
|
+
) do |span|
|
|
32
|
+
response = super
|
|
33
|
+
|
|
34
|
+
# 2. Extract Stats (Anthropic Format)
|
|
35
|
+
if response.is_a?(Hash)
|
|
36
|
+
if (usage = response["usage"])
|
|
37
|
+
input = usage["input_tokens"].to_i
|
|
38
|
+
output = usage["output_tokens"].to_i
|
|
39
|
+
span.set_attribute("gen_ai.usage.input_tokens", input)
|
|
40
|
+
span.set_attribute("gen_ai.usage.output_tokens", output)
|
|
41
|
+
span.set_attribute("gen_ai.usage.total_tokens", input + output)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
if response["stop_reason"]
|
|
45
|
+
span.set_attribute("gen_ai.response.finish_reason", response["stop_reason"].to_s)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
response
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def tracer
|
|
56
|
+
OpenTelemetry.tracer_provider.tracer("railswatch_gem-ai")
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "railswatch_gem/instrumentation/ai_helper"
|
|
4
|
+
|
|
5
|
+
module RailswatchGem
|
|
6
|
+
module Instrumentation
|
|
7
|
+
module OpenAI
|
|
8
|
+
extend AiHelper
|
|
9
|
+
|
|
10
|
+
def self.install
|
|
11
|
+
return unless defined?(::OpenAI::Client)
|
|
12
|
+
::OpenAI::Client.prepend(Patches)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
module Patches
|
|
16
|
+
def chat(parameters = {})
|
|
17
|
+
model = parameters[:model] || parameters["model"]
|
|
18
|
+
messages = parameters[:messages] || parameters["messages"]
|
|
19
|
+
last_msg = messages&.last
|
|
20
|
+
|
|
21
|
+
# Extract prompt safely
|
|
22
|
+
prompt_role = last_msg.is_a?(Hash) ? (last_msg[:role] || last_msg["role"]) : nil
|
|
23
|
+
prompt_content = last_msg.is_a?(Hash) ? (last_msg[:content] || last_msg["content"]) : nil
|
|
24
|
+
|
|
25
|
+
RailswatchGem::Instrumentation::OpenAI.trace_ai_request(
|
|
26
|
+
tracer,
|
|
27
|
+
"gen_ai.chat",
|
|
28
|
+
system: "openai",
|
|
29
|
+
model: model,
|
|
30
|
+
prompt_role: prompt_role,
|
|
31
|
+
prompt_content: prompt_content
|
|
32
|
+
) do |span|
|
|
33
|
+
# 1. Call original method
|
|
34
|
+
response = super
|
|
35
|
+
|
|
36
|
+
# 2. Extract Stats (OpenAI Format)
|
|
37
|
+
if response.is_a?(Hash)
|
|
38
|
+
if (usage = response["usage"])
|
|
39
|
+
span.set_attribute("gen_ai.usage.input_tokens", usage["prompt_tokens"].to_i)
|
|
40
|
+
span.set_attribute("gen_ai.usage.output_tokens", usage["completion_tokens"].to_i)
|
|
41
|
+
span.set_attribute("gen_ai.usage.total_tokens", usage["total_tokens"].to_i)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
if (choice = response.dig("choices", 0))
|
|
45
|
+
span.set_attribute("gen_ai.response.finish_reason", choice["finish_reason"].to_s)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
response
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def tracer
|
|
56
|
+
OpenTelemetry.tracer_provider.tracer("railswatch_gem-ai")
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -1,21 +1,14 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "rails/railtie"
|
|
4
|
-
require_relative "middleware/request_context"
|
|
5
|
-
|
|
6
3
|
module RailswatchGem
|
|
7
4
|
class Railtie < ::Rails::Railtie
|
|
8
|
-
# Insert our middleware at the very top (index 0) to ensure we catch everything,
|
|
9
|
-
# including potential errors in other middlewares.
|
|
10
|
-
initializer "railswatch.middleware" do |app|
|
|
11
|
-
app.middleware.insert_before 0, RailswatchGem::Middleware::RequestContext
|
|
12
|
-
end
|
|
13
|
-
|
|
14
5
|
# Automatically boot the instrumentation when Rails finishes initializing.
|
|
15
6
|
config.after_initialize do
|
|
16
|
-
#
|
|
17
|
-
if RailswatchGem.
|
|
18
|
-
RailswatchGem.
|
|
7
|
+
# Check if properly configured (e.g. via ENV vars or initializer)
|
|
8
|
+
if RailswatchGem.config.api_key.present?
|
|
9
|
+
RailswatchGem.start!
|
|
10
|
+
else
|
|
11
|
+
puts "[Railswatch] API Key not found. Observability disabled."
|
|
19
12
|
end
|
|
20
13
|
end
|
|
21
14
|
end
|