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