This guide walks through declaring an Ash resource as analyzable and running dynamic analyses against it at runtime.
A runnable end-to-end example (Phoenix + LiveView, seven live charts) ships in
examples/ash_dyan_demo/:

1. Declare a resource as analyzable
Add AshDyan to the resource's extensions and open a dyan section. Each
analyzable_field is a whitelist entry: a runtime request may only reference
fields, functions, buckets, and percentiles declared here.
defmodule MyApp.Order do
use Ash.Resource,
extensions: [AshDyan]
attributes do
uuid_primary_key :id
attribute :status, :atom
attribute :total_amount, :decimal
attribute :region, :atom
attribute :inserted_at, :utc_datetime
end
actions do
defaults([:create, :read, :update, :destroy])
end
dyan do
# Count occurrences of a categorical column.
analyzable_field :status, type: :frequency
# Numeric aggregates over a column.
analyzable_field :total_amount, type: :aggregate,
functions: [:sum, :avg, :min, :max, :count, :count_distinct, :stddev, :variance, :median]
# Time-bucketed aggregates (in-memory bucketing on any data layer).
analyzable_field :inserted_at, type: :time_bucket,
buckets: [:day, :week, :month]
# In-memory percentiles.
analyzable_field :total_amount, type: :percentile,
percentiles: [50, 90, 99]
# Numeric distribution into bins (histogram).
analyzable_field :total_amount, type: :histogram, bins: 10
# Limits & guards.
max_group_by 3
default_limit 100
max_limit 1000
query_timeout 15_000
allow_filters_on [:status, :region, :inserted_at]
end
endSection options
| Option | Default | Meaning |
|---|---|---|
max_group_by | 3 | Maximum number of group_by fields a request may specify. |
default_limit | 100 | Row limit applied when a request does not specify one. |
max_limit | 1000 | Hard cap on the limit a request may specify. |
query_timeout | 15000 | Per-request read timeout in milliseconds (always enforced). |
allow_filters_on | [] | Attributes a runtime request is allowed to filter on. |
analyzable_field options
| Option | Applies to | Meaning |
|---|---|---|
type | all | :frequency / :aggregate / :time_bucket / :percentile / :histogram |
functions | :aggregate | Allowed aggregate functions: :sum, :avg, :min, :max, :count, :count_distinct, :stddev, :variance, :median. |
buckets | :time_bucket | Allowed bucket granularities. |
percentiles | :percentile | Allowed percentile values. |
time_field | :time_bucket | Time attribute to bucket on (defaults to name). |
bins | :histogram | Number of bins (default 10). |
bin_width | :histogram | Fixed bin width; auto-computed from the data range when omitted. |
2. (Optional) Register resources on a domain
The domain dyan section is a thin discovery registry. Cross-resource joins are
out of scope for v1.
defmodule MyApp.Shop do
use Ash.Domain, extensions: [AshDyan.Domain]
dyan do
analyzable_resource MyApp.Order
end
end3. Build a request
A request is a map (or an AshDyan.Request struct). String keys are accepted
so HTTP adapters can pass params through directly.
spec = %{
domain: MyApp.Shop,
resource: MyApp.Order,
type: :time_bucket,
time_field: :inserted_at,
bucket: :day,
column: :total_amount,
function: :sum,
group_by: [:status],
filters: %{region: :EU},
limit: 200
}| Field | Required when | Notes |
|---|---|---|
resource | always | Must be an analyzable Ash resource. |
domain | recommended | Used to resolve the read action. |
type | always | One of the five capabilities. |
column | :frequency / :aggregate / :percentile / :histogram | The metric/attribute to analyze. |
function | :aggregate | One of the whitelisted functions. |
bucket | :time_bucket | One of the whitelisted buckets. |
time_field | :time_bucket | Defaults to column. |
percentiles | :percentile | List of whitelisted percentile values. |
bins | :histogram | Number of bins (default 10). |
bin_width | :histogram | Fixed bin width; auto-computed when omitted. |
group_by | optional | Checked against max_group_by. |
filters | optional | Only allow_filters_on fields are permitted. |
limit | optional | Capped at max_limit. |
4. Run it
# Safe: returns {:ok, result} or {:error, %AshDyan.Error{}}.
{:ok, result} = AshDyan.run(spec)
# With an actor for policy checks:
{:ok, result} = AshDyan.run(spec, actor: current_user)
# With a tenant:
{:ok, result} = AshDyan.run(spec, tenant: "acme")
# Override the per-request timeout (defaults to the resource's query_timeout):
{:ok, result} = AshDyan.run(spec, timeout: 5_000)
# Raise on error instead:
result = AshDyan.run!(spec)Options
:actor— the actor passed to the read action for policy checks.:tenant— tenant for multitenant resources.:timeout— overrides the per-request query timeout (defaults to the resource'squery_timeout, which is always enforced).:data— explicit in-memory dataset for theAsh.DataLayer.Simplelayer (used by tests and embedded resources).
5. Read the result
Every analysis type returns the same labels / series shape, so a client-side
chart adapter needs no per-type branching.
%AshDyan.Result{
type: :time_bucket,
labels: ["2026-07-01", "2026-07-02", ...],
series: [
%{name: "paid", data: [120.5, 98.0, ...]},
%{name: "refunded", data: [12.0, 4.5, ...]}
]
}:frequency—labelsare distinct column values; one series (named after the column) with counts, or one series pergroup_bycombination.:aggregate—labelsis[column](no group_by) or the distinct group combinations; one series named after the function.:time_bucket—labelsare bucket labels; series per group_by combination (or a single series named after the function).:percentile—labelsare percentile labels ("50th", ...); series per group_by combination (or a single series named after the column).:histogram—labelsare bin ranges ("0.0-50.0", ...); series per group_by combination (or a single series named after the column). Counts are aligned to the shared bin axis so a chart adapter needs no per-type branching.
6. Errors
Validation and configuration errors are returned as
{:error, %AshDyan.Error{field:, reason:}} naming the offending field. The
reason atom is stable for programmatic matching:
case AshDyan.run(spec) do
{:ok, result} -> render(result)
{:error, %AshDyan.Error{field: :limit, reason: :too_large}} ->
{:error, 422, "limit exceeds maximum"}
endCommon reasons: :not_a_resource, :not_analyzable, :unknown_type,
:not_analyzable (column/time_field), :not_allowed (function/bucket/
percentiles/filters), :too_many, :too_large, :unknown_attribute,
:bad_type, :bad_bins, :invalid_value, :unsupported_data_layer,
:no_primary_read_action, :not_supported, :incompatible.
7. Capability checks
Before issuing a query, you can discover data-layer limits:
AshDyan.supports?(MyApp.Order, :percentile)
# => true on Postgres, false on the in-memory Simple layer (v1)8. Building an adapter
AshDyan ships no Phoenix/Channel/gen_api modules. The run/2 contract is
already adapter-agnostic, so a delivery layer is ~10 lines of glue you own. A
thin Phoenix controller action looks like:
defmodule MyAppWeb.AnalysisController do
use MyAppWeb, :controller
def analyze(conn, params) do
spec = %{
domain: String.to_atom(params["domain"]),
resource: String.to_atom(params["resource"]),
type: String.to_atom(params["type"]),
column: maybe_atom(params["column"]),
function: maybe_atom(params["function"]),
bucket: maybe_atom(params["bucket"]),
time_field: maybe_atom(params["time_field"]),
group_by: maybe_atoms(params["group_by"]),
percentiles: maybe_ints(params["percentiles"]),
filters: params["filters"] || %{},
limit: maybe_int(params["limit"])
}
opts = if actor = conn.assigns[:current_user], do: [actor: actor], else: []
case AshDyan.run(spec, opts) do
{:ok, result} ->
conn |> put_resp_content_type("application/json") |> send_resp(200, Jason.encode!(result))
{:error, %AshDyan.Error{} = error} ->
conn |> put_resp_content_type("application/json")
|> send_resp(422, Jason.encode!(%{error: error.message, field: error.field, reason: error.reason}))
{:error, other} ->
conn |> put_resp_content_type("application/json") |> send_resp(500, Jason.encode!(%{error: inspect(other)}))
end
end
endThe same shape works for a Phoenix Channel (handle_in("analyze", payload, socket) → AshDyan.run(payload, opts) → {:reply, ...}) or an
ash_phoenix_gen_api MFA bridge ({MyApp.Analysis, :run, [:spec, :opts]}).
9. Logging
AshDyan.run/2 emits structured Logger events:
:debugwhen a request starts or is rejected during validation/configuration.:warningwhen the requested analysis type is unsupported by the data layer.:errorwhen the underlying read fails.
Filter contents are never logged.
10. Analysis types supported
AshDyan exposes five analysis capabilities. Each must be whitelisted per field
in the resource's dyan section (see §1) and is gated by the resource's data
layer (see §7).
| Type | What it computes | Required request fields | group_by |
|---|---|---|---|
:frequency | Count of occurrences of a categorical column. | column | optional |
:aggregate | Numeric aggregate of a column (sum/avg/min/max). | column, function | optional |
:time_bucket | Time-bucketed aggregate of a column over a time field. | time_field (or column), bucket, function | optional |
:percentile | In-memory percentile(s) of a numeric column. | column, percentiles | optional |
:histogram | Numeric distribution of a column into bins. | column, bins (optional), bin_width (optional) | optional |
:frequency—labelsare the distinct column values; one series named after the column (or one series pergroup_bycombination).:aggregate—labelsis[column]with nogroup_by, or the distinctgroup_bycombinations; one series named after the function.:time_bucket—labelsare bucket labels (day→2026-07-01,week→ Monday of the ISO week,month→2026-07,quarter→2026-Q3,year→2026,hour/minute→YYYY-MM-DD HH:00); series pergroup_bycombination (or a single series named after the function).:percentile—labelsare percentile labels ("50th", ...); series pergroup_bycombination (or a single series named after the column). Computed with linear interpolation between the two nearest ranks, for bothDecimaland plain-number values.:histogram—labelsare bin ranges ("0.0-50.0", ...); series pergroup_bycombination (or a single series named after the column). Counts are aligned to the shared bin axis so a chart adapter needs no per-type branching. Bins are computed frombins/bin_width(or auto-sized from the data range).
Data-layer capability matrix
Not every data layer can serve every type. AshDyan.supports?/2 (§7) reflects
this matrix; a request for an unsupported type returns
{:error, %AshDyan.Error{field: :type, reason: :unsupported_data_layer}}.
AshDyan keeps these integrations optional. If your application uses one of these data layers, declare its package alongside AshDyan:
{:ash_clickhouse, "~> 0.5"}
{:ash_scylla, "~> 1.7"}
{:ash_sqlite, "~> 0.2"}The built-in registry recognizes AshClickhouse.DataLayer and
AshScylla.DataLayer and AshSqlite.DataLayer automatically.
| Data layer | :frequency | :aggregate | :time_bucket | :percentile | :histogram |
|---|---|---|---|---|---|
AshPostgres | yes | yes | yes | yes | yes |
AshClickhouse.DataLayer | yes | yes | yes | yes | yes |
AshScylla.DataLayer | yes | yes | yes | yes | yes |
AshSqlite.DataLayer | yes | yes | yes | yes | yes |
Ash.DataLayer.Simple (ETS) | yes | yes | yes | yes | yes |
| Other / unknown (Default) | yes | yes | no | no | no |
- Postgres — all five capabilities are supported.
- ClickHouse, ScyllaDB, and SQLite — all five capabilities use their filtered, projected, limited read paths and AshDyan's in-memory formatter. Native aggregate support in those adapters does not change the stable AshDyan output contract.
- Simple (ETS, in-memory) — all five capabilities are computed in memory.
- Default (any other layer) — only the universally-safe
:frequencyand:aggregateare allowed;:time_bucket,:percentile, and:histogramare rejected with a clear error rather than silently wrong results.
Custom data layers
Register a capability module for a custom Ash data layer. supports?/2 gates
requests, while capabilities/1 describes the query primitives and aggregate
implementation available to the adapter:
defmodule MyApp.AnalyticsCapabilities do
@behaviour AshDyan.DataLayer
@impl true
def supports?(_resource, capability),
do: capability in [:frequency, :aggregate, :time_bucket]
@impl true
def capabilities(_resource) do
%{
query: %{filter: true, select: true, limit: true, timeout: true, sort: true},
aggregation: %{mode: :pushdown, functions: [:count, :sum, :avg]}
}
end
end
config :ash_dyan, :data_layer_capabilities, %{
MyApp.CustomDataLayer => MyApp.AnalyticsCapabilities
}Inspect the metadata before issuing a request:
AshDyan.DataLayer.capabilities(MyApp.Order)
AshDyan.DataLayer.capabilities_for_data_layer(
MyApp.CustomDataLayer,
MyApp.Order
)The aggregation.mode value documents whether an adapter uses :in_memory,
:pushdown, or :hybrid aggregation. AshDyan continues to use its stable
bounded-read formatting path unless a future engine integration explicitly
consumes a pushdown callback.
11. Chart-ready output (AshDyan.Charts)
Every analysis type returns the same labels/series shape. The AshDyan.Charts
module turns a result into chart-library-ready shapes so a client can render a
chart without knowing AshDyan's internals.
{:ok, result} = AshDyan.run(spec)
# Pick a sensible default chart type from the result shape.
AshDyan.Charts.recommend(result)
# => :bar | :line | :area | :pie | :donut | :histogram | :scatter
# Serialize for a specific library (both return JSON-encodable maps).
AshDyan.Charts.to_chartjs(result) # Chart.js `data`/`options`
AshDyan.Charts.to_echarts(result) # ECharts `option`
# Or force a chart type:
AshDyan.Charts.to_chartjs(result, :line)recommend/1 maps result types to chart types:
Result type | Default chart (recommend/1) |
|---|---|
:frequency | :bar (:pie for a single series) |
:aggregate | :bar (:pie for a single series) |
:time_bucket | :line |
:percentile | :line |
:histogram | :histogram |
The serialized maps are plain (JSON-encodable) structures: to_chartjs/2 returns
%{type:, data: %{labels:, datasets:}, options:} and to_echarts/2 returns an
ECharts option map (%{tooltip:, legend:, xAxis:, yAxis:, series:}).
12. Extending AshDyan
AshDyan is designed to be extensible without forking. There are three ways to extend its capabilities:
12.1 Configuration-based Extension
For simple extensions, use application config:
# config/config.exs
# Add custom analysis types
config :ash_dyan, :analysis_types, %{
funnel: MyApp.AshDyan.Funnel,
cohort: MyApp.AshDyan.Cohort
}
# Add custom data layer capabilities
config :ash_dyan, :data_layer_capabilities, %{
MyApp.CustomDataLayer => MyApp.AshDyan.CustomCapabilities
}
# Add custom aggregate functions
config :ash_dyan, :custom_aggregates, %{
weighted_avg: MyApp.AshDyan.WeightedAvg,
percentile_approx: MyApp.AshDyan.PercentileApprox
}
# Add pipeline hooks
config :ash_dyan, :hooks, %{
before_query: [MyApp.Hooks.QueryLogger],
after_query: [MyApp.Hooks.ResultCache],
before_format: [MyApp.Hooks.DataTransformer],
after_format: [MyApp.Hooks.MetadataEnricher]
}12.2 Formal Extensions (Recommended)
For more complex extensions, implement the AshDyan.Extension behaviour:
defmodule MyApp.AshDyan.Extension do
@behaviour AshDyan.Extension
@impl true
def analysis_types do
%{
funnel: MyApp.AshDyan.Funnel,
cohort: MyApp.AshDyan.Cohort
}
end
@impl true
def data_layer_capabilities do
%{
MyApp.CustomDataLayer => MyApp.AshDyan.CustomCapabilities
}
end
@impl true
def custom_aggregates do
%{
weighted_avg: MyApp.AshDyan.WeightedAvg,
percentile_approx: MyApp.AshDyan.PercentileApprox
}
end
@impl true
def hooks do
%{
before_query: [MyApp.Hooks.QueryLogger],
after_query: [MyApp.Hooks.ResultCache],
before_format: [MyApp.Hooks.DataTransformer],
after_format: [MyApp.Hooks.MetadataEnricher]
}
end
@impl true
def dsl_entities do
[
MyApp.AshDyan.Dsl.AnalyzableFieldExtension
]
end
endRegister in your application's config/config.exs:
config :ash_dyan, :extensions, [MyApp.AshDyan.Extension]Or register multiple extensions:
config :ash_dyan, :extensions, [
MyApp.AshDyan.Extension,
SomeOther.AshDyan.Extension
]Implementing a callback is optional — any callback you skip contributes its
empty default (an extension that only provides analysis_types still loads).
Extension-registered values are merged with the config-based ones, with config
winning on key conflicts within the same map. Custom aggregates registered via
an extension are also admitted by :aggregate/:time_bucket validation, not
just at format time.
12.3 Custom Analysis Type Example
Implement the AshDyan.Analysis behaviour for a new analysis type:
defmodule MyApp.AshDyan.Funnel do
@behaviour AshDyan.Analysis
alias AshDyan.{Request, Result, Error}
@impl true
def validate(%{column: nil, steps: []}) do
{:error, Error.exception(field: :steps, message: ":funnel requires :steps")}
end
def validate(%{column: column, steps: steps, resource: resource}) do
if AshDyan.Info.analyzable_field(resource, column, :funnel) do
if steps != [] do
:ok
else
{:error, Error.exception(field: :steps, message: "steps cannot be empty")}
end
else
{:error, Error.exception(field: :column, reason: :not_analyzable, message: "column not analyzable for funnel")}
end
end
@impl true
def select_fields(%{column: column, group_by: group_by}) do
Enum.uniq([column | group_by])
end
@impl true
def format(request, records) do
# Custom funnel logic here
{:ok, %Result{type: :funnel, labels: [], series: []}}
end
@impl true
def recommend_chart(_result), do: :bar
@impl true
def supports_presentation?(:sort_by), do: false
def supports_presentation?(:top), do: false
def supports_presentation?(_), do: true
end12.4 Custom Aggregate Function Example
defmodule MyApp.AshDyan.WeightedAvg do
def apply(values) do
# values is a list of {value, weight} tuples or just values
# Return the weighted average
weighted_sum = Enum.reduce(values, 0, fn {v, w}, acc -> acc + v * w end)
total_weight = Enum.reduce(values, 0, fn {_v, w}, acc -> acc + w end)
if total_weight > 0, do: weighted_sum / total_weight, else: nil
end
end12.5 Pipeline Hooks Example
defmodule MyApp.Hooks.QueryLogger do
@behaviour AshDyan.Engine.Hook
@impl true
def before_query(query, request, _opts) do
Logger.info("Executing query for #{request.resource}, type: #{request.type}")
query
end
end
defmodule MyApp.Hooks.ResultCache do
@behaviour AshDyan.Engine.Hook
@impl true
def after_query(records, request, _opts) do
# Cache the raw records
Cache.put("ash_dyan:#{request.resource}:#{request.type}", records)
records
end
end12.6 Custom Data Layer Capabilities
defmodule MyApp.AshDyan.CustomCapabilities do
@behaviour AshDyan.DataLayer
@impl true
def supports?(_resource, :frequency), do: true
def supports?(_resource, :aggregate), do: true
def supports?(_resource, :time_bucket), do: true
def supports?(_resource, :percentile), do: true
def supports?(_resource, :histogram), do: true
def supports?(_resource, :funnel), do: true
@impl true
def pushdown_time_bucket(query, time_field, bucket) do
# Custom SQL pushdown for time bucketing
{:ok, query}
end
@impl true
def pushdown_aggregate(query, column, function) do
# Custom SQL pushdown for aggregates
{:ok, query}
end
@impl true
def stream(query, opts, _read_opts) do
# Stream large result sets
{:ok, Enum.to_stream(records)}
end
@impl true
def paginate(query, limit, offset) do
# Server-side pagination
{:ok, Ash.Query.limit(query, limit) |> Ash.Query.offset(offset)}
end
end13. Limitations
v1 is intentionally scoped. Known limitations:
- No cross-resource joins. The domain
dyanregistry (§2) is discovery-only; analyses run against a single resource. - In-memory aggregation. The engine selects only the needed columns, applies
the caller's filters and the configured
limit(a hard cap), runs the query through the resource's read action, then aggregates the returned rows in memory. This is bounded bymax_limitrows and is not a true SQLGROUP BYpushdown.AshDyan.Engine.TimeBucket.expr/2is a reference helper for a future Postgresdate_truncpushdown; today all bucketing is done in memory so behaviour is identical across data layers. - Analysis execution is bounded, not pushed down. Percentiles on ETS and all analyses on ClickHouse/ScyllaDB are computed from the limited rows returned by the data layer; they are not native percentile or GROUP BY queries.
query_timeoutis data-layer dependent. The timeout is always applied on data layers that can honor it (Postgres, etc.); on the in-memory ETS path it is skipped (guarded byAsh.DataLayer.data_layer_can?/2) so tests and embedded resources keep working.- Whitelist-only fields. A request may only reference fields, functions,
buckets, percentiles, and filter targets declared in the
dyansection. Anything else is rejected during validation with a stablereasonatom (§6). - Filters are restricted and internal. Only
allow_filters_onattributes may be filtered, and they are parsed as internal Ash filters (so they need not bepublic?). A filter that passes the whitelist but still fails Ash's filter parse (e.g. a type mismatch) surfaces as:invalid_valuerather than being dropped. group_bybounds. The number ofgroup_byfields is capped bymax_group_by; referencing a non-existent attribute is rejected with:unknown_attribute.- No query-builder UI / BI engine. AshDyan turns "chart of X grouped by Y, filtered by Z" into a safe runtime capability; it is not a full reporting tool or tied to Phoenix/Channels (those are thin adapters, §8).