activeagent 1.0.3 → 1.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 +4 -4
- data/CHANGELOG.md +71 -0
- data/README.md +26 -0
- data/lib/active_agent/base.rb +4 -0
- data/lib/active_agent/dashboard/app/controllers/active_agent/dashboard/api/traces_controller.rb +27 -6
- data/lib/active_agent/dashboard/app/controllers/active_agent/dashboard/application_controller.rb +11 -1
- data/lib/active_agent/dashboard/app/controllers/active_agent/dashboard/dashboard_controller.rb +15 -12
- data/lib/active_agent/dashboard/app/controllers/active_agent/dashboard/traces_controller.rb +27 -7
- data/lib/active_agent/dashboard/app/jobs/active_agent/process_telemetry_traces_job.rb +9 -0
- data/lib/active_agent/dashboard/app/models/active_agent/telemetry_trace.rb +18 -2
- data/lib/active_agent/dashboard/app/views/active_agent/dashboard/traces/_trace_detail.html.erb +15 -3
- data/lib/active_agent/dashboard/app/views/active_agent/dashboard/traces/metrics.html.erb +3 -1
- data/lib/active_agent/dashboard/app/views/layouts/active_agent/dashboard/application.html.erb +4 -4
- data/lib/active_agent/dashboard/config/routes.rb +5 -64
- data/lib/active_agent/dashboard/engine.rb +19 -15
- data/lib/active_agent/dashboard.rb +13 -3
- data/lib/active_agent/model_capabilities.rb +89 -0
- data/lib/active_agent/providers/_base_provider.rb +23 -2
- data/lib/active_agent/providers/concerns/exception_handler.rb +12 -1
- data/lib/active_agent/providers/errors.rb +140 -0
- data/lib/active_agent/providers/ollama/chat/transforms.rb +9 -3
- data/lib/active_agent/railtie.rb +5 -0
- data/lib/active_agent/telemetry/configuration.rb +112 -172
- data/lib/active_agent/telemetry/instrumentation.rb +137 -14
- data/lib/active_agent/telemetry/reporter.rb +12 -165
- data/lib/active_agent/telemetry/span.rb +18 -245
- data/lib/active_agent/telemetry/tracer.rb +67 -70
- data/lib/active_agent/telemetry.rb +1 -2
- data/lib/active_agent/version.rb +1 -1
- data/lib/active_agent.rb +5 -0
- data/lib/generators/active_agent/dashboard/install_generator.rb +30 -2
- data/lib/generators/active_agent/dashboard/templates/active_agent_dashboard.rb.erb +41 -4
- data/lib/generators/active_agent/dashboard/templates/create_active_agent_telemetry_traces.rb.erb +20 -4
- metadata +17 -11
- data/lib/generators/active_agent/dashboard/install/install_generator.rb +0 -96
- data/lib/generators/active_agent/dashboard/install/templates/initializer.rb +0 -89
- data/lib/generators/active_agent/dashboard/install/templates/migrations/create_active_agent_agent_runs.rb +0 -42
- data/lib/generators/active_agent/dashboard/install/templates/migrations/create_active_agent_agent_templates.rb +0 -38
- data/lib/generators/active_agent/dashboard/install/templates/migrations/create_active_agent_agent_versions.rb +0 -22
- data/lib/generators/active_agent/dashboard/install/templates/migrations/create_active_agent_agents.rb +0 -53
- data/lib/generators/active_agent/dashboard/install/templates/migrations/create_active_agent_sandbox_runs.rb +0 -28
- data/lib/generators/active_agent/dashboard/install/templates/migrations/create_active_agent_sandbox_sessions.rb +0 -43
- data/lib/generators/active_agent/dashboard/install/templates/migrations/create_active_agent_session_recordings.rb +0 -44
- data/lib/generators/active_agent/dashboard/install/templates/migrations/create_active_agent_telemetry_traces.rb +0 -56
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveAgent
|
|
4
|
+
# Per-model capability quirks, applied before a request reaches the
|
|
5
|
+
# provider. Vendors ship models that reject otherwise-standard sampling
|
|
6
|
+
# parameters (thinking-first models steered by prompting/effort instead)
|
|
7
|
+
# with an API 400 — this registry strips those parameters up front so an
|
|
8
|
+
# agent configured with a shared temperature keeps working across model
|
|
9
|
+
# switches.
|
|
10
|
+
#
|
|
11
|
+
# The built-in rules cover the known families; apps can extend the
|
|
12
|
+
# registry for new or self-hosted models:
|
|
13
|
+
#
|
|
14
|
+
# @example Register a custom rule
|
|
15
|
+
# ActiveAgent::ModelCapabilities.register(/\Amy-reasoning-model/, unsupported: [:temperature, :top_p])
|
|
16
|
+
#
|
|
17
|
+
# @example Disable sanitization entirely
|
|
18
|
+
# ActiveAgent::ModelCapabilities.enabled = false
|
|
19
|
+
module ModelCapabilities
|
|
20
|
+
SAMPLING_PARAMS = [ :temperature, :top_p ].freeze
|
|
21
|
+
|
|
22
|
+
# Model families that reject sampling parameters with a 400:
|
|
23
|
+
# - Anthropic thinking-first models (Opus 4.7+, Opus 5, Sonnet 5,
|
|
24
|
+
# Fable 5 / Mythos 5)
|
|
25
|
+
# - OpenAI reasoning models (o-series, GPT-5 family)
|
|
26
|
+
BUILTIN_RULES = [
|
|
27
|
+
{ pattern: /\Aclaude-(opus-5|opus-4-[78]|sonnet-5|fable-5|mythos-5)/, unsupported: SAMPLING_PARAMS },
|
|
28
|
+
{ pattern: /\A(o1|o3|o4)(-|$)/, unsupported: SAMPLING_PARAMS },
|
|
29
|
+
{ pattern: /\Agpt-5/, unsupported: SAMPLING_PARAMS }
|
|
30
|
+
].freeze
|
|
31
|
+
|
|
32
|
+
class << self
|
|
33
|
+
# Master switch; on by default. Set false to send parameters through
|
|
34
|
+
# untouched (the vendor then enforces its own rules).
|
|
35
|
+
attr_writer :enabled
|
|
36
|
+
|
|
37
|
+
def enabled
|
|
38
|
+
return @enabled unless @enabled.nil?
|
|
39
|
+
|
|
40
|
+
true
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Registers an app-defined capability rule ahead of the built-ins.
|
|
44
|
+
#
|
|
45
|
+
# @param pattern [Regexp] matched against the model name
|
|
46
|
+
# @param unsupported [Array<Symbol>] parameter keys the model rejects
|
|
47
|
+
def register(pattern, unsupported:)
|
|
48
|
+
custom_rules << { pattern: pattern, unsupported: unsupported.map(&:to_sym) }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def custom_rules
|
|
52
|
+
@custom_rules ||= []
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def reset!
|
|
56
|
+
@custom_rules = []
|
|
57
|
+
@enabled = nil
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# @return [Array<Symbol>] parameter keys the model rejects
|
|
61
|
+
def unsupported_params(model)
|
|
62
|
+
return [] if model.nil?
|
|
63
|
+
|
|
64
|
+
(custom_rules + BUILTIN_RULES).each do |rule|
|
|
65
|
+
return rule[:unsupported] if model.to_s.match?(rule[:pattern])
|
|
66
|
+
end
|
|
67
|
+
[]
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def sampling_supported?(model)
|
|
71
|
+
(unsupported_params(model) & SAMPLING_PARAMS).empty?
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Strips parameters the model rejects, in place. Returns the removed
|
|
75
|
+
# keys (empty when nothing applied).
|
|
76
|
+
#
|
|
77
|
+
# @param parameters [Hash] prepared prompt parameters (must carry :model)
|
|
78
|
+
# @return [Array<Symbol>] removed parameter keys
|
|
79
|
+
def sanitize!(parameters)
|
|
80
|
+
return [] unless enabled
|
|
81
|
+
return [] unless parameters.is_a?(Hash)
|
|
82
|
+
|
|
83
|
+
removed = unsupported_params(parameters[:model]).select { |key| parameters.key?(key) }
|
|
84
|
+
removed.each { |key| parameters.delete(key) }
|
|
85
|
+
removed
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -55,7 +55,13 @@ module ActiveAgent
|
|
|
55
55
|
:request, :message_stack, # Runtime
|
|
56
56
|
:stream_broadcaster, :streaming, # Callback (Streams)
|
|
57
57
|
:tools_function, # Callback (Tools)
|
|
58
|
-
:usage_stack
|
|
58
|
+
:usage_stack, # Usage Tracking
|
|
59
|
+
:max_tool_turns, :tool_turns # Tool-loop safety
|
|
60
|
+
|
|
61
|
+
# Upper bound on tool-calling round-trips within one generation. A
|
|
62
|
+
# model that keeps emitting tool calls otherwise recurses until the
|
|
63
|
+
# provider stops it — override per agent/prompt with max_tool_turns:.
|
|
64
|
+
DEFAULT_MAX_TOOL_TURNS = 25
|
|
59
65
|
|
|
60
66
|
# @return [String] e.g., "Anthropic", "OpenAI"
|
|
61
67
|
def self.service_name
|
|
@@ -106,6 +112,8 @@ module ActiveAgent
|
|
|
106
112
|
self.stream_broadcaster = kwargs.delete(:stream_broadcaster)
|
|
107
113
|
self.streaming = false
|
|
108
114
|
self.tools_function = kwargs.delete(:tools_function)
|
|
115
|
+
self.max_tool_turns = kwargs.delete(:max_tool_turns) || DEFAULT_MAX_TOOL_TURNS
|
|
116
|
+
self.tool_turns = 0
|
|
109
117
|
self.options = options_klass.new(kwargs.extract!(*options_klass.keys))
|
|
110
118
|
self.context = kwargs
|
|
111
119
|
self.message_stack = []
|
|
@@ -344,7 +352,7 @@ module ActiveAgent
|
|
|
344
352
|
message_stack.push(*api_messages)
|
|
345
353
|
end
|
|
346
354
|
|
|
347
|
-
if (tool_calls = process_prompt_finished_extract_function_calls)&.any?
|
|
355
|
+
if (tool_calls = process_prompt_finished_extract_function_calls)&.any? && tool_turn_allowed?
|
|
348
356
|
process_function_calls(tool_calls)
|
|
349
357
|
resolve_prompt
|
|
350
358
|
else
|
|
@@ -373,6 +381,19 @@ module ActiveAgent
|
|
|
373
381
|
end
|
|
374
382
|
end
|
|
375
383
|
|
|
384
|
+
# Counts a tool round-trip against the per-generation cap. When the
|
|
385
|
+
# cap is hit the loop finishes cleanly with the messages gathered so
|
|
386
|
+
# far (a partial result) instead of recursing indefinitely.
|
|
387
|
+
#
|
|
388
|
+
# @return [Boolean] whether another tool round-trip may run
|
|
389
|
+
def tool_turn_allowed?
|
|
390
|
+
self.tool_turns += 1
|
|
391
|
+
return true if max_tool_turns.nil? || tool_turns <= max_tool_turns
|
|
392
|
+
|
|
393
|
+
instrument("tool_turns_exceeded.active_agent", limit: max_tool_turns)
|
|
394
|
+
false
|
|
395
|
+
end
|
|
396
|
+
|
|
376
397
|
# @abstract
|
|
377
398
|
# @param api_response [Object]
|
|
378
399
|
# @return [Array<Message>, nil]
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "../errors"
|
|
4
|
+
|
|
3
5
|
module ActiveAgent
|
|
4
6
|
module Providers
|
|
5
7
|
# Provides exception handling for provider operations.
|
|
@@ -54,7 +56,16 @@ module ActiveAgent
|
|
|
54
56
|
def with_exception_handling(&block)
|
|
55
57
|
yield
|
|
56
58
|
rescue => exception
|
|
57
|
-
|
|
59
|
+
# Vendor API failures are normalized into the framework taxonomy
|
|
60
|
+
# (Errors::RateLimited, Errors::ContextLengthExceeded, ...) so
|
|
61
|
+
# rescue_from policy is portable across providers; the original
|
|
62
|
+
# exception is preserved as #cause. Anything unrecognizable —
|
|
63
|
+
# including ordinary Ruby errors — passes through untouched.
|
|
64
|
+
exception = Errors::Taxonomy.normalize(
|
|
65
|
+
exception,
|
|
66
|
+
provider_tag: (tag_name if respond_to?(:tag_name))
|
|
67
|
+
)
|
|
68
|
+
rescue_with_handler(exception) || raise(exception)
|
|
58
69
|
nil # Discard handler return value to prevent polluting raw_response
|
|
59
70
|
end
|
|
60
71
|
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveAgent
|
|
4
|
+
module Providers
|
|
5
|
+
# Typed provider failures, normalized across vendor SDKs.
|
|
6
|
+
#
|
|
7
|
+
# Every vendor raises its own exception classes for the same underlying
|
|
8
|
+
# conditions (rate limits, context overflows, content filters, outages),
|
|
9
|
+
# which makes retry/backoff/fallback policy impossible to express
|
|
10
|
+
# portably. The taxonomy classifies vendor errors into a small set of
|
|
11
|
+
# framework types — the original exception is preserved as +#cause+, so
|
|
12
|
+
# nothing is lost.
|
|
13
|
+
#
|
|
14
|
+
# @example Portable retry policy
|
|
15
|
+
# rescue_from ActiveAgent::Providers::Errors::RateLimited do |error|
|
|
16
|
+
# retry_job wait: 30.seconds
|
|
17
|
+
# end
|
|
18
|
+
#
|
|
19
|
+
# @example Fallback on outage
|
|
20
|
+
# rescue_from ActiveAgent::Providers::Errors::ServiceUnavailable do |error|
|
|
21
|
+
# FallbackAgent.with(params).ask.generate_later
|
|
22
|
+
# end
|
|
23
|
+
module Errors
|
|
24
|
+
# Base class for normalized provider failures.
|
|
25
|
+
class ProviderError < StandardError
|
|
26
|
+
# @return [Integer, nil] HTTP status from the vendor error, when known
|
|
27
|
+
attr_reader :status
|
|
28
|
+
|
|
29
|
+
# @return [String, nil] provider tag (e.g. "Anthropic", "OpenAI::Chat")
|
|
30
|
+
attr_reader :provider_tag
|
|
31
|
+
|
|
32
|
+
def initialize(message = nil, status: nil, provider_tag: nil)
|
|
33
|
+
super(message)
|
|
34
|
+
@status = status
|
|
35
|
+
@provider_tag = provider_tag
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# 429s / vendor rate & quota limits. Retryable with backoff.
|
|
40
|
+
class RateLimited < ProviderError; end
|
|
41
|
+
|
|
42
|
+
# The prompt exceeded the model's context window. Not retryable
|
|
43
|
+
# without shrinking the input.
|
|
44
|
+
class ContextLengthExceeded < ProviderError; end
|
|
45
|
+
|
|
46
|
+
# Invalid, expired, or unauthorized credentials (401/403).
|
|
47
|
+
class AuthenticationFailed < ProviderError; end
|
|
48
|
+
|
|
49
|
+
# The vendor's safety layer refused the request or response.
|
|
50
|
+
class ContentFiltered < ProviderError; end
|
|
51
|
+
|
|
52
|
+
# Vendor-side failure or overload (5xx, timeouts, connection drops).
|
|
53
|
+
# Retryable; a natural trigger for provider fallback.
|
|
54
|
+
class ServiceUnavailable < ProviderError; end
|
|
55
|
+
|
|
56
|
+
# Malformed or unsupported request the vendor rejected (400/422)
|
|
57
|
+
# that doesn't classify more specifically.
|
|
58
|
+
class InvalidRequest < ProviderError; end
|
|
59
|
+
|
|
60
|
+
# Classifies vendor SDK exceptions into the taxonomy. Unrecognizable
|
|
61
|
+
# exceptions (including ordinary Ruby errors) pass through untouched —
|
|
62
|
+
# only errors that look like vendor API failures are normalized.
|
|
63
|
+
module Taxonomy
|
|
64
|
+
# Vendor SDK class names (demodulized) → taxonomy class. Covers the
|
|
65
|
+
# official anthropic/openai gems and SDKs following their naming.
|
|
66
|
+
NAME_MAP = {
|
|
67
|
+
"RateLimitError" => RateLimited,
|
|
68
|
+
"AuthenticationError" => AuthenticationFailed,
|
|
69
|
+
"PermissionDeniedError" => AuthenticationFailed,
|
|
70
|
+
"ContentFilterError" => ContentFiltered,
|
|
71
|
+
"InternalServerError" => ServiceUnavailable,
|
|
72
|
+
"APIConnectionError" => ServiceUnavailable,
|
|
73
|
+
"APIConnectionTimeoutError" => ServiceUnavailable,
|
|
74
|
+
"APITimeoutError" => ServiceUnavailable,
|
|
75
|
+
"OverloadedError" => ServiceUnavailable,
|
|
76
|
+
"ServiceUnavailableError" => ServiceUnavailable,
|
|
77
|
+
"BadRequestError" => InvalidRequest,
|
|
78
|
+
"UnprocessableEntityError" => InvalidRequest
|
|
79
|
+
}.freeze
|
|
80
|
+
|
|
81
|
+
STATUS_MAP = {
|
|
82
|
+
400 => InvalidRequest,
|
|
83
|
+
401 => AuthenticationFailed,
|
|
84
|
+
403 => AuthenticationFailed,
|
|
85
|
+
408 => ServiceUnavailable,
|
|
86
|
+
422 => InvalidRequest,
|
|
87
|
+
429 => RateLimited,
|
|
88
|
+
529 => ServiceUnavailable # Anthropic "overloaded"
|
|
89
|
+
}.freeze
|
|
90
|
+
|
|
91
|
+
CONTEXT_LENGTH_PATTERN = /context length|context_length|maximum context|context window|too many tokens|prompt is too long|input (?:is )?too long/i
|
|
92
|
+
CONTENT_FILTER_PATTERN = /content (?:filter|policy|management)|filtered due to|blocked by|safety (?:system|filter)/i
|
|
93
|
+
|
|
94
|
+
class << self
|
|
95
|
+
# @param exception [Exception]
|
|
96
|
+
# @param provider_tag [String, nil]
|
|
97
|
+
# @return [Exception] a taxonomy error, or the original exception
|
|
98
|
+
# when it doesn't classify
|
|
99
|
+
def normalize(exception, provider_tag: nil)
|
|
100
|
+
return exception if exception.is_a?(ProviderError)
|
|
101
|
+
|
|
102
|
+
klass = classify(exception)
|
|
103
|
+
return exception unless klass
|
|
104
|
+
|
|
105
|
+
klass.new(exception.message, status: status_of(exception), provider_tag: provider_tag)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# @return [Class, nil]
|
|
109
|
+
def classify(exception)
|
|
110
|
+
name = exception.class.name.to_s.demodulize
|
|
111
|
+
status = status_of(exception)
|
|
112
|
+
api_error = NAME_MAP.key?(name) || !status.nil?
|
|
113
|
+
return nil unless api_error
|
|
114
|
+
|
|
115
|
+
message = exception.message.to_s
|
|
116
|
+
return ContextLengthExceeded if CONTEXT_LENGTH_PATTERN.match?(message)
|
|
117
|
+
return ContentFiltered if CONTENT_FILTER_PATTERN.match?(message)
|
|
118
|
+
|
|
119
|
+
NAME_MAP[name] || STATUS_MAP[status] || (status && status >= 500 ? ServiceUnavailable : nil)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# @return [Integer, nil]
|
|
123
|
+
def status_of(exception)
|
|
124
|
+
[ :status, :status_code, :http_status, :code ].each do |reader|
|
|
125
|
+
next unless exception.respond_to?(reader)
|
|
126
|
+
|
|
127
|
+
value = begin
|
|
128
|
+
exception.public_send(reader)
|
|
129
|
+
rescue StandardError
|
|
130
|
+
nil
|
|
131
|
+
end
|
|
132
|
+
return value if value.is_a?(Integer)
|
|
133
|
+
end
|
|
134
|
+
nil
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
@@ -55,12 +55,18 @@ module ActiveAgent
|
|
|
55
55
|
OpenAI::Chat::Transforms.normalize_messages(messages)
|
|
56
56
|
end
|
|
57
57
|
|
|
58
|
-
# Normalizes instructions using OpenAI transforms
|
|
58
|
+
# Normalizes instructions using OpenAI transforms, then remaps the
|
|
59
|
+
# role: OpenAI's transforms emit the "developer" role, but the chat
|
|
60
|
+
# templates of Ollama-served models (qwen, llama, gemma, …) only
|
|
61
|
+
# know "system" — a "developer" message is silently dropped, so the
|
|
62
|
+
# model never sees its instructions.
|
|
59
63
|
#
|
|
60
64
|
# @param instructions [Array<String>, String]
|
|
61
|
-
# @return [Array<
|
|
65
|
+
# @return [Array<Hash>] system messages
|
|
62
66
|
def normalize_instructions(instructions)
|
|
63
|
-
OpenAI::Chat::Transforms.normalize_instructions(instructions)
|
|
67
|
+
OpenAI::Chat::Transforms.normalize_instructions(instructions).map do |message|
|
|
68
|
+
message.is_a?(Hash) ? message.merge(role: "system") : message
|
|
69
|
+
end
|
|
64
70
|
end
|
|
65
71
|
|
|
66
72
|
# Cleans up serialized request for API submission
|
data/lib/active_agent/railtie.rb
CHANGED
|
@@ -67,6 +67,11 @@ module ActiveAgent
|
|
|
67
67
|
include ActiveAgent::Telemetry::Instrumentation
|
|
68
68
|
instrument_telemetry!
|
|
69
69
|
end
|
|
70
|
+
|
|
71
|
+
# Flush remaining traces when the process exits — without this,
|
|
72
|
+
# short-lived processes (rails runner, jobs, deploys rolling a
|
|
73
|
+
# server) drop whatever was buffered since the last interval flush.
|
|
74
|
+
at_exit { ActiveAgent::Telemetry.shutdown }
|
|
70
75
|
end
|
|
71
76
|
# endregion telemetry_configuration
|
|
72
77
|
|
|
@@ -2,212 +2,152 @@
|
|
|
2
2
|
|
|
3
3
|
module ActiveAgent
|
|
4
4
|
module Telemetry
|
|
5
|
-
#
|
|
5
|
+
# The framework's telemetry configuration, now provided by the
|
|
6
|
+
# activeagents-telemetry gem. This subclass keeps the framework's
|
|
7
|
+
# historical behavior on top of the shared core:
|
|
6
8
|
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
+
# * telemetry is opt-in (`enabled` defaults to false)
|
|
10
|
+
# * `local_storage: true` persists traces through the dashboard's trace
|
|
11
|
+
# model instead of HTTP
|
|
9
12
|
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
# config.api_key = "your-api-key"
|
|
15
|
-
# config.sample_rate = 1.0
|
|
16
|
-
# end
|
|
13
|
+
# Every other option — endpoint, api_key, sample_rate, batch_size,
|
|
14
|
+
# flush_interval, capture_bodies, redact_attributes, service_name,
|
|
15
|
+
# environment, resource_attributes — lives on the shared class, so
|
|
16
|
+
# existing config/active_agent.yml files keep working unchanged.
|
|
17
17
|
#
|
|
18
|
-
# @
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
|
18
|
+
# @see ActiveAgents::Telemetry::Configuration
|
|
19
|
+
class Configuration < ActiveAgents::Telemetry::Configuration
|
|
20
|
+
# Fallback ingest path when the dashboard engine's mount point can't
|
|
21
|
+
# be resolved from the host's routes (e.g. engine not mounted).
|
|
22
|
+
LOCAL_ENDPOINT_PATH = "/activeagents/api/traces"
|
|
57
23
|
|
|
58
|
-
# @return [
|
|
59
|
-
|
|
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"
|
|
24
|
+
# @return [Boolean] Whether to store traces in the app's own database
|
|
25
|
+
attr_reader :local_storage
|
|
75
26
|
|
|
76
27
|
def initialize
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
|
28
|
+
super
|
|
29
|
+
# The framework predates the shared gem and has always been opt-in;
|
|
30
|
+
# adapters treat the presence of an api_key as the switch instead.
|
|
31
|
+
self.enabled = false
|
|
90
32
|
@local_storage = false
|
|
91
33
|
end
|
|
92
34
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
# @return [Boolean]
|
|
96
|
-
def enabled?
|
|
97
|
-
@enabled == true
|
|
35
|
+
def local_storage=(value)
|
|
36
|
+
@local_storage = value == true
|
|
98
37
|
end
|
|
99
38
|
|
|
100
|
-
|
|
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?)
|
|
39
|
+
def local_storage?
|
|
40
|
+
@local_storage
|
|
107
41
|
end
|
|
108
42
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
43
|
+
def local_store?
|
|
44
|
+
local_storage? || super
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# When local storage is on, traces persist through the dashboard's
|
|
48
|
+
# trace model rather than leaving the process.
|
|
49
|
+
def local_store
|
|
50
|
+
super || (dashboard_store if local_storage?)
|
|
114
51
|
end
|
|
115
52
|
|
|
116
53
|
# Returns the resolved endpoint for trace reporting.
|
|
117
|
-
#
|
|
118
|
-
# Uses local endpoint when local_storage is enabled.
|
|
119
|
-
#
|
|
120
|
-
# @return [String]
|
|
121
54
|
def resolved_endpoint
|
|
122
|
-
|
|
123
|
-
LOCAL_ENDPOINT_PATH
|
|
124
|
-
else
|
|
125
|
-
endpoint
|
|
126
|
-
end
|
|
55
|
+
local_storage? ? local_endpoint_path : endpoint
|
|
127
56
|
end
|
|
128
57
|
|
|
129
|
-
#
|
|
130
|
-
#
|
|
131
|
-
#
|
|
132
|
-
#
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
return false if sample_rate <= 0.0
|
|
137
|
-
|
|
138
|
-
rand < sample_rate
|
|
58
|
+
# The dashboard engine's ingest path, derived from wherever the host
|
|
59
|
+
# app actually mounted it — "/activeagents", "/observability", or "/"
|
|
60
|
+
# on a dedicated subdomain all work. Falls back to
|
|
61
|
+
# LOCAL_ENDPOINT_PATH when the engine isn't mounted.
|
|
62
|
+
def local_endpoint_path
|
|
63
|
+
mount = dashboard_mount_path
|
|
64
|
+
mount ? "#{mount}/api/traces" : LOCAL_ENDPOINT_PATH
|
|
139
65
|
end
|
|
140
66
|
|
|
141
|
-
#
|
|
142
|
-
#
|
|
143
|
-
# Falls back to Rails application name or "activeagent".
|
|
144
|
-
#
|
|
145
|
-
# @return [String]
|
|
67
|
+
# The framework's historical fallback is "activeagent", not the shared
|
|
68
|
+
# core's generic "ruby".
|
|
146
69
|
def resolved_service_name
|
|
147
|
-
|
|
70
|
+
value = super
|
|
71
|
+
value == "ruby" ? "activeagent" : value
|
|
148
72
|
end
|
|
149
73
|
|
|
150
|
-
|
|
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)
|
|
74
|
+
def to_h
|
|
75
|
+
super.merge(local_storage: local_storage)
|
|
157
76
|
end
|
|
158
77
|
|
|
159
|
-
|
|
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
|
|
78
|
+
private
|
|
182
79
|
|
|
183
|
-
#
|
|
184
|
-
#
|
|
185
|
-
#
|
|
186
|
-
def
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
80
|
+
# Persists a trace through the dashboard's trace model, honoring
|
|
81
|
+
# ActiveAgent::Dashboard.trace_model_class overrides. Idempotent on
|
|
82
|
+
# trace_id, as HTTP ingest is.
|
|
83
|
+
def dashboard_store
|
|
84
|
+
@dashboard_store ||= lambda do |trace, sdk|
|
|
85
|
+
model = local_trace_model
|
|
86
|
+
unless model
|
|
87
|
+
resolved_logger.error(
|
|
88
|
+
"[ActiveAgent::Telemetry] local_storage is enabled but no trace model is available — " \
|
|
89
|
+
"run `rails generate active_agent:dashboard:install` first"
|
|
90
|
+
)
|
|
91
|
+
next
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
next if model.exists?(trace_id: trace["trace_id"])
|
|
95
|
+
|
|
96
|
+
model.create_from_payload(trace, sdk)
|
|
97
|
+
end
|
|
200
98
|
end
|
|
201
99
|
|
|
202
|
-
|
|
100
|
+
def local_trace_model
|
|
101
|
+
if defined?(ActiveAgent::Dashboard) && ActiveAgent::Dashboard.respond_to?(:trace_model)
|
|
102
|
+
ActiveAgent::Dashboard.trace_model
|
|
103
|
+
elsif defined?(ActiveAgent::TelemetryTrace)
|
|
104
|
+
ActiveAgent::TelemetryTrace
|
|
105
|
+
end
|
|
106
|
+
rescue NameError
|
|
107
|
+
nil
|
|
108
|
+
end
|
|
203
109
|
|
|
204
|
-
|
|
205
|
-
|
|
110
|
+
# The engine's mount point in the host app, found by locating the
|
|
111
|
+
# mounted engine in the host's route set. Works regardless of the
|
|
112
|
+
# helper name, so `mount ... => "/", as: :something_else` and
|
|
113
|
+
# constraint-wrapped mounts resolve correctly. Returns nil when the
|
|
114
|
+
# engine isn't mounted or no Rails app is booted; "" for a root mount.
|
|
115
|
+
def dashboard_mount_path
|
|
116
|
+
return nil unless defined?(::Rails) && ::Rails.respond_to?(:application) && ::Rails.application
|
|
206
117
|
|
|
207
|
-
Rails.application.
|
|
118
|
+
mount_path_in(::Rails.application.routes)
|
|
208
119
|
rescue StandardError
|
|
209
120
|
nil
|
|
210
121
|
end
|
|
122
|
+
|
|
123
|
+
# The engine's mount path within a given route set, or nil when it
|
|
124
|
+
# isn't mounted there. Separate from #dashboard_mount_path so it can be
|
|
125
|
+
# exercised against a route set directly.
|
|
126
|
+
public def mount_path_in(route_set)
|
|
127
|
+
return nil unless defined?(::ActiveAgent::Dashboard::Engine)
|
|
128
|
+
|
|
129
|
+
route = route_set.routes.find do |candidate|
|
|
130
|
+
mounted_engine(candidate.app) == ::ActiveAgent::Dashboard::Engine
|
|
131
|
+
end
|
|
132
|
+
return nil unless route
|
|
133
|
+
|
|
134
|
+
# "/activeagents(.:format)" -> "/activeagents"; a root mount -> "".
|
|
135
|
+
route.path.spec.to_s.sub(/\(\.:format\)\z/, "").chomp("/")
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Unwraps the constraint layers Rails wraps a mounted engine in,
|
|
139
|
+
# returning the engine class (or nil for ordinary routes). Stops at the
|
|
140
|
+
# engine class itself — engines also respond to #app (their route set),
|
|
141
|
+
# so unwrapping blindly walks straight past them.
|
|
142
|
+
def mounted_engine(app)
|
|
143
|
+
5.times do
|
|
144
|
+
return app if app.is_a?(Class) && app < ::Rails::Engine
|
|
145
|
+
break unless app.respond_to?(:app) && !app.app.equal?(app)
|
|
146
|
+
|
|
147
|
+
app = app.app
|
|
148
|
+
end
|
|
149
|
+
nil
|
|
150
|
+
end
|
|
211
151
|
end
|
|
212
152
|
end
|
|
213
153
|
end
|