Skip to content

Reports and CSV export #9827

Description

@dfliess

Hi, I might be misunderstanding how reports are meant to work, so I would like to ask before assuming anything is broken.

The reports reference has an example titled "Example: query-based report with CSV export" that uses a data: block with metrics:, together with export: format: csv:

https://docs.rilldata.com/reference/project-files/reports#examples

When I copy that example, the report parses, reconciles and schedules without any error, and the email is delivered. But the download link in the email returns 400:

{"code":3,"message":"unsupported report resolver: metrics"}

The check that rejects it seems to be here:

// Get legacy query info back from resolver because currently resolvers does not all options like include headers and also not all resolvers support exports so until then. TODO change UI to support resolver props to open report like alerts
var queryName string
var queryArgsJSON string
var ok bool
if rep.Spec.Resolver != "legacy_metrics" {
return nil, status.Errorf(codes.InvalidArgument, "unsupported report resolver: %s", rep.Spec.Resolver)

Reading around it, the export path looks older than the resolver abstraction: it rebuilds a legacy query proto from query_name and query_args_json rather than going through Resolver.ResolveExport. And of the resolvers, only sql seems to implement ResolveExport; metrics returns not implemented:

func (r *metricsResolver) ResolveExport(ctx context.Context, w io.Writer, opts *runtime.ResolverExportOptions) error {
return errors.New("not implemented")
}

Is data: meant to support export for reports, so this is a gap that has not been filled yet? Or is query: the only supported way to write a report that delivers a file, in which case the docs example may be worth updating?

Happy to help with a PR if it would be useful, though I am not sure yet whether the right shape is routing downloads through ResolveExport or something else.

Activity

  1. k-anshul commented on Sep 3, 2026

    @k-anshul
    Member

    Hi @dfliess

    Thanks for raising the issue.
    Yes we are aware of this implementation gap and this is on our backlog.

  2. dfliess commented on Sep 3, 2026

    @dfliess
    ContributorAuthor

    Thanks for confirming.

    Do you have a rough timeframe for this? If it’s near-term, I’ll leave it with you. If it’s further out, I’d be happy to take it on.
    I’ve gone through with Claude, and I’ve included a proposal below in case it’s useful. If you’d prefer to keep this in-house, no problem at all.

    The idea, in plain terms. The resolver framework already has an export hook, and nothing has ever called it. So this isn't about building an export mechanism — it's about connecting the one that already exists: let the download request carry a resolver instead of a legacy query, reuse the permission gate you already apply to resolvers elsewhere, and fill in the one missing implementation. Your own docs describe the end state already — {{ .export }} is documented as "true when the API is being resolved for export (CSV, Excel, Parquet)", and it can never be true today.

    | `{{ .export }}` | `bool` | `true` when the API is being resolved for export (CSV, Excel, Parquet) |

    What's there today. ResolveExport has no call sites anywhere in the repo, ForExport is never set to true, and sqlResolver is the only one of the 13 implementations that exports anything.

    rill/runtime/resolver.go

    Lines 56 to 57 in 545bf7b

    // ResolveExport resolve data for export (e.g. downloads or reports).
    ResolveExport(ctx context.Context, w io.Writer, opts *ResolverExportOptions) error

    Proposal:

    1. ExportRequest gains resolver / resolver_properties / resolver_args — the same pair ReportSpec and AlertSpec already carry alongside their deprecated legacy fields — and Export gets the permission switch QueryResolver already uses: metrics / metrics_sql on ReadMetrics, everything else on ReadResolvers. Without that switch the new field would let any viewer mint a download token for resolver: sql, since Export only checks ReadMetrics today.
      switch req.Resolver {
      case "metrics", "metrics_sql":
      // As a special case, we allow metrics resolvers for users with ReadMetrics permission (i.e. all users)
      if !claims.Can(runtime.ReadMetrics) {
      return nil, status.Error(codes.PermissionDenied, "not allowed to query metrics")
      }
      default:
      // Other resolvers require ReadResolvers permission (i.e. project admin)
      if !claims.Can(runtime.ReadResolvers) {
      return nil, status.Error(codes.PermissionDenied, "only project admins can query resolvers")
      }
      }
    2. ExportReport builds that request from rep.Spec for any resolver other than legacy_metrics, which keeps its current translation, and downloadHandler grows one branch for request.Resolver != "".
    3. metricsResolver.ResolveExport, sharing generateExportHeaders and the format mapping with MetricsViewAggregation.Export rather than duplicating them. metrics_sql comes along for free, since it returns a metricsResolver.
    4. IncludeHeader / OriginDashboard / OriginURL added to ResolverExportOptions. Priority and execution time stay in Args, which every resolver already reads.
    5. Keeping the transitive-access stripping exactly as it is, and export.limit parity by injecting props["limit"] the way QueryResolver does.

    Separately, and arguably its own bug: sql reports don't work for recipients at all today, before export enters the picture. sqlResolver.InferRequiredSecurityRules returns an error, ResolveTransitiveAccess propagates it, and expandTransitiveAccessRules then fails the whole security resolution — so a magic-token recipient of a sql report can't resolve anything. Returning nil, nil looks right given the comment there, but that's a security call I'd rather you make.

    func (r *sqlResolver) InferRequiredSecurityRules() ([]*runtimev1.SecurityRule, error) {
    // NOTE - This is the regular SQL resolver, so the only refs would be to models, which don't have security policies / access checks
    return nil, errors.New("security rule inference not implemented")

    Happy to split it: proto + gate + handler; then metrics export with the shared headers; then the sql security fix; then apiHandler ?format= so {{ .export }} matches its docs; then the UI mapper.

    Questions, if you do want a PR:

    • Does extending ExportRequest with the QueryResolver gate work for you, or would you rather this stayed internal to ExportReport?
    • Extend ResolverExportOptions with the three header fields, or converge it onto runtime.ExportOptions? I'd extend — converging would duplicate what Args already carries.
    • Runtime.ResolveExport mirroring Runtime.Resolve so tracing and the billable query metric stay in one place, or initialise the resolver in the handler the way alert.go does?
    • sqlResolver.InferRequiredSecurityRules returning nil, nil, or do you want real inference against the referenced models?
    • Backend first, then a shared mapper for the report and alert open routes? Both read the legacy query_name / query_args_json pair today, so neither can open a data: report either.
  3. k-anshul commented on Sep 3, 2026

    @k-anshul
    Member

    Hey @dfliess

    Unfortunately I don't have a concrete timeline for this.
    We do appreciate your interest in contributing to this but we would like to keep this work in-house for now.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions