Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,40 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## Unreleased

### Dashboard & Telemetry — dev console readiness

The dashboard engine — Active Agent's local dev console — now works out of
the box (production observability is the hosted platform product):

- **Engine load paths fixed**: `Engine.find_root` now points at the
dashboard directory, so `ActiveAgent::TelemetryTrace`,
`ProcessTelemetryTracesJob`, the API controller, views and engine routes
are auto-discovered in host apps (previously they required manual
`require`s). The engine is also required eagerly with Rails, since
engines defined lazily miss initializer collection.
- **Routes now match shipped controllers**: the engine exposes traces,
metrics and the ingest API (`/active_agent/api/traces`, matching
`Telemetry::Configuration::LOCAL_ENDPOINT_PATH`); routes to
never-shipped controllers (agents, sandboxes, templates, recordings,
api/v1) were removed. Engine root renders the traces index.
- **`local_storage` telemetry mode fixed**: tracer payloads are
symbol-keyed and were silently dropped by the string-keyed ingestion
normalizer; the reporter now stringifies and honors
`ActiveAgent::Dashboard.trace_model` overrides.
- **Token totals no longer double-count**: instrumentation mirrors LLM
token usage onto the root span; `TelemetryTrace.create_from_payload`
now counts child spans as the source of truth.
- **Span waterfall renders real offsets** (was pinned to 0ms), turbo-rails
is now optional (previously 500s without it), layout route helpers fixed,
`Agent.for_owner` scope added, synchronous ingest capped at 100
traces/request.
- **New docs** (`docs/framework/dashboard.md`, README section) covering
install, authentication (none by default — see docs), remote ingestion
and multi-tenant mode; dashboard engine test suite added
(`test/dashboard/`).

## [1.0.0] - 2025-11-21

Major refactor with breaking changes. Complete provider rewrite. New modular architecture.
Expand Down
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,32 @@ development:
service: "RubyLLM"
```

## Dev Console & Observability

Active Agent includes a local dev console — traces with span waterfalls,
token usage, and per-agent metrics — as a mountable Rails engine, so you
can watch your agents while you build:

```bash
rails generate active_agent:dashboard:install
rails db:migrate
```

```yaml
# config/active_agent.yml
telemetry:
enabled: true
local_storage: true
```

Open `/active_agent` and every generation appears as a trace. See
[docs/framework/dashboard.md](docs/framework/dashboard.md) for
authentication, remote ingestion, and multi-tenant mode. For production
monitoring — evaluations, cost estimates, retention, team workspaces —
point telemetry at the hosted platform at
[activeagents.ai](https://activeagents.ai); every workspace starts with a
free low-volume trial.

## Features

- **Agent-Oriented Programming**: Build AI applications using familiar Rails patterns
Expand Down
144 changes: 144 additions & 0 deletions docs/framework/dashboard.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
# Dev Console (Dashboard Engine)

Active Agent ships a local development console as a Rails engine: every
agent generation recorded as a trace with a span waterfall, plus a metrics
overview — running inside your app against your own database while you
build. It uses the same telemetry pipeline as the hosted
[activeagents.ai](https://activeagents.ai) platform, which is the
production observability product: what you see locally in development is
what the platform shows (plus evaluations, cost estimates, retention, and
team workspaces) once you point telemetry at it. Every platform workspace
starts with a free low-volume trial.

![Dashboard: traces list with expandable span timelines]

## Quick start

```bash
rails generate active_agent:dashboard:install
rails db:migrate
```

The generator:

- copies the `active_agent_telemetry_traces` migration (plus agent,
run, template, sandbox and recording tables for the full install),
- mounts the engine at `/active_agent`,
- writes `config/initializers/active_agent_dashboard.rb`.

Then enable telemetry with local storage in `config/active_agent.yml`:

```yaml
telemetry:
enabled: true
local_storage: true
```

That's it. Run any agent and open `/active_agent` — each generation
appears as a trace with prompt/LLM/tool spans, timing, token usage
(input / output / thinking), provider and model.

## What you get

| Page | Path | Contents |
|------|------|----------|
| Traces | `/active_agent/traces` | Every generation: agent + action, status, duration, tokens; expandable span timeline; All/Errors filter; 30s auto-refresh |
| Trace detail | `/active_agent/traces/:id` | Span waterfall with relative offsets, token breakdown, error details, raw payload |
| Metrics | `/active_agent/traces/metrics` | Last-24h totals: traces, tokens, avg duration, error rate, active agents; per-agent statistics |
| Ingest API | `POST /active_agent/api/traces` | JSON trace ingestion (used by `local_storage` mode and remote SDKs) |

Time-series charts on the metrics page use the optional
[groupdate](https://github.com/ankane/groupdate) gem when present and
degrade gracefully without it.

## Authentication

**The dashboard has no authentication by default.** Anyone who can reach
the route can read your traces. Before deploying anywhere non-local, set
an authentication method in the initializer:

```ruby
ActiveAgent::Dashboard.configure do |config|
# Any proc that authenticates the request — Devise, Rails 8 sessions, basic auth…
config.authentication_method = ->(controller) do
controller.authenticate_admin!
end
end
```

Or constrain the mount in `config/routes.rb`:

```ruby
authenticate :user, ->(u) { u.admin? } do
mount ActiveAgent::Dashboard::Engine => "/active_agent"
end
```

The local ingest endpoint is unauthenticated in local mode by design (it
receives traces from your own app process). In multi-tenant mode it
requires a Bearer token (see below).

## Sending traces to a remote endpoint instead

Point telemetry at any compatible receiver — including the hosted
platform — instead of (or in addition to) local storage:

```yaml
telemetry:
enabled: true
endpoint: https://api.activeagents.ai/v1/traces
api_key: <%= ENV["ACTIVEAGENTS_API_KEY"] %>
```

The wire format is documented in [telemetry.md](./telemetry.md) under
"self-hosting endpoint requirements" — anything that speaks it can feed
or receive these traces.

## Multi-tenant mode (running your own platform)

The engine also supports account-scoped deployments — this is exactly how
the hosted platform runs it:

```ruby
ActiveAgent::Dashboard.configure do |config|
config.multi_tenant = true
config.account_class = "Account" # must have a telemetry_api_key column
config.trace_model_class = "TelemetryTrace" # optional model override
end
```

In multi-tenant mode the ingest API authenticates with
`Authorization: Bearer <account.telemetry_api_key>` and processes traces
asynchronously through `ActiveAgent::ProcessTelemetryTracesJob`
(idempotent per trace_id, capped at 100 traces per request). Add an
`increment_telemetry_usage!` method to your account model to hook usage
tracking or rate limiting.

## Relationship to the hosted platform

| | Dev console (this engine) | activeagents.ai (production) |
|---|---|---|
| Intended use | Development | Production monitoring |
| Traces + span waterfall | ✓ | ✓ |
| Metrics + per-agent stats | ✓ | ✓ |
| Trace ingest API | ✓ (single tenant, local) | ✓ (multi-tenant, quotas) |
| Conversation persistence | via [solid_agent](https://github.com/activeagents/solid_agent) | ✓ built in |
| Evaluations, cost estimates, retention, team accounts | — | ✓ |

One pipeline, two contexts: the console shows your traces while you
develop; the platform runs the same engine multi-tenant with managed
infrastructure for production. Free workspaces get a low-volume trial
tier; paid plans lift the quotas.

## Conversation persistence

Pair the dashboard with the `solid_agent` gem to persist full
conversations (contexts, messages, generations) alongside traces; its
generation records carry the same `trace_id` for correlation:

```ruby
class ApplicationAgent < ActiveAgent::Base
include SolidAgent::HasContext
has_context contextual: :user
end
```
4 changes: 4 additions & 0 deletions lib/active_agent.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@
# Module Level Extensions
require "active_agent/configuration"
require "active_agent/railtie" if defined?(Rails)
# The dashboard is a Rails engine; engines must be defined when the gem is
# required (before the host app collects railtie initializers), so it
# cannot be left to the Dashboard autoload above.
require "active_agent/dashboard" if defined?(Rails)

# ActiveAgent is a framework for building AI agents with Rails-like conventions.
#
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,13 @@ module Api
class TracesController < ActionController::API
before_action :authenticate_api_key!, if: -> { ActiveAgent::Dashboard.multi_tenant? }

# Maximum traces accepted per request (mirrors
# ProcessTelemetryTracesJob::MAX_TRACES_PER_JOB).
MAX_TRACES_PER_REQUEST = 100

# POST /active_agent/api/traces
def create
traces = params[:traces] || []
traces = Array(params[:traces]).take(MAX_TRACES_PER_REQUEST)
sdk_info = params[:sdk] || {}

return head :accepted if traces.empty?
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,17 @@ class ApplicationController < ActionController::Base
private

def authenticate_dashboard!
return if ActiveAgent::Dashboard.authentication_method.nil?
if ActiveAgent::Dashboard.authentication_method.nil?
# Traces contain prompts, outputs, and error backtraces. Refuse to
# serve them unauthenticated in production rather than exposing
# them to the internet by default.
if Rails.env.production?
render plain: "ActiveAgent Dashboard: set ActiveAgent::Dashboard.authentication_method " \
"(see docs/framework/dashboard.md) to enable access in production.",
status: :forbidden
end
return
end

result = ActiveAgent::Dashboard.authentication_method.call(self)
head :unauthorized unless result
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,23 +6,26 @@ module Dashboard
#
class DashboardController < ApplicationController
def index
unless ActiveAgent::Dashboard.use_inertia && defined?(InertiaRails)
# No ERB overview ships yet — the traces list is the dashboard.
# Loading agent/run data here would also require the full
# dashboard tables, which telemetry-only installs don't have.
return redirect_to(traces_path)
end

@agents = fetch_agents.limit(10)
@recent_runs = fetch_recent_runs.limit(10)
@recent_traces = fetch_recent_traces.limit(10)
@metrics = calculate_metrics

if ActiveAgent::Dashboard.use_inertia && defined?(InertiaRails)
render inertia: "Dashboard", props: {
agents: serialize_agents(@agents),
recentRuns: serialize_runs(@recent_runs),
recentTraces: serialize_traces(@recent_traces),
metrics: @metrics,
user: current_user_props,
account: current_account_props
}
else
render :index
end
render inertia: "Dashboard", props: {
agents: serialize_agents(@agents),
recentRuns: serialize_runs(@recent_runs),
recentTraces: serialize_traces(@recent_traces),
metrics: @metrics,
user: current_user_props,
account: current_account_props
}
end

private
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,14 @@ def index

respond_to do |format|
format.html
format.turbo_stream
# turbo-rails is optional; responding to an unregistered MIME
# type raises in host apps without it.
format.turbo_stream if turbo_stream_available?
end
end

def show
@trace = ActiveAgent::TelemetryTrace.find(params[:id])
@trace = ActiveAgent::Dashboard.trace_model.find(params[:id])
end

def metrics
Expand All @@ -31,12 +33,16 @@ def metrics

respond_to do |format|
format.html
format.turbo_stream
format.turbo_stream if turbo_stream_available?
end
end

private

def turbo_stream_available?
Mime::Type.lookup_by_extension(:turbo_stream).present?
end

def fetch_traces
traces = ActiveAgent::TelemetryTrace.recent

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,16 @@ def perform(account_id: nil, traces:, sdk_info:, received_at:)
return
end

# Process in bounded slices; anything beyond the cap is re-enqueued
# rather than silently dropped (the client already received 202).
remainder = traces.drop(MAX_TRACES_PER_JOB)
traces = traces.take(MAX_TRACES_PER_JOB)
if remainder.any?
self.class.perform_later(
account_id: account_id, traces: remainder,
sdk_info: sdk_info, received_at: received_at
)
end

traces.each do |trace|
process_trace(trace, sdk_info, account)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,13 +54,20 @@ def self.create_from_payload(trace, sdk_info = {}, account: nil)
spans = trace["spans"] || []
root_span = spans.find { |s| s["parent_span_id"].nil? } || spans.first || {}

# Calculate totals from all spans
total_duration = root_span["duration_ms"]

# Instrumentation mirrors LLM token usage onto the root span for
# display, so summing every span double-counts. When child spans
# carry token data, they are the source of truth; the root span only
# counts for single-span traces.
counted_spans = spans.reject { |s| s["parent_span_id"].nil? }
counted_spans = spans if counted_spans.none? { |s| span_token_sum(s).positive? }

total_input = 0
total_output = 0
total_thinking = 0

spans.each do |span|
counted_spans.each do |span|
tokens = span["tokens"] || {}
total_input += (tokens["input"] || 0)
total_output += (tokens["output"] || 0)
Expand Down Expand Up @@ -100,6 +107,15 @@ def self.create_from_payload(trace, sdk_info = {}, account: nil)
create!(attrs)
end

# Sums a span's token counts (used to decide which spans carry the
# authoritative token data during ingestion).
#
# @api private
def self.span_token_sum(span)
tokens = span["tokens"] || {}
tokens.fetch("input", 0).to_i + tokens.fetch("output", 0).to_i + tokens.fetch("thinking", 0).to_i
end

# Returns the root span of this trace.
#
# @return [Hash, nil] The root span or nil
Expand Down
Loading