Skip to main content

everruns_core/
reporting.rs

1//! Backend-neutral reporting contract.
2//!
3//! Reporting facts are derived, org-scoped analytical data. Callers submit a
4//! constrained semantic query; backend implementations compile that shape to
5//! their own storage/query language and must inject tenant scope themselves.
6
7use async_trait::async_trait;
8use chrono::{DateTime, Utc};
9use serde::{Deserialize, Serialize};
10use serde_json::Value;
11
12use crate::Caller;
13
14#[derive(Debug, Clone)]
15pub struct ReportScope {
16    pub org_id: i64,
17    pub caller: Caller,
18}
19
20/// Half-open time window applied to the dataset's primary timestamp column
21/// during a report query. `from` is inclusive, `to` is exclusive.
22#[derive(Debug, Clone, Serialize, Deserialize)]
23#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
24pub struct ReportTimeRange {
25    /// Start of the window (RFC 3339, inclusive).
26    #[cfg_attr(feature = "openapi", schema(example = "2026-04-24T00:00:00Z"))]
27    pub from: DateTime<Utc>,
28    /// End of the window (RFC 3339, exclusive).
29    #[cfg_attr(feature = "openapi", schema(example = "2026-05-24T00:00:00Z"))]
30    pub to: DateTime<Utc>,
31}
32
33/// Semantic query a caller submits to the reporting layer. The backend
34/// compiles this to its native query language, scopes it to the calling
35/// org, and returns a `ReportResult`.
36#[derive(Debug, Clone, Serialize, Deserialize)]
37#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
38pub struct ReportQuery {
39    /// Dataset name to query (see `GET /v1/reports/catalog` for the list of available datasets).
40    #[cfg_attr(feature = "openapi", schema(example = "sessions"))]
41    pub dataset: String,
42    /// Time window for the query. The dataset selects which timestamp column the range applies to.
43    pub time_range: ReportTimeRange,
44    /// Columns to group by. Empty list returns one aggregate row.
45    #[serde(default)]
46    #[cfg_attr(feature = "openapi", schema(example = json!(["status"])))]
47    pub dimensions: Vec<String>,
48    /// Aggregations to compute (count, sum, avg, etc.). Empty list returns row counts only.
49    #[serde(default)]
50    #[cfg_attr(feature = "openapi", schema(example = json!(["session_count", "avg_duration_ms"])))]
51    pub measures: Vec<String>,
52    /// Predicate filters applied before aggregation.
53    #[serde(default)]
54    pub filters: Vec<ReportFilter>,
55    /// Sort spec applied after aggregation. Empty list yields unspecified order.
56    #[serde(default)]
57    pub order_by: Vec<ReportOrderBy>,
58    /// Maximum number of rows to return (defaults to 100).
59    #[serde(default = "default_report_limit")]
60    #[cfg_attr(feature = "openapi", schema(example = 100))]
61    pub limit: u32,
62}
63
64fn default_report_limit() -> u32 {
65    100
66}
67
68/// One predicate filter applied to the dataset before aggregation.
69/// Combined with other filters via logical AND.
70#[derive(Debug, Clone, Serialize, Deserialize)]
71#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
72pub struct ReportFilter {
73    /// Field to filter on. Must be a filter field exposed by the dataset (see `filter_fields` in the catalog).
74    #[cfg_attr(feature = "openapi", schema(example = "status"))]
75    pub field: String,
76    /// Comparison operator. Determines the expected shape of `value`
77    /// (scalar for `eq`/`neq`/`gt`/`gte`/`lt`/`lte`, array for `in`).
78    pub op: ReportFilterOp,
79    /// Comparison value. Type depends on `op`: a scalar for `eq`/`neq`/`gt`/`gte`/`lt`/`lte`,
80    /// an array for `in`. Example for `op = in`: `["completed", "failed"]`.
81    pub value: Value,
82}
83
84/// Comparison operator used in a `ReportFilter`. The `In` variant takes a
85/// JSON array as its value; all others take a scalar.
86#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
87#[serde(rename_all = "snake_case")]
88#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
89pub enum ReportFilterOp {
90    Eq,
91    Neq,
92    In,
93    Gt,
94    Gte,
95    Lt,
96    Lte,
97}
98
99/// One sort clause applied to the aggregated result. Either `dimension`
100/// OR `measure` is set (mutually exclusive), never both.
101#[derive(Debug, Clone, Serialize, Deserialize)]
102#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
103pub struct ReportOrderBy {
104    /// Dimension name to sort by. Mutually exclusive with `measure`.
105    #[serde(default)]
106    #[cfg_attr(feature = "openapi", schema(example = "org_id"))]
107    pub dimension: Option<String>,
108    /// Measure name to sort by. Mutually exclusive with `dimension`.
109    #[serde(default)]
110    #[cfg_attr(feature = "openapi", schema(example = "session_count"))]
111    pub measure: Option<String>,
112    /// Sort direction (`asc` or `desc`). Defaults to `asc`.
113    #[serde(default = "default_order_direction")]
114    pub direction: ReportOrderDirection,
115}
116
117fn default_order_direction() -> ReportOrderDirection {
118    ReportOrderDirection::Asc
119}
120
121/// Sort direction for a `ReportOrderBy` clause.
122#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
123#[serde(rename_all = "snake_case")]
124#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
125pub enum ReportOrderDirection {
126    Asc,
127    Desc,
128}
129
130/// Materialized result of a report query — column metadata, rows, and the
131/// freshness of the underlying data.
132#[derive(Debug, Clone, Serialize, Deserialize)]
133#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
134pub struct ReportResult {
135    /// Timestamp the underlying data was materialized (RFC 3339). Useful as
136    /// an "as of" footer when rendering — distinct from when the query ran.
137    pub as_of: DateTime<Utc>,
138    /// How stale the data is relative to the server's wall clock at query
139    /// time, in milliseconds. `None` when freshness can't be determined
140    /// (e.g. backends that don't track projector lag).
141    #[serde(skip_serializing_if = "Option::is_none")]
142    pub freshness_lag_ms: Option<i64>,
143    /// Column metadata in the same order as the entries of each row in
144    /// `rows`. Use the `kind` field to tell dimensions from measures.
145    pub columns: Vec<ReportColumn>,
146    /// Result rows. Each row is a JSON object keyed by column name; cell
147    /// types match the underlying dataset (numbers for measures, strings
148    /// or numbers for dimensions). Length is capped by `ReportQuery.limit`.
149    pub rows: Vec<Value>,
150}
151
152/// One column header in a `ReportResult`. The ordered `columns` list
153/// declares the key set of each row in `rows`.
154#[derive(Debug, Clone, Serialize, Deserialize)]
155#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
156pub struct ReportColumn {
157    /// Column name as it appears in `rows`.
158    #[cfg_attr(feature = "openapi", schema(example = "session_count"))]
159    pub name: String,
160    /// Whether this column is a grouping `dimension` or an aggregate
161    /// `measure` — the same distinction made on `ReportQuery`.
162    pub kind: ReportColumnKind,
163}
164
165/// Whether a `ReportColumn` is a grouping dimension or an aggregate measure.
166#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
167#[serde(rename_all = "snake_case")]
168#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
169pub enum ReportColumnKind {
170    Dimension,
171    Measure,
172}
173
174/// Listing of every dataset the reporting layer can answer queries over.
175/// Returned from `GET /v1/reports/catalog`.
176#[derive(Debug, Clone, Serialize, Deserialize)]
177#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
178pub struct DatasetCatalog {
179    /// All datasets the caller has access to, in stable alphabetical order.
180    pub datasets: Vec<DatasetCatalogEntry>,
181}
182
183/// A single dataset entry in the reporting catalog — the set of dimensions,
184/// measures, and filter fields the dataset exposes to query authors.
185#[derive(Debug, Clone, Serialize, Deserialize)]
186#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
187pub struct DatasetCatalogEntry {
188    /// Dataset identifier as passed to `ReportQuery.dataset`.
189    pub name: String,
190    /// Dimensions available to group by.
191    pub dimensions: Vec<String>,
192    /// Measures available to aggregate.
193    pub measures: Vec<String>,
194    /// Fields valid as the `field` of a `ReportFilter`.
195    pub filter_fields: Vec<String>,
196}
197
198#[derive(Debug, Clone)]
199pub struct SourceKey {
200    pub source_type: String,
201    pub source_id: String,
202}
203
204#[derive(Debug, Clone)]
205pub struct FactBatch {
206    pub records: Vec<FactRecord>,
207}
208
209#[derive(Debug, Clone)]
210pub struct FactRecord {
211    pub dataset: String,
212    pub org_id: i64,
213    pub source_key: String,
214    pub values: Value,
215}
216
217#[async_trait]
218pub trait ReportingProjectionSink: Send + Sync {
219    async fn upsert_facts(&self, batch: FactBatch) -> anyhow::Result<()>;
220    async fn supersede_source(&self, source: SourceKey) -> anyhow::Result<()>;
221}
222
223#[async_trait]
224pub trait ReportingQueryBackend: Send + Sync {
225    async fn query(&self, scope: ReportScope, query: ReportQuery) -> anyhow::Result<ReportResult>;
226}