Skip to content

Commit bec63c3

Browse files
committed
API v2 comparisons
* Add a new response structure: a separate `comparison_results` field, used only when comparing in a time-dimensional query. * Add `meta.time_label_result_indices` to response (exclusive to internal API). Makes it easier for FE to find buckets for time labels.
1 parent 1b9db59 commit bec63c3

5 files changed

Lines changed: 257 additions & 71 deletions

File tree

lib/plausible/stats/query_include.ex

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@ defmodule Plausible.Stats.QueryInclude do
44
defstruct imports: false,
55
imports_meta: false,
66
time_labels: false,
7+
# `time_label_result_indices` is a convenience for our main graph component. It
8+
# is not yet ready for a public API release because it should also account for
9+
# breakdowns by multiple dimensions (time + non-time). Also, at this point it is
10+
# still unclear whether `time_labels` will stay in the public API or not.
11+
time_label_result_indices: false,
712
present_index: false,
813
partial_time_labels: false,
914
total_rows: false,
@@ -21,6 +26,7 @@ defmodule Plausible.Stats.QueryInclude do
2126
imports: boolean(),
2227
imports_meta: boolean(),
2328
time_labels: boolean(),
29+
time_label_result_indices: boolean(),
2430
present_index: boolean(),
2531
partial_time_labels: boolean(),
2632
total_rows: boolean(),

lib/plausible/stats/query_result.ex

Lines changed: 79 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ defmodule Plausible.Stats.QueryResult do
1111
alias Plausible.Stats.{Query, QueryRunner, Filters}
1212

1313
defstruct results: [],
14+
comparison_results: nil,
1415
meta: %{},
1516
query: nil
1617

@@ -43,10 +44,11 @@ defmodule Plausible.Stats.QueryResult do
4344
4445
`results` should already-built by Plausible.Stats.QueryRunner
4546
"""
46-
def from(%QueryRunner{results: results} = runner) do
47+
def from(%QueryRunner{results: results, comparison_results: comparison_results} = runner) do
4748
struct!(
4849
__MODULE__,
4950
results: results,
51+
comparison_results: comparison_results,
5052
meta: meta(runner) |> Jason.OrderedObject.new(),
5153
query: query(runner) |> Jason.OrderedObject.new()
5254
)
@@ -56,7 +58,10 @@ defmodule Plausible.Stats.QueryResult do
5658
%{}
5759
|> add_imports_meta(runner.main_query)
5860
|> add_metric_warnings_meta(runner.main_query)
59-
|> add_time_labels_meta(runner.main_query)
61+
|> add_time_labels_meta(runner)
62+
|> add_time_labels_result_indices_meta(runner)
63+
|> add_comparison_time_labels_meta(runner)
64+
|> add_comparison_time_label_result_indices_meta(runner)
6065
|> add_present_index_meta(runner.main_query)
6166
|> add_partial_time_labels_meta(runner.main_query)
6267
|> add_total_rows_meta(runner.main_query, runner.total_rows)
@@ -87,14 +92,57 @@ defmodule Plausible.Stats.QueryResult do
8792
end
8893
end
8994

90-
defp add_time_labels_meta(meta, query) do
95+
defp add_time_labels_meta(meta, %QueryRunner{main_query: query}) do
9196
if query.include.time_labels do
9297
Map.put(meta, :time_labels, Plausible.Stats.Time.time_labels(query))
9398
else
9499
meta
95100
end
96101
end
97102

103+
defp add_comparison_time_labels_meta(meta, %QueryRunner{main_query: query} = runner) do
104+
if query.include.time_labels && query.include.compare do
105+
Map.put(
106+
meta,
107+
:comparison_time_labels,
108+
Plausible.Stats.Time.time_labels(runner.comparison_query)
109+
)
110+
else
111+
meta
112+
end
113+
end
114+
115+
defp add_time_labels_result_indices_meta(meta, %QueryRunner{main_query: query} = runner) do
116+
time_labels = meta[:time_labels]
117+
118+
if query.include.time_label_result_indices and is_list(time_labels) do
119+
Map.put(
120+
meta,
121+
:time_label_result_indices,
122+
result_indices_for_time_labels(time_labels, runner.main_results)
123+
)
124+
else
125+
meta
126+
end
127+
end
128+
129+
defp add_comparison_time_label_result_indices_meta(
130+
meta,
131+
%QueryRunner{main_query: query} = runner
132+
) do
133+
comp_time_labels = meta[:comparison_time_labels]
134+
135+
if query.include.time_label_result_indices and is_list(comp_time_labels) do
136+
Map.put(
137+
meta,
138+
:comparison_time_label_result_indices,
139+
result_indices_for_time_labels(comp_time_labels, runner.comparison_results)
140+
)
141+
else
142+
meta
143+
end
144+
end
145+
98146
defp add_present_index_meta(meta, query) do
99147
time_labels = meta[:time_labels]
100148

@@ -235,6 +283,15 @@ defmodule Plausible.Stats.QueryResult do
235283

236284
defp metric_warning(_metric, _query), do: nil
237285

286+
defp result_indices_for_time_labels(time_labels, results_list) do
287+
index_lookup_map =
288+
results_list
289+
|> Enum.with_index()
290+
|> Map.new(fn {%{dimensions: [dim]}, idx} -> {dim, idx} end)
291+
292+
Enum.map(time_labels, &Map.get(index_lookup_map, &1))
293+
end
294+
238295
defp to_iso8601(datetime, timezone) do
239296
datetime
240297
|> DateTime.shift_zone!(timezone)
@@ -243,8 +300,25 @@ defmodule Plausible.Stats.QueryResult do
243300
end
244301

245302
defimpl Jason.Encoder, for: Plausible.Stats.QueryResult do
246-
def encode(%Plausible.Stats.QueryResult{results: results, meta: meta, query: query}, opts) do
247-
Jason.OrderedObject.new(results: results, meta: meta, query: query)
303+
def encode(
304+
%Plausible.Stats.QueryResult{
305+
results: results,
306+
comparison_results: comparison_results,
307+
meta: meta,
308+
query: query
309+
},
310+
opts
311+
) do
312+
if comparison_results do
313+
Jason.OrderedObject.new(
314+
results: results,
315+
comparison_results: comparison_results,
316+
meta: meta,
317+
query: query
318+
)
319+
else
320+
Jason.OrderedObject.new(results: results, meta: meta, query: query)
321+
end
248322
|> Jason.Encoder.encode(opts)
249323
end
250324
end

lib/plausible/stats/query_runner.ex

Lines changed: 69 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -87,27 +87,60 @@ defmodule Plausible.Stats.QueryRunner do
8787
end
8888
end
8989

90-
defp get_time_lookup(query, comparison_query) do
91-
if Time.time_dimension(query) && comparison_query do
92-
Enum.zip(
93-
Time.time_labels(query),
94-
Time.time_labels(comparison_query)
95-
)
96-
|> Map.new()
97-
else
98-
%{}
90+
# Assembles the final results, optionally attaching comparison data.
91+
#
92+
# Without a comparison, main results are returned as-is and comparison_results
93+
# is nil.
94+
#
95+
# With comparisons, timeseries and non-time-dimension breakdowns are handled
96+
# separately because they have fundamentally different shapes:
97+
#
98+
# - Non-time breakdowns (e.g. by page, source) return one row per dimension
99+
# group. The comparison query is filtered to the same set of dimension
100+
# values as the main query, so every comparison result is guaranteed to
101+
# have a matching main result. Comparison data is merged inline into each
102+
# result row; comparison_results is nil.
103+
#
104+
# - Timeseries (single "time:*" dimension) keep results and comparison_results
105+
# as separate lists of only non-empty rows. Each comparison row carries a
106+
# `change` field computed against the positionally-aligned original bucket
107+
# (or nil when there is no corresponding original bucket).
108+
defp build_results_list(%__MODULE__{main_query: query, main_results: main_results} = runner) do
109+
case {query.include.compare, query.dimensions} do
110+
{nil, _dimensions} ->
111+
struct!(runner,
112+
results: main_results,
113+
comparison_results: nil
114+
)
115+
116+
{_non_nil_compare, ["time:" <> _]} ->
117+
struct!(runner,
118+
results: main_results,
119+
comparison_results: build_comparison_results(runner)
120+
)
121+
122+
{_non_nil_compare, _dimensions} ->
123+
struct!(runner,
124+
results: merge_with_comparison_results(main_results, runner),
125+
comparison_results: nil
126+
)
99127
end
100128
end
101129

102-
defp build_results_list(%__MODULE__{main_query: query, main_results: main_results} = runner) do
103-
results =
104-
case query.dimensions do
105-
["time:" <> _] -> main_results |> add_empty_timeseries_rows(runner)
106-
_ -> main_results
107-
end
108-
|> merge_with_comparison_results(runner)
109-
110-
struct!(runner, results: results)
130+
defp build_comparison_results(%__MODULE__{main_query: query} = runner) do
131+
main_map = index_by_dimensions(runner.main_results)
132+
133+
comp_label_to_main_label =
134+
Enum.zip(Time.time_labels(runner.comparison_query), Time.time_labels(query))
135+
|> Map.new()
136+
137+
Enum.map(runner.comparison_results, fn %{dimensions: [comp_label]} = comp_row ->
138+
main_label = Map.get(comp_label_to_main_label, comp_label)
139+
main_metrics = main_label && Map.get(main_map, [main_label])
140+
change = calculate_metric_changes(query, main_metrics, comp_row.metrics)
141+
142+
Map.put(comp_row, :change, change)
143+
end)
111144
end
112145

113146
defp execute_query(query, site) do
@@ -181,82 +214,52 @@ defmodule Plausible.Stats.QueryRunner do
181214
|> Enum.at(goal_index - 1)
182215
end
183216

184-
# Special case: If comparison and single time dimension, add 0 rows - otherwise
185-
# comparisons would not be shown for timeseries with 0 values.
186-
defp add_empty_timeseries_rows(results_list, %__MODULE__{main_query: query})
187-
when not is_nil(query.include.compare) do
188-
indexed_results = index_by_dimensions(results_list)
189-
190-
empty_timeseries_rows =
191-
Time.time_labels(query)
192-
|> Enum.reject(fn dimension_value -> Map.has_key?(indexed_results, [dimension_value]) end)
193-
|> Enum.map(fn dimension_value ->
194-
%{
195-
metrics: empty_metrics(query, [dimension_value]),
196-
dimensions: [dimension_value]
197-
}
198-
end)
199-
200-
results_list ++ empty_timeseries_rows
201-
end
202-
203-
defp add_empty_timeseries_rows(results_list, _), do: results_list
204-
205217
defp merge_with_comparison_results(results_list, runner) do
206-
comparison_map = (runner.comparison_results || []) |> index_by_dimensions()
207-
time_lookup = get_time_lookup(runner.main_query, runner.comparison_query)
208-
209-
Enum.map(
210-
results_list,
211-
&add_comparison_results(&1, runner.main_query, comparison_map, time_lookup)
212-
)
218+
comparison_map = index_by_dimensions(runner.comparison_results)
219+
Enum.map(results_list, &add_comparison_results(&1, runner.main_query, comparison_map))
213220
end
214221

215-
defp add_comparison_results(row, query, comparison_map, time_lookup)
216-
when not is_nil(query.include.compare) do
217-
dimensions = get_comparison_dimensions(row.dimensions, query, time_lookup)
218-
comparison_metrics = get_comparison_metrics(comparison_map, dimensions, query)
222+
defp add_comparison_results(row, query, comparison_map) do
223+
comparison_metrics = metrics_for_dimension_group(comparison_map, row.dimensions, query)
219224

220225
change =
221226
Enum.zip([query.metrics, row.metrics, comparison_metrics])
222-
|> Enum.map(fn {metric, metric_value, comparison_value} ->
223-
Compare.calculate_change(metric, comparison_value, metric_value)
227+
|> Enum.map(fn {metric, main_value, comp_value} ->
228+
Compare.calculate_change(metric, comp_value, main_value)
224229
end)
225230

226231
Map.merge(row, %{
227232
comparison: %{
228-
dimensions: dimensions,
233+
dimensions: row.dimensions,
229234
metrics: comparison_metrics,
230235
change: change
231236
}
232237
})
233238
end
234239

235-
defp add_comparison_results(row, _, _, _), do: row
236-
237-
defp get_comparison_dimensions(dimensions, query, time_lookup) do
238-
query.dimensions
239-
|> Enum.zip(dimensions)
240-
|> Enum.map(fn
241-
{"time:" <> _, value} -> time_lookup[value]
242-
{_, value} -> value
243-
end)
244-
end
245-
246240
defp index_by_dimensions(results_list) do
247241
results_list
248242
|> Map.new(fn entry -> {entry.dimensions, entry.metrics} end)
249243
end
250244

251-
defp get_comparison_metrics(comparison_map, dimensions, query) do
252-
Map.get_lazy(comparison_map, dimensions, fn -> empty_metrics(query, dimensions) end)
245+
defp metrics_for_dimension_group(lookup_map, dimensions, query) do
246+
Map.get_lazy(lookup_map, dimensions, fn -> empty_metrics(query, dimensions) end)
253247
end
254248

255249
defp empty_metrics(query, dimensions) do
256250
query.metrics
257251
|> Enum.map(fn metric -> Metrics.default_value(metric, query, dimensions) end)
258252
end
259253

254+
defp calculate_metric_changes(query, main_metrics, comparison_metrics) do
255+
if main_metrics do
256+
Enum.zip([query.metrics, main_metrics, comparison_metrics])
257+
|> Enum.map(fn {metric, main_value, comp_value} ->
258+
Compare.calculate_change(metric, comp_value, main_value)
259+
end)
260+
end
261+
end
262+
260263
defp total_rows([]), do: 0
261264
defp total_rows([first_row | _rest]), do: first_row.total_rows
262265
end

lib/plausible_web/controllers/api/stats_controller.ex

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,13 @@ defmodule PlausibleWeb.Api.StatsController do
3434

3535
with {:ok, %ParsedQueryParams{} = params} <- Dashboard.QueryParser.parse(params),
3636
{:ok, %Query{} = query} <- QueryBuilder.build(site, params, debug_metadata(conn)) do
37+
query =
38+
if query.include.time_labels do
39+
Query.set_include(query, :time_label_result_indices, true)
40+
else
41+
query
42+
end
43+
3744
json(conn, Plausible.Stats.query(site, query))
3845
else
3946
{:error, %QueryError{message: message}} -> bad_request(conn, message)

0 commit comments

Comments
 (0)