File size: 10,621 Bytes
8da2481
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
defmodule Plausible.Stats.QueryResult do
  @moduledoc """
  This struct contains the (JSON-encodable) response for a query and
  is responsible for building it from database query results.

  For the convenience of API docs and consumers, the JSON result
  produced by Jason.encode(query_result) is ordered.
  """

  use Plausible
  alias Plausible.Stats.{Query, QueryRunner, Filters}

  defstruct results: [],
            comparison_results: nil,
            meta: %{},
            query: nil

  @imports_warnings %{
    unsupported_query:
      "Imported stats are not included in the results because query parameters are not supported. " <>
        "For more information, see: https://plausible.io/docs/stats-api#filtering-imported-stats",
    unsupported_interval:
      "Imported stats are not included because the time dimension (i.e. the interval) is too short."
  }

  def imports_warnings(), do: @imports_warnings

  @no_imported_scroll_depth_warning %{
    code: :no_imported_scroll_depth,
    warning: "No imports with scroll depth data were found"
  }

  def no_imported_scroll_depth_warning(), do: @no_imported_scroll_depth_warning

  @no_imported_bounce_rate_warning %{
    code: :no_imported_bounce_rate,
    warning: "imported bounce_rate is not available when using a page filter"
  }

  def no_imported_bounce_rate_warning(), do: @no_imported_bounce_rate_warning

  @doc """
  Builds full JSON-serializable query response.

  `results` should already-built by Plausible.Stats.QueryRunner
  """
  def from(%QueryRunner{results: results, comparison_results: comparison_results} = runner) do
    struct!(
      __MODULE__,
      results: results,
      comparison_results: comparison_results,
      meta: meta(runner) |> Jason.OrderedObject.new(),
      query: query(runner) |> Jason.OrderedObject.new()
    )
  end

  defp meta(%QueryRunner{} = runner) do
    %{}
    |> add_imports_meta(runner.main_query)
    |> add_metric_warnings_meta(runner.main_query)
    |> add_empty_metrics_meta(runner.main_query)
    |> add_time_labels_meta(runner)
    |> add_time_labels_result_indices_meta(runner)
    |> add_comparison_time_labels_meta(runner)
    |> add_comparison_time_label_result_indices_meta(runner)
    |> add_present_index_meta(runner.main_query)
    |> add_partial_time_labels_meta(runner.main_query)
    |> add_comparison_partial_time_labels_meta(runner)
    |> add_total_rows_meta(runner.main_query, runner.total_rows)
    |> Enum.sort_by(&elem(&1, 0))
  end

  defp add_imports_meta(meta, %Query{include: include} = query) do
    if include.imports or include.imports_meta do
      %{
        imports_included: query.include_imported,
        imports_skip_reason: query.skip_imported_reason,
        imports_warning: @imports_warnings[query.skip_imported_reason]
      }
      |> Map.reject(fn {_key, value} -> is_nil(value) end)
      |> Map.merge(meta)
    else
      meta
    end
  end

  defp add_metric_warnings_meta(meta, query) do
    warnings = metric_warnings(query)

    if map_size(warnings) > 0 do
      Map.put(meta, :metric_warnings, warnings)
    else
      meta
    end
  end

  defp add_empty_metrics_meta(meta, query) do
    if query.include.empty_metrics and "event:goal" not in query.dimensions do
      Map.put(
        meta,
        :empty_metrics,
        Enum.map(query.metrics, &Plausible.Stats.Metrics.default_value(&1, query))
      )
    else
      meta
    end
  end

  defp add_time_labels_meta(meta, %QueryRunner{main_query: query}) do
    if query.include.time_labels do
      Map.put(meta, :time_labels, Plausible.Stats.Time.time_labels(query))
    else
      meta
    end
  end

  defp add_comparison_time_labels_meta(meta, %QueryRunner{main_query: query} = runner) do
    if query.include.time_labels && query.include.compare do
      Map.put(
        meta,
        :comparison_time_labels,
        Plausible.Stats.Time.time_labels(runner.comparison_query)
      )
    else
      meta
    end
  end

  defp add_time_labels_result_indices_meta(meta, %QueryRunner{main_query: query} = runner) do
    time_labels = meta[:time_labels]

    if query.include.time_label_result_indices and is_list(time_labels) do
      Map.put(
        meta,
        :time_label_result_indices,
        result_indices_for_time_labels(time_labels, runner.main_results)
      )
    else
      meta
    end
  end

  defp add_comparison_time_label_result_indices_meta(
         meta,
         %QueryRunner{main_query: query} = runner
       ) do
    comp_time_labels = meta[:comparison_time_labels]

    if query.include.time_label_result_indices and is_list(comp_time_labels) do
      Map.put(
        meta,
        :comparison_time_label_result_indices,
        result_indices_for_time_labels(comp_time_labels, runner.comparison_results)
      )
    else
      meta
    end
  end

  defp add_present_index_meta(meta, query) do
    time_labels = meta[:time_labels]

    if query.include.present_index and is_list(time_labels) do
      Map.put(meta, :present_index, Plausible.Stats.Time.present_index(time_labels, query))
    else
      meta
    end
  end

  defp add_partial_time_labels_meta(meta, query) do
    time_labels = meta[:time_labels]

    if query.include.partial_time_labels and is_list(time_labels) do
      Map.put(
        meta,
        :partial_time_labels,
        Plausible.Stats.Time.partial_time_labels(time_labels, query)
      )
    else
      meta
    end
  end

  defp add_comparison_partial_time_labels_meta(meta, %QueryRunner{main_query: query} = runner) do
    comparison_time_labels = meta[:comparison_time_labels]

    if query.include.partial_time_labels and is_list(comparison_time_labels) do
      Map.put(
        meta,
        :comparison_partial_time_labels,
        Plausible.Stats.Time.partial_time_labels(comparison_time_labels, runner.comparison_query)
      )
    else
      meta
    end
  end

  defp add_total_rows_meta(meta, query, total_rows) do
    if query.include.total_rows do
      Map.put(meta, :total_rows, total_rows)
    else
      meta
    end
  end

  defp query(%QueryRunner{site: site, main_query: query}) do
    [
      site_id: site.domain,
      metrics: query.metrics,
      date_range: [
        to_iso8601(query.utc_time_range.first, query.timezone),
        to_iso8601(query.utc_time_range.last, query.timezone)
      ],
      comparison_date_range:
        if(query.include.compare,
          do: [
            to_iso8601(query.comparison_utc_time_range.first, query.timezone),
            to_iso8601(query.comparison_utc_time_range.last, query.timezone)
          ]
        ),
      filters: query.filters,
      dimensions: query.dimensions,
      order_by: query.order_by |> Enum.map(&Tuple.to_list/1),
      include: include(query) |> Map.filter(fn {_key, val} -> val end),
      pagination: query.pagination
    ]
    |> Enum.reject(fn {_k, v} -> is_nil(v) end)
  end

  defp include(query) do
    case query.include.compare do
      {:date_range, first, last} ->
        struct!(query.include, compare: [Date.to_iso8601(first), Date.to_iso8601(last)])

      _ ->
        query.include
    end
    |> Map.from_struct()
  end

  defp metric_warnings(%Query{} = query) do
    Enum.reduce(query.metrics, %{}, fn metric, acc ->
      case metric_warning(metric, query) do
        nil -> acc
        %{} = warning -> Map.put(acc, metric, warning)
      end
    end)
  end

  on_ee do
    @revenue_metrics Plausible.Stats.Goal.Revenue.revenue_metrics()

    @revenue_metrics_warnings %{
      revenue_goals_unavailable:
        "The owner of this site does not have access to the revenue metrics feature.",
      no_single_revenue_currency:
        "Revenue metrics are null as there are multiple currencies for the selected event:goals.",
      no_revenue_goals_matching:
        "Revenue metrics are null as there are no matching revenue goals."
    }

    defp metric_warning(metric, %Query{} = query)
         when metric in @revenue_metrics do
      if query.revenue_warning do
        %{
          code: query.revenue_warning,
          warning: @revenue_metrics_warnings[query.revenue_warning]
        }
      else
        nil
      end
    end
  end

  defp metric_warning(:scroll_depth, %Query{} = query) do
    if query.include_imported and not Enum.any?(query.imports_in_range, & &1.has_scroll_depth) do
      @no_imported_scroll_depth_warning
    end
  end

  defp metric_warning(:time_on_page, %Query{} = query) do
    case query.time_on_page_data do
      %{new_metric_visible: true, include_legacy_metric: true, cutoff: cutoff} ->
        cutoff_date =
          cutoff |> DateTime.shift_zone!(query.timezone) |> Calendar.strftime("%Y-%m-%d")

        %{
          code: :legacy_time_on_page_used,
          message:
            "This period includes data calculated with the legacy time on page method up to #{cutoff_date}"
        }

      _ ->
        nil
    end
  end

  # Native queries (i.e. ones that don't include imported data) allow querying bounce rate
  # with an `event:page` filter or dimension. In those cases, an `event:page` gets treated
  # as `visit:entry_page`. While theoretically possible, this behaviour does not yet exist
  # for imported data, which is why we're returning a metric warning here.
  defp metric_warning(:bounce_rate, %Query{} = query) do
    page_filter_or_dimension? =
      Filters.filtering_on_dimension?(query, "event:page", behavioral_filters: :ignore) or
        "event:page" in query.dimensions

    if query.include_imported and page_filter_or_dimension? do
      @no_imported_bounce_rate_warning
    end
  end

  defp metric_warning(_metric, _query), do: nil

  defp result_indices_for_time_labels(time_labels, results_list) do
    index_lookup_map =
      results_list
      |> Enum.with_index()
      |> Map.new(fn {%{dimensions: [dim]}, idx} -> {dim, idx} end)

    Enum.map(time_labels, &Map.get(index_lookup_map, &1))
  end

  defp to_iso8601(datetime, timezone) do
    datetime
    |> DateTime.shift_zone!(timezone)
    |> DateTime.to_iso8601(:extended)
  end
end

defimpl Jason.Encoder, for: Plausible.Stats.QueryResult do
  def encode(
        %Plausible.Stats.QueryResult{
          results: results,
          comparison_results: comparison_results,
          meta: meta,
          query: query
        },
        opts
      ) do
    if comparison_results do
      Jason.OrderedObject.new(
        results: results,
        comparison_results: comparison_results,
        meta: meta,
        query: query
      )
    else
      Jason.OrderedObject.new(results: results, meta: meta, query: query)
    end
    |> Jason.Encoder.encode(opts)
  end
end