activeagent 1.0.1 → 1.0.2

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 (88) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +10 -4
  3. data/lib/active_agent/base.rb +3 -2
  4. data/lib/active_agent/concerns/provider.rb +6 -2
  5. data/lib/active_agent/concerns/rescue.rb +39 -0
  6. data/lib/active_agent/concerns/streaming.rb +2 -1
  7. data/lib/active_agent/dashboard/app/controllers/active_agent/dashboard/api/traces_controller.rb +117 -0
  8. data/lib/active_agent/dashboard/app/controllers/active_agent/dashboard/application_controller.rb +54 -0
  9. data/lib/active_agent/dashboard/app/controllers/active_agent/dashboard/dashboard_controller.rb +126 -0
  10. data/lib/active_agent/dashboard/app/controllers/active_agent/dashboard/traces_controller.rb +103 -0
  11. data/lib/active_agent/dashboard/app/jobs/active_agent/dashboard/agent_execution_job.rb +56 -0
  12. data/lib/active_agent/dashboard/app/jobs/active_agent/dashboard/application_job.rb +14 -0
  13. data/lib/active_agent/dashboard/app/jobs/active_agent/dashboard/sandbox_cleanup_job.rb +49 -0
  14. data/lib/active_agent/dashboard/app/jobs/active_agent/dashboard/sandbox_provision_job.rb +65 -0
  15. data/lib/active_agent/dashboard/app/jobs/active_agent/process_telemetry_traces_job.rb +77 -0
  16. data/lib/active_agent/dashboard/app/models/active_agent/dashboard/agent.rb +256 -0
  17. data/lib/active_agent/dashboard/app/models/active_agent/dashboard/agent_run.rb +113 -0
  18. data/lib/active_agent/dashboard/app/models/active_agent/dashboard/agent_template.rb +208 -0
  19. data/lib/active_agent/dashboard/app/models/active_agent/dashboard/agent_version.rb +60 -0
  20. data/lib/active_agent/dashboard/app/models/active_agent/dashboard/application_record.rb +46 -0
  21. data/lib/active_agent/dashboard/app/models/active_agent/dashboard/recording_action.rb +125 -0
  22. data/lib/active_agent/dashboard/app/models/active_agent/dashboard/recording_snapshot.rb +83 -0
  23. data/lib/active_agent/dashboard/app/models/active_agent/dashboard/sandbox_run.rb +52 -0
  24. data/lib/active_agent/dashboard/app/models/active_agent/dashboard/sandbox_session.rb +169 -0
  25. data/lib/active_agent/dashboard/app/models/active_agent/dashboard/session_recording.rb +193 -0
  26. data/lib/active_agent/dashboard/app/models/active_agent/telemetry_trace.rb +198 -0
  27. data/lib/active_agent/dashboard/app/views/active_agent/dashboard/traces/_trace_detail.html.erb +105 -0
  28. data/lib/active_agent/dashboard/app/views/active_agent/dashboard/traces/index.html.erb +135 -0
  29. data/lib/active_agent/dashboard/app/views/active_agent/dashboard/traces/metrics.html.erb +143 -0
  30. data/lib/active_agent/dashboard/app/views/active_agent/dashboard/traces/show.html.erb +36 -0
  31. data/lib/active_agent/dashboard/app/views/layouts/active_agent/dashboard/application.html.erb +94 -0
  32. data/lib/active_agent/dashboard/config/routes.rb +78 -0
  33. data/lib/active_agent/dashboard/engine.rb +39 -0
  34. data/lib/active_agent/dashboard.rb +151 -0
  35. data/lib/active_agent/providers/_base_provider.rb +2 -1
  36. data/lib/active_agent/providers/anthropic_provider.rb +14 -4
  37. data/lib/active_agent/providers/azure/_types.rb +5 -0
  38. data/lib/active_agent/providers/azure/options.rb +111 -0
  39. data/lib/active_agent/providers/azure_open_ai_provider.rb +2 -0
  40. data/lib/active_agent/providers/azure_openai_provider.rb +2 -0
  41. data/lib/active_agent/providers/azure_provider.rb +133 -0
  42. data/lib/active_agent/providers/azureopenai_provider.rb +2 -0
  43. data/lib/active_agent/providers/bedrock/_types.rb +8 -0
  44. data/lib/active_agent/providers/bedrock/bearer_client.rb +109 -0
  45. data/lib/active_agent/providers/bedrock/options.rb +77 -0
  46. data/lib/active_agent/providers/bedrock_provider.rb +84 -0
  47. data/lib/active_agent/providers/common/messages/_types.rb +6 -2
  48. data/lib/active_agent/providers/concerns/exception_handler.rb +1 -0
  49. data/lib/active_agent/providers/gemini/_types.rb +19 -0
  50. data/lib/active_agent/providers/gemini/options.rb +41 -0
  51. data/lib/active_agent/providers/gemini_provider.rb +94 -0
  52. data/lib/active_agent/providers/open_ai/chat/transforms.rb +37 -1
  53. data/lib/active_agent/providers/open_ai/chat_provider.rb +2 -0
  54. data/lib/active_agent/providers/ruby_llm/_types.rb +77 -0
  55. data/lib/active_agent/providers/ruby_llm/embedding_request.rb +16 -0
  56. data/lib/active_agent/providers/ruby_llm/messages/_types.rb +109 -0
  57. data/lib/active_agent/providers/ruby_llm/messages/assistant.rb +27 -0
  58. data/lib/active_agent/providers/ruby_llm/messages/base.rb +48 -0
  59. data/lib/active_agent/providers/ruby_llm/messages/system.rb +18 -0
  60. data/lib/active_agent/providers/ruby_llm/messages/tool.rb +24 -0
  61. data/lib/active_agent/providers/ruby_llm/messages/user.rb +18 -0
  62. data/lib/active_agent/providers/ruby_llm/options.rb +28 -0
  63. data/lib/active_agent/providers/ruby_llm/request.rb +30 -0
  64. data/lib/active_agent/providers/ruby_llm/tool_proxy.rb +45 -0
  65. data/lib/active_agent/providers/ruby_llm_provider.rb +407 -0
  66. data/lib/active_agent/railtie.rb +32 -1
  67. data/lib/active_agent/telemetry/configuration.rb +213 -0
  68. data/lib/active_agent/telemetry/instrumentation.rb +155 -0
  69. data/lib/active_agent/telemetry/reporter.rb +176 -0
  70. data/lib/active_agent/telemetry/span.rb +267 -0
  71. data/lib/active_agent/telemetry/tracer.rb +184 -0
  72. data/lib/active_agent/telemetry.rb +162 -0
  73. data/lib/active_agent/version.rb +1 -1
  74. data/lib/active_agent.rb +2 -0
  75. data/lib/generators/active_agent/dashboard/install/install_generator.rb +96 -0
  76. data/lib/generators/active_agent/dashboard/install/templates/initializer.rb +89 -0
  77. data/lib/generators/active_agent/dashboard/install/templates/migrations/create_active_agent_agent_runs.rb +42 -0
  78. data/lib/generators/active_agent/dashboard/install/templates/migrations/create_active_agent_agent_templates.rb +38 -0
  79. data/lib/generators/active_agent/dashboard/install/templates/migrations/create_active_agent_agent_versions.rb +22 -0
  80. data/lib/generators/active_agent/dashboard/install/templates/migrations/create_active_agent_agents.rb +53 -0
  81. data/lib/generators/active_agent/dashboard/install/templates/migrations/create_active_agent_sandbox_runs.rb +28 -0
  82. data/lib/generators/active_agent/dashboard/install/templates/migrations/create_active_agent_sandbox_sessions.rb +43 -0
  83. data/lib/generators/active_agent/dashboard/install/templates/migrations/create_active_agent_session_recordings.rb +44 -0
  84. data/lib/generators/active_agent/dashboard/install/templates/migrations/create_active_agent_telemetry_traces.rb +56 -0
  85. data/lib/generators/active_agent/dashboard/install_generator.rb +64 -0
  86. data/lib/generators/active_agent/dashboard/templates/active_agent_dashboard.rb.erb +30 -0
  87. data/lib/generators/active_agent/dashboard/templates/create_active_agent_telemetry_traces.rb.erb +30 -0
  88. metadata +99 -13
@@ -0,0 +1,407 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "_base_provider"
4
+
5
+ require_gem!(:ruby_llm, __FILE__) unless defined?(::RubyLLM)
6
+
7
+ require_relative "ruby_llm/_types"
8
+ require_relative "ruby_llm/tool_proxy"
9
+
10
+ module ActiveAgent
11
+ module Providers
12
+ # Provider for RubyLLM's unified API, supporting 15+ LLM providers
13
+ # (OpenAI, Anthropic, Gemini, Bedrock, Azure, Ollama, etc.).
14
+ #
15
+ # Uses RubyLLM's provider-level API (provider.complete()) rather than
16
+ # the high-level Chat object to avoid conflicts with ActiveAgent's own
17
+ # conversation management and tool execution loop.
18
+ #
19
+ # @see BaseProvider
20
+ class RubyLLMProvider < BaseProvider
21
+ # @return [RubyLLM::EmbeddingRequestType] embedding request type
22
+ def self.embed_request_type
23
+ RubyLLM::EmbeddingRequestType.new
24
+ end
25
+
26
+ protected
27
+
28
+ # Clears tool_choice between turns to prevent infinite tool-calling loops.
29
+ def prepare_prompt_request
30
+ prepare_prompt_request_tools
31
+ super
32
+ end
33
+
34
+ # Executes a prompt request via RubyLLM's provider-level API.
35
+ #
36
+ # Resolves the appropriate provider from the model ID, converts
37
+ # ActiveAgent messages/tools to RubyLLM format, and calls
38
+ # provider.complete().
39
+ #
40
+ # @param parameters [Hash] serialized request parameters
41
+ # @return [Hash, nil] normalized API response hash, or nil for streaming
42
+ def api_prompt_execute(parameters)
43
+ @resolved_model_id = parameters[:model] || options.model
44
+ resolve_ruby_llm_provider!(@resolved_model_id)
45
+
46
+ # Convert messages to RubyLLM format
47
+ messages = build_ruby_llm_messages(parameters)
48
+
49
+ # Convert tools to RubyLLM format
50
+ tools = build_ruby_llm_tools(parameters[:tools])
51
+
52
+ # Build kwargs for provider.complete (tools, temperature, model are required)
53
+ kwargs = {
54
+ model: @ruby_llm_model,
55
+ tools: tools || {},
56
+ temperature: parameters[:temperature]
57
+ }
58
+ kwargs[:schema] = parameters[:response_format] if parameters[:response_format]
59
+
60
+ # Pass extra params (max_tokens, etc.) via RubyLLM's params: deep-merge
61
+ max_tokens = parameters[:max_tokens] || options.max_tokens
62
+ if max_tokens
63
+ kwargs[:params] = { max_tokens: max_tokens }
64
+ end
65
+
66
+ if parameters[:stream]
67
+ stream_proc = parameters[:stream]
68
+
69
+ # For streaming, pass a block that forwards chunks
70
+ @ruby_llm_provider.complete(messages, **kwargs) do |chunk|
71
+ stream_proc.call(chunk)
72
+ end
73
+
74
+ nil
75
+ else
76
+ response = @ruby_llm_provider.complete(messages, **kwargs)
77
+ normalize_ruby_llm_response(response, @resolved_model_id)
78
+ end
79
+ end
80
+
81
+ # Executes an embedding request via RubyLLM.
82
+ #
83
+ # @param parameters [Hash] serialized embedding request parameters
84
+ # @return [Hash] normalized embedding response with symbol keys
85
+ def api_embed_execute(parameters)
86
+ model_id = parameters[:model] || options.model
87
+ resolve_ruby_llm_provider!(model_id)
88
+
89
+ input = parameters[:input]
90
+ inputs = input.is_a?(Array) ? input : [ input ]
91
+
92
+ data = inputs.map.with_index do |text, index|
93
+ embedding = @ruby_llm_provider.embed(text, model: model_id, dimensions: parameters[:dimensions])
94
+
95
+ {
96
+ object: "embedding",
97
+ index: index,
98
+ embedding: embedding.vectors
99
+ }
100
+ end
101
+
102
+ {
103
+ object: :list,
104
+ data: data,
105
+ model: model_id
106
+ }
107
+ end
108
+
109
+ # Processes streaming chunks from RubyLLM.
110
+ #
111
+ # Handles RubyLLM::Chunk objects, building up the message in message_stack.
112
+ #
113
+ # @param chunk [RubyLLM::Chunk] streaming chunk
114
+ # @return [void]
115
+ def process_stream_chunk(chunk)
116
+ instrument("stream_chunk.active_agent")
117
+
118
+ broadcast_stream_open
119
+
120
+ if message_stack.empty? || !message_stack.last.is_a?(Hash) || message_stack.last[:role] != "assistant"
121
+ message_stack.push({ role: "assistant", content: "" })
122
+ end
123
+
124
+ message = message_stack.last
125
+
126
+ # Append content delta
127
+ if chunk.content
128
+ message[:content] ||= ""
129
+ message[:content] += chunk.content
130
+ broadcast_stream_update(message, chunk.content)
131
+ end
132
+
133
+ # Handle tool calls in chunk
134
+ if chunk.tool_calls&.any?
135
+ message[:tool_calls] ||= []
136
+ chunk.tool_calls.each do |_id, tool_call|
137
+ existing = message[:tool_calls].find { |tc| tc[:id] == tool_call.id }
138
+ if existing
139
+ existing[:function][:arguments] += tool_call.arguments.to_s if tool_call.arguments
140
+ else
141
+ message[:tool_calls] << {
142
+ id: tool_call.id,
143
+ type: "function",
144
+ function: {
145
+ name: tool_call.name,
146
+ arguments: tool_call.arguments.to_s
147
+ }
148
+ }
149
+ end
150
+ end
151
+ end
152
+
153
+ # Stream completion is handled by the base provider after
154
+ # api_prompt_execute returns nil. No action needed here.
155
+ end
156
+
157
+ # Extracts messages from the completed API response.
158
+ #
159
+ # @param api_response [Hash, nil] normalized response hash
160
+ # @return [Array<Hash>, nil]
161
+ def process_prompt_finished_extract_messages(api_response)
162
+ return nil unless api_response
163
+ [ api_response ]
164
+ end
165
+
166
+ # Extracts tool/function calls from the last message in the stack.
167
+ #
168
+ # Converts RubyLLM's tool_calls format to ActiveAgent's expected format
169
+ # with parsed JSON arguments.
170
+ #
171
+ # @return [Array<Hash>, nil] tool calls or nil
172
+ def process_prompt_finished_extract_function_calls
173
+ last_message = message_stack.last
174
+ return nil unless last_message.is_a?(Hash)
175
+
176
+ tool_calls = last_message[:tool_calls]
177
+ return nil unless tool_calls&.any?
178
+
179
+ tool_calls.map do |tc|
180
+ args = tc.dig(:function, :arguments)
181
+ parsed_args = if args.is_a?(String) && args.present?
182
+ JSON.parse(args, symbolize_names: true)
183
+ elsif args.is_a?(Hash)
184
+ args.deep_symbolize_keys
185
+ else
186
+ {}
187
+ end
188
+
189
+ {
190
+ id: tc[:id],
191
+ name: tc.dig(:function, :name),
192
+ input: parsed_args
193
+ }
194
+ end
195
+ end
196
+
197
+ # Extracts function names from tool_calls in assistant messages on the stack.
198
+ #
199
+ # @return [Array<String>]
200
+ def extract_used_function_names
201
+ message_stack
202
+ .select { |msg| msg[:role] == "assistant" && msg[:tool_calls] }
203
+ .flat_map { |msg| msg[:tool_calls] }
204
+ .map { |tc| tc.dig(:function, :name) }
205
+ .compact
206
+ end
207
+
208
+ # Returns true if tool_choice forces any tool to be used.
209
+ #
210
+ # Handles both string ("required") and hash ({name: "..."}) formats.
211
+ #
212
+ # @return [Boolean]
213
+ def tool_choice_forces_required?
214
+ request.tool_choice == "required"
215
+ end
216
+
217
+ # Returns [true, name] if tool_choice forces a specific tool.
218
+ #
219
+ # @return [Array<Boolean, String|nil>]
220
+ def tool_choice_forces_specific?
221
+ if request.tool_choice.is_a?(Hash)
222
+ [ true, request.tool_choice[:name] ]
223
+ else
224
+ [ false, nil ]
225
+ end
226
+ end
227
+
228
+ # Executes tool calls and pushes results to message_stack.
229
+ #
230
+ # @param tool_calls [Array<Hash>] with :id, :name, :input keys
231
+ # @return [void]
232
+ def process_function_calls(tool_calls)
233
+ tool_calls.each do |tool_call|
234
+ content = instrument("tool_call.active_agent", tool_name: tool_call[:name]) do
235
+ tools_function.call(tool_call[:name], **tool_call[:input])
236
+ end
237
+
238
+ message_stack.push({
239
+ role: "tool",
240
+ tool_call_id: tool_call[:id],
241
+ content: content.to_json
242
+ })
243
+ end
244
+ end
245
+
246
+ # api_prompt_execute always returns a normalized Hash or nil (streaming),
247
+ # so no additional normalization is needed for instrumentation.
248
+ # Inherits default api_response_normalize from BaseProvider.
249
+
250
+ private
251
+
252
+ # Resolves and caches the RubyLLM provider for the given model.
253
+ #
254
+ # Reuses the cached provider if the model hasn't changed (e.g., during
255
+ # multi-turn tool calling loops).
256
+ #
257
+ # @param model_id [String] model identifier
258
+ # @return [void]
259
+ def resolve_ruby_llm_provider!(model_id)
260
+ return if @ruby_llm_provider && @cached_model_id == model_id
261
+
262
+ @cached_model_id = model_id
263
+ @ruby_llm_model, @ruby_llm_provider = ::RubyLLM::Models.resolve(model_id, config: ::RubyLLM.config)
264
+ end
265
+
266
+ # Converts ActiveAgent messages to RubyLLM message format.
267
+ #
268
+ # Prepends system instructions as the first message if present.
269
+ #
270
+ # @param parameters [Hash] request parameters
271
+ # @return [Array<Hash>] RubyLLM-formatted messages
272
+ def build_ruby_llm_messages(parameters)
273
+ messages = []
274
+
275
+ # Add system instructions
276
+ if parameters[:instructions].present?
277
+ messages << ::RubyLLM::Message.new(
278
+ role: :system,
279
+ content: parameters[:instructions]
280
+ )
281
+ end
282
+
283
+ # Convert each message
284
+ (parameters[:messages] || []).each do |msg|
285
+ ruby_llm_msg = if msg[:tool_call_id]
286
+ ::RubyLLM::Message.new(
287
+ role: :tool,
288
+ content: msg[:content].to_s,
289
+ tool_call_id: msg[:tool_call_id]
290
+ )
291
+ else
292
+ attrs = {
293
+ role: msg[:role].to_sym,
294
+ content: extract_content_text(msg[:content])
295
+ }
296
+ attrs[:tool_calls] = convert_tool_calls_for_ruby_llm(msg[:tool_calls]) if msg[:tool_calls]
297
+ ::RubyLLM::Message.new(**attrs)
298
+ end
299
+
300
+ messages << ruby_llm_msg
301
+ end
302
+
303
+ messages
304
+ end
305
+
306
+ # Extracts plain text from various content formats.
307
+ #
308
+ # @param content [String, Array, Object] message content
309
+ # @return [String]
310
+ def extract_content_text(content)
311
+ case content
312
+ when String
313
+ content
314
+ when Array
315
+ content.select { |block| block.is_a?(Hash) && block[:type] == "text" }
316
+ .map { |block| block[:text] }
317
+ .join("\n")
318
+ else
319
+ content.to_s
320
+ end
321
+ end
322
+
323
+ # Converts ActiveAgent tool_calls to RubyLLM's ToolCall format.
324
+ #
325
+ # @param tool_calls [Array<Hash>] ActiveAgent format tool calls
326
+ # @return [Hash] RubyLLM format { id => ToolCall }
327
+ def convert_tool_calls_for_ruby_llm(tool_calls)
328
+ return nil unless tool_calls
329
+
330
+ tool_calls.each_with_object({}) do |tc, hash|
331
+ id = tc[:id]
332
+ call = ::RubyLLM::ToolCall.new(
333
+ id: id,
334
+ name: tc.dig(:function, :name) || tc[:name],
335
+ arguments: tc.dig(:function, :arguments) || tc[:input]&.to_json || "{}"
336
+ )
337
+ hash[id] = call
338
+ end
339
+ end
340
+
341
+ # Converts ActiveAgent tool definitions to RubyLLM ToolProxy objects.
342
+ #
343
+ # @param tools [Array<Hash>, nil] ActiveAgent tool definitions
344
+ # @return [Hash, nil] { "name" => ToolProxy }
345
+ def build_ruby_llm_tools(tools)
346
+ return nil unless tools&.any?
347
+
348
+ tools.each_with_object({}) do |tool, hash|
349
+ func = tool[:function] || tool
350
+ proxy = RubyLLM::ToolProxy.new(
351
+ name: func[:name],
352
+ description: func[:description] || "",
353
+ parameters: func[:parameters] || {}
354
+ )
355
+ hash[proxy.name] = proxy
356
+ end
357
+ end
358
+
359
+ # Converts a RubyLLM::Message response to a normalized hash.
360
+ #
361
+ # @param response [RubyLLM::Message] the response message
362
+ # @param model_id [String, nil] the model used
363
+ # @return [Hash] normalized response hash
364
+ def normalize_ruby_llm_response(response, model_id)
365
+ hash = {
366
+ role: "assistant",
367
+ content: response.content.to_s
368
+ }
369
+
370
+ # Handle tool calls
371
+ if response.tool_calls&.any?
372
+ hash[:tool_calls] = response.tool_calls.map do |id, tc|
373
+ {
374
+ id: id,
375
+ type: "function",
376
+ function: {
377
+ name: tc.name,
378
+ arguments: tc.arguments.is_a?(String) ? tc.arguments : tc.arguments.to_json
379
+ }
380
+ }
381
+ end
382
+ end
383
+
384
+ # Add stop_reason if available
385
+ if response.respond_to?(:stop_reason) && response.stop_reason
386
+ hash[:stop_reason] = response.stop_reason
387
+ elsif response.tool_calls&.any?
388
+ hash[:stop_reason] = "tool_use"
389
+ else
390
+ hash[:stop_reason] = "end_turn"
391
+ end
392
+
393
+ # Add usage info if available
394
+ if response.respond_to?(:input_tokens) && response.input_tokens
395
+ hash[:usage] = {
396
+ input_tokens: response.input_tokens,
397
+ output_tokens: response.output_tokens
398
+ }
399
+ end
400
+
401
+ hash[:model] = model_id if model_id
402
+
403
+ hash
404
+ end
405
+ end
406
+ end
407
+ end
@@ -10,6 +10,7 @@ module ActiveAgent
10
10
  class Railtie < Rails::Railtie # :nodoc:
11
11
  config.active_agent = ActiveSupport::OrderedOptions.new
12
12
  config.active_agent.preview_paths = []
13
+ config.active_agent.telemetry = ActiveSupport::OrderedOptions.new
13
14
  config.eager_load_namespaces << ActiveAgent
14
15
 
15
16
  initializer "active_agent.deprecator", before: :load_environment_config do |app|
@@ -40,6 +41,35 @@ module ActiveAgent
40
41
  ActiveAgent.configuration_load(Rails.root.join("config", "active_agent.yml"))
41
42
  # endregion configuration_load
42
43
 
44
+ # region telemetry_configuration
45
+ # Load telemetry configuration from activeagent.yml or Rails config
46
+ telemetry_config = ActiveAgent.configuration[:telemetry]
47
+ if telemetry_config.is_a?(Hash)
48
+ ActiveAgent::Telemetry.configure do |config|
49
+ config.load_from_hash(telemetry_config)
50
+ end
51
+ end
52
+
53
+ # Also support Rails config.active_agent.telemetry
54
+ if options.telemetry.present?
55
+ ActiveAgent::Telemetry.configure do |config|
56
+ config.enabled = options.telemetry[:enabled] if options.telemetry.key?(:enabled)
57
+ config.endpoint = options.telemetry[:endpoint] if options.telemetry.key?(:endpoint)
58
+ config.api_key = options.telemetry[:api_key] if options.telemetry.key?(:api_key)
59
+ config.sample_rate = options.telemetry[:sample_rate] if options.telemetry.key?(:sample_rate)
60
+ config.service_name = options.telemetry[:service_name] if options.telemetry.key?(:service_name)
61
+ end
62
+ end
63
+
64
+ # Apply instrumentation to ActiveAgent::Base when telemetry is enabled
65
+ if ActiveAgent::Telemetry.enabled?
66
+ ActiveSupport.on_load(:active_agent) do
67
+ include ActiveAgent::Telemetry::Instrumentation
68
+ instrument_telemetry!
69
+ end
70
+ end
71
+ # endregion telemetry_configuration
72
+
43
73
  ActiveSupport.on_load(:active_agent) do
44
74
  include AbstractController::UrlFor
45
75
  extend ::AbstractController::Railties::RoutesHelpers.with(app.routes, false)
@@ -55,7 +85,8 @@ module ActiveAgent
55
85
  self.generation_job = generation_job.constantize
56
86
  end
57
87
 
58
- options.each { |k, v| send(:"#{k}=", v) }
88
+ # Skip telemetry config - it's handled separately above
89
+ options.except(:telemetry).each { |k, v| send(:"#{k}=", v) }
59
90
  end
60
91
 
61
92
  ActiveSupport.on_load(:action_dispatch_integration_test) do
@@ -0,0 +1,213 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveAgent
4
+ module Telemetry
5
+ # Configuration for telemetry collection and reporting.
6
+ #
7
+ # Stores settings for endpoint, authentication, sampling, and batching.
8
+ # Configuration can be set programmatically or loaded from YAML.
9
+ #
10
+ # @example Programmatic configuration
11
+ # ActiveAgent::Telemetry.configure do |config|
12
+ # config.enabled = true
13
+ # config.endpoint = "https://api.activeagents.ai/v1/traces"
14
+ # config.api_key = "your-api-key"
15
+ # config.sample_rate = 1.0
16
+ # end
17
+ #
18
+ # @example YAML configuration (config/activeagent.yml)
19
+ # telemetry:
20
+ # enabled: true
21
+ # endpoint: https://api.activeagents.ai/v1/traces
22
+ # api_key: <%= ENV["ACTIVEAGENTS_API_KEY"] %>
23
+ # sample_rate: 1.0
24
+ # batch_size: 100
25
+ # flush_interval: 5
26
+ #
27
+ class Configuration
28
+ # @return [Boolean] Whether telemetry is enabled (default: false)
29
+ attr_accessor :enabled
30
+
31
+ # @return [String] The endpoint URL for sending traces
32
+ attr_accessor :endpoint
33
+
34
+ # @return [String] API key for authentication
35
+ attr_accessor :api_key
36
+
37
+ # @return [Float] Sampling rate from 0.0 to 1.0 (default: 1.0)
38
+ attr_accessor :sample_rate
39
+
40
+ # @return [Integer] Number of traces to batch before sending (default: 100)
41
+ attr_accessor :batch_size
42
+
43
+ # @return [Integer] Seconds between automatic flushes (default: 5)
44
+ attr_accessor :flush_interval
45
+
46
+ # @return [Integer] HTTP timeout in seconds (default: 10)
47
+ attr_accessor :timeout
48
+
49
+ # @return [Boolean] Whether to capture request/response bodies (default: false)
50
+ attr_accessor :capture_bodies
51
+
52
+ # @return [Array<String>] Attributes to redact from traces
53
+ attr_accessor :redact_attributes
54
+
55
+ # @return [String] Service name for trace attribution
56
+ attr_accessor :service_name
57
+
58
+ # @return [String] Environment name (development, staging, production)
59
+ attr_accessor :environment
60
+
61
+ # @return [Hash] Additional resource attributes to include in all traces
62
+ attr_accessor :resource_attributes
63
+
64
+ # @return [Logger] Logger for telemetry operations
65
+ attr_accessor :logger
66
+
67
+ # @return [Boolean] Whether to store traces locally in the app's database
68
+ attr_accessor :local_storage
69
+
70
+ # Default ActiveAgents.ai endpoint for hosted observability.
71
+ DEFAULT_ENDPOINT = "https://api.activeagents.ai/v1/traces"
72
+
73
+ # Local dashboard endpoint path (relative to app root)
74
+ LOCAL_ENDPOINT_PATH = "/active_agent/api/traces"
75
+
76
+ def initialize
77
+ @enabled = false
78
+ @endpoint = DEFAULT_ENDPOINT
79
+ @api_key = nil
80
+ @sample_rate = 1.0
81
+ @batch_size = 100
82
+ @flush_interval = 5
83
+ @timeout = 10
84
+ @capture_bodies = false
85
+ @redact_attributes = %w[password secret token key credential api_key]
86
+ @service_name = nil
87
+ @environment = Rails.env if defined?(Rails)
88
+ @resource_attributes = {}
89
+ @logger = nil
90
+ @local_storage = false
91
+ end
92
+
93
+ # Returns whether telemetry collection is enabled.
94
+ #
95
+ # @return [Boolean]
96
+ def enabled?
97
+ @enabled == true
98
+ end
99
+
100
+ # Returns whether telemetry is properly configured.
101
+ #
102
+ # Checks that endpoint and api_key are present, or local_storage is enabled.
103
+ #
104
+ # @return [Boolean]
105
+ def configured?
106
+ local_storage? || (endpoint.present? && api_key.present?)
107
+ end
108
+
109
+ # Returns whether local storage mode is enabled.
110
+ #
111
+ # @return [Boolean]
112
+ def local_storage?
113
+ @local_storage == true
114
+ end
115
+
116
+ # Returns the resolved endpoint for trace reporting.
117
+ #
118
+ # Uses local endpoint when local_storage is enabled.
119
+ #
120
+ # @return [String]
121
+ def resolved_endpoint
122
+ if local_storage?
123
+ LOCAL_ENDPOINT_PATH
124
+ else
125
+ endpoint
126
+ end
127
+ end
128
+
129
+ # Returns whether a trace should be sampled.
130
+ #
131
+ # Uses sample_rate to determine if trace should be collected.
132
+ #
133
+ # @return [Boolean]
134
+ def should_sample?
135
+ return true if sample_rate >= 1.0
136
+ return false if sample_rate <= 0.0
137
+
138
+ rand < sample_rate
139
+ end
140
+
141
+ # Resolves the service name for traces.
142
+ #
143
+ # Falls back to Rails application name or "activeagent".
144
+ #
145
+ # @return [String]
146
+ def resolved_service_name
147
+ @service_name || rails_app_name || "activeagent"
148
+ end
149
+
150
+ # Returns the logger for telemetry operations.
151
+ #
152
+ # Falls back to Rails.logger or a null logger.
153
+ #
154
+ # @return [Logger]
155
+ def resolved_logger
156
+ @logger || (defined?(Rails) && Rails.logger) || Logger.new(File::NULL)
157
+ end
158
+
159
+ # Loads configuration from a hash (typically from YAML).
160
+ #
161
+ # @param hash [Hash] Configuration hash
162
+ # @return [self]
163
+ def load_from_hash(hash)
164
+ hash = hash.with_indifferent_access if hash.respond_to?(:with_indifferent_access)
165
+
166
+ @enabled = hash[:enabled] if hash.key?(:enabled)
167
+ @endpoint = hash[:endpoint] if hash.key?(:endpoint)
168
+ @api_key = hash[:api_key] if hash.key?(:api_key)
169
+ @sample_rate = hash[:sample_rate].to_f if hash.key?(:sample_rate)
170
+ @batch_size = hash[:batch_size].to_i if hash.key?(:batch_size)
171
+ @flush_interval = hash[:flush_interval].to_i if hash.key?(:flush_interval)
172
+ @timeout = hash[:timeout].to_i if hash.key?(:timeout)
173
+ @capture_bodies = hash[:capture_bodies] if hash.key?(:capture_bodies)
174
+ @redact_attributes = hash[:redact_attributes] if hash.key?(:redact_attributes)
175
+ @service_name = hash[:service_name] if hash.key?(:service_name)
176
+ @environment = hash[:environment] if hash.key?(:environment)
177
+ @resource_attributes = hash[:resource_attributes] if hash.key?(:resource_attributes)
178
+ @local_storage = hash[:local_storage] if hash.key?(:local_storage)
179
+
180
+ self
181
+ end
182
+
183
+ # Returns configuration as a hash for serialization.
184
+ #
185
+ # @return [Hash]
186
+ def to_h
187
+ {
188
+ enabled: enabled,
189
+ endpoint: endpoint,
190
+ api_key: api_key ? "[REDACTED]" : nil,
191
+ sample_rate: sample_rate,
192
+ batch_size: batch_size,
193
+ flush_interval: flush_interval,
194
+ timeout: timeout,
195
+ capture_bodies: capture_bodies,
196
+ service_name: resolved_service_name,
197
+ environment: environment,
198
+ local_storage: local_storage
199
+ }
200
+ end
201
+
202
+ private
203
+
204
+ def rails_app_name
205
+ return nil unless defined?(Rails) && Rails.application
206
+
207
+ Rails.application.class.module_parent_name.underscore
208
+ rescue StandardError
209
+ nil
210
+ end
211
+ end
212
+ end
213
+ end