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/:

AshDyan demo dashboard

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
end

Section options

OptionDefaultMeaning
max_group_by3Maximum number of group_by fields a request may specify.
default_limit100Row limit applied when a request does not specify one.
max_limit1000Hard cap on the limit a request may specify.
query_timeout15000Per-request read timeout in milliseconds (always enforced).
allow_filters_on[]Attributes a runtime request is allowed to filter on.

analyzable_field options

OptionApplies toMeaning
typeall:frequency / :aggregate / :time_bucket / :percentile / :histogram
functions:aggregateAllowed aggregate functions: :sum, :avg, :min, :max, :count, :count_distinct, :stddev, :variance, :median.
buckets:time_bucketAllowed bucket granularities.
percentiles:percentileAllowed percentile values.
time_field:time_bucketTime attribute to bucket on (defaults to name).
bins:histogramNumber of bins (default 10).
bin_width:histogramFixed 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
end

3. 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
}
FieldRequired whenNotes
resourcealwaysMust be an analyzable Ash resource.
domainrecommendedUsed to resolve the read action.
typealwaysOne of the five capabilities.
column:frequency / :aggregate / :percentile / :histogramThe metric/attribute to analyze.
function:aggregateOne of the whitelisted functions.
bucket:time_bucketOne of the whitelisted buckets.
time_field:time_bucketDefaults to column.
percentiles:percentileList of whitelisted percentile values.
bins:histogramNumber of bins (default 10).
bin_width:histogramFixed bin width; auto-computed when omitted.
group_byoptionalChecked against max_group_by.
filtersoptionalOnly allow_filters_on fields are permitted.
limitoptionalCapped 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's query_timeout, which is always enforced).
  • :data — explicit in-memory dataset for the Ash.DataLayer.Simple layer (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, ...]}
  ]
}
  • :frequencylabels are distinct column values; one series (named after the column) with counts, or one series per group_by combination.
  • :aggregatelabels is [column] (no group_by) or the distinct group combinations; one series named after the function.
  • :time_bucketlabels are bucket labels; series per group_by combination (or a single series named after the function).
  • :percentilelabels are percentile labels ("50th", ...); series per group_by combination (or a single series named after the column).
  • :histogramlabels are 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"}
end

Common 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
end

The 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:

  • :debug when a request starts or is rejected during validation/configuration.
  • :warning when the requested analysis type is unsupported by the data layer.
  • :error when 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).

TypeWhat it computesRequired request fieldsgroup_by
:frequencyCount of occurrences of a categorical column.columnoptional
:aggregateNumeric aggregate of a column (sum/avg/min/max).column, functionoptional
:time_bucketTime-bucketed aggregate of a column over a time field.time_field (or column), bucket, functionoptional
:percentileIn-memory percentile(s) of a numeric column.column, percentilesoptional
:histogramNumeric distribution of a column into bins.column, bins (optional), bin_width (optional)optional
  • :frequencylabels are the distinct column values; one series named after the column (or one series per group_by combination).
  • :aggregatelabels is [column] with no group_by, or the distinct group_by combinations; one series named after the function.
  • :time_bucketlabels are bucket labels (day2026-07-01, week → Monday of the ISO week, month2026-07, quarter2026-Q3, year2026, hour/minuteYYYY-MM-DD HH:00); series per group_by combination (or a single series named after the function).
  • :percentilelabels are percentile labels ("50th", ...); series per group_by combination (or a single series named after the column). Computed with linear interpolation between the two nearest ranks, for both Decimal and plain-number values.
  • :histogramlabels are 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. Bins are computed from bins/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
AshPostgresyesyesyesyesyes
AshClickhouse.DataLayeryesyesyesyesyes
AshScylla.DataLayeryesyesyesyesyes
AshSqlite.DataLayeryesyesyesyesyes
Ash.DataLayer.Simple (ETS)yesyesyesyesyes
Other / unknown (Default)yesyesnonono
  • 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 :frequency and :aggregate are allowed; :time_bucket, :percentile, and :histogram are 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 typeDefault 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]
}

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
end

Register 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
end

12.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
end

12.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
end

12.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
end

13. Limitations

v1 is intentionally scoped. Known limitations:

  • No cross-resource joins. The domain dyan registry (§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 by max_limit rows and is not a true SQL GROUP BY pushdown. AshDyan.Engine.TimeBucket.expr/2 is a reference helper for a future Postgres date_trunc pushdown; 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_timeout is 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 by Ash.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 dyan section. Anything else is rejected during validation with a stable reason atom (§6).
  • Filters are restricted and internal. Only allow_filters_on attributes may be filtered, and they are parsed as internal Ash filters (so they need not be public?). A filter that passes the whitelist but still fails Ash's filter parse (e.g. a type mismatch) surfaces as :invalid_value rather than being dropped.
  • group_by bounds. The number of group_by fields is capped by max_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).