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.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +71 -0
  3. data/README.md +26 -0
  4. data/lib/active_agent/base.rb +4 -0
  5. data/lib/active_agent/dashboard/app/controllers/active_agent/dashboard/api/traces_controller.rb +27 -6
  6. data/lib/active_agent/dashboard/app/controllers/active_agent/dashboard/application_controller.rb +11 -1
  7. data/lib/active_agent/dashboard/app/controllers/active_agent/dashboard/dashboard_controller.rb +15 -12
  8. data/lib/active_agent/dashboard/app/controllers/active_agent/dashboard/traces_controller.rb +27 -7
  9. data/lib/active_agent/dashboard/app/jobs/active_agent/process_telemetry_traces_job.rb +9 -0
  10. data/lib/active_agent/dashboard/app/models/active_agent/telemetry_trace.rb +18 -2
  11. data/lib/active_agent/dashboard/app/views/active_agent/dashboard/traces/_trace_detail.html.erb +15 -3
  12. data/lib/active_agent/dashboard/app/views/active_agent/dashboard/traces/metrics.html.erb +3 -1
  13. data/lib/active_agent/dashboard/app/views/layouts/active_agent/dashboard/application.html.erb +4 -4
  14. data/lib/active_agent/dashboard/config/routes.rb +5 -64
  15. data/lib/active_agent/dashboard/engine.rb +19 -15
  16. data/lib/active_agent/dashboard.rb +13 -3
  17. data/lib/active_agent/model_capabilities.rb +89 -0
  18. data/lib/active_agent/providers/_base_provider.rb +23 -2
  19. data/lib/active_agent/providers/concerns/exception_handler.rb +12 -1
  20. data/lib/active_agent/providers/errors.rb +140 -0
  21. data/lib/active_agent/providers/ollama/chat/transforms.rb +9 -3
  22. data/lib/active_agent/railtie.rb +5 -0
  23. data/lib/active_agent/telemetry/configuration.rb +112 -172
  24. data/lib/active_agent/telemetry/instrumentation.rb +137 -14
  25. data/lib/active_agent/telemetry/reporter.rb +12 -165
  26. data/lib/active_agent/telemetry/span.rb +18 -245
  27. data/lib/active_agent/telemetry/tracer.rb +67 -70
  28. data/lib/active_agent/telemetry.rb +1 -2
  29. data/lib/active_agent/version.rb +1 -1
  30. data/lib/active_agent.rb +5 -0
  31. data/lib/generators/active_agent/dashboard/install_generator.rb +30 -2
  32. data/lib/generators/active_agent/dashboard/templates/active_agent_dashboard.rb.erb +41 -4
  33. data/lib/generators/active_agent/dashboard/templates/create_active_agent_telemetry_traces.rb.erb +20 -4
  34. metadata +17 -11
  35. data/lib/generators/active_agent/dashboard/install/install_generator.rb +0 -96
  36. data/lib/generators/active_agent/dashboard/install/templates/initializer.rb +0 -89
  37. data/lib/generators/active_agent/dashboard/install/templates/migrations/create_active_agent_agent_runs.rb +0 -42
  38. data/lib/generators/active_agent/dashboard/install/templates/migrations/create_active_agent_agent_templates.rb +0 -38
  39. data/lib/generators/active_agent/dashboard/install/templates/migrations/create_active_agent_agent_versions.rb +0 -22
  40. data/lib/generators/active_agent/dashboard/install/templates/migrations/create_active_agent_agents.rb +0 -53
  41. data/lib/generators/active_agent/dashboard/install/templates/migrations/create_active_agent_sandbox_runs.rb +0 -28
  42. data/lib/generators/active_agent/dashboard/install/templates/migrations/create_active_agent_sandbox_sessions.rb +0 -43
  43. data/lib/generators/active_agent/dashboard/install/templates/migrations/create_active_agent_session_recordings.rb +0 -44
  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 # Usage Tracking
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
- rescue_with_handler(exception) || raise
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<OpenAI::Models::Chat::ChatCompletionMessageParam>]
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
@@ -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
- # Configuration for telemetry collection and reporting.
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
- # Stores settings for endpoint, authentication, sampling, and batching.
8
- # Configuration can be set programmatically or loaded from YAML.
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
- # @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
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
- # @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
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 [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"
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
- @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
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
- # Returns whether telemetry collection is enabled.
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
- # 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?)
39
+ def local_storage?
40
+ @local_storage
107
41
  end
108
42
 
109
- # Returns whether local storage mode is enabled.
110
- #
111
- # @return [Boolean]
112
- def local_storage?
113
- @local_storage == true
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
- if local_storage?
123
- LOCAL_ENDPOINT_PATH
124
- else
125
- endpoint
126
- end
55
+ local_storage? ? local_endpoint_path : endpoint
127
56
  end
128
57
 
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
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
- # Resolves the service name for traces.
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
- @service_name || rails_app_name || "activeagent"
70
+ value = super
71
+ value == "ruby" ? "activeagent" : value
148
72
  end
149
73
 
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)
74
+ def to_h
75
+ super.merge(local_storage: local_storage)
157
76
  end
158
77
 
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
78
+ private
182
79
 
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
- }
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
- private
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
- def rails_app_name
205
- return nil unless defined?(Rails) && Rails.application
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.class.module_parent_name.underscore
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