Small build updates (#3546)

* Sites API

* Extract Revenue react api helpers

* !fixup

* Extract JS Money module to /extra

* Extract Revenue full build extras (tests pass for full)

* Update MIX_ENV=small mix test

* Remove dead code

* Add moduledocs

* Add credo config

* Trick dialyzer

* DRY revenue metrics

* Use more concise version of on_full_build macro

* Disable credo check
This commit is contained in:
hq1 2023-11-22 15:34:47 +01:00 committed by GitHub
parent af0b97e68a
commit 88e1d9dc28
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
46 changed files with 1298 additions and 1062 deletions

205
.credo.exs Normal file
View File

@ -0,0 +1,205 @@
# This file contains the configuration for Credo and you are probably reading
# this after creating it with `mix credo.gen.config`.
#
# If you find anything wrong or unclear in this file, please report an
# issue on GitHub: https://github.com/rrrene/credo/issues
#
%{
#
# You can have as many configs as you like in the `configs:` field.
configs: [
%{
#
# Run any config using `mix credo -C <name>`. If no config name is given
# "default" is used.
#
name: "default",
#
# These are the files included in the analysis:
files: %{
#
# You can give explicit globs or simply directories.
# In the latter case `**/*.{ex,exs}` will be used.
#
included: [
"lib/",
"test/",
"extra/"
],
excluded: [~r"/_build/", ~r"/deps/", ~r"/node_modules/"]
},
#
# Load and configure plugins here:
#
plugins: [],
#
# If you create your own checks, you must specify the source files for
# them here, so they can be loaded by Credo before running the analysis.
#
requires: [],
#
# If you want to enforce a style guide and need a more traditional linting
# experience, you can change `strict` to `true` below:
#
strict: false,
#
# To modify the timeout for parsing files, change this value:
#
parse_timeout: 5000,
#
# If you want to use uncolored output by default, you can change `color`
# to `false` below:
#
color: true,
#
# You can customize the parameters of any check by adding a second element
# to the tuple.
#
# To disable a check put `false` as second element:
#
# {Credo.Check.Design.DuplicatedCode, false}
#
checks: %{
enabled: [
#
## Consistency Checks
#
{Credo.Check.Consistency.ExceptionNames, []},
{Credo.Check.Consistency.LineEndings, []},
{Credo.Check.Consistency.ParameterPatternMatching, []},
{Credo.Check.Consistency.SpaceAroundOperators, []},
{Credo.Check.Consistency.SpaceInParentheses, []},
{Credo.Check.Consistency.TabsOrSpaces, []},
#
## Design Checks
#
# You can customize the priority of any check
# Priority values are: `low, normal, high, higher`
#
{Credo.Check.Design.AliasUsage,
[priority: :low, if_nested_deeper_than: 2, if_called_more_often_than: 0]},
# You can also customize the exit_status of each check.
# If you don't want TODO comments to cause `mix credo` to fail, just
# set this value to 0 (zero).
#
{Credo.Check.Design.TagTODO, [exit_status: 2]},
{Credo.Check.Design.TagFIXME, []},
#
## Readability Checks
#
{Credo.Check.Readability.AliasOrder, []},
{Credo.Check.Readability.FunctionNames, []},
{Credo.Check.Readability.LargeNumbers, []},
{Credo.Check.Readability.MaxLineLength, [priority: :low, max_length: 120]},
{Credo.Check.Readability.ModuleAttributeNames, []},
{Credo.Check.Readability.ModuleDoc, []},
{Credo.Check.Readability.ModuleNames, []},
{Credo.Check.Readability.ParenthesesInCondition, []},
{Credo.Check.Readability.ParenthesesOnZeroArityDefs, []},
{Credo.Check.Readability.PipeIntoAnonymousFunctions, []},
{Credo.Check.Readability.PredicateFunctionNames, []},
{Credo.Check.Readability.PreferImplicitTry, []},
{Credo.Check.Readability.RedundantBlankLines, []},
{Credo.Check.Readability.Semicolons, []},
{Credo.Check.Readability.SpaceAfterCommas, []},
{Credo.Check.Readability.StringSigils, []},
{Credo.Check.Readability.TrailingBlankLine, []},
{Credo.Check.Readability.TrailingWhiteSpace, []},
{Credo.Check.Readability.UnnecessaryAliasExpansion, []},
{Credo.Check.Readability.VariableNames, []},
{Credo.Check.Readability.WithSingleClause, []},
#
## Refactoring Opportunities
#
{Credo.Check.Refactor.Apply, []},
{Credo.Check.Refactor.CondStatements, []},
{Credo.Check.Refactor.CyclomaticComplexity, []},
{Credo.Check.Refactor.FunctionArity, []},
{Credo.Check.Refactor.LongQuoteBlocks, []},
{Credo.Check.Refactor.MatchInCondition, []},
{Credo.Check.Refactor.MapJoin, []},
{Credo.Check.Refactor.NegatedConditionsInUnless, []},
{Credo.Check.Refactor.NegatedConditionsWithElse, false},
{Credo.Check.Refactor.Nesting, []},
{Credo.Check.Refactor.UnlessWithElse, []},
{Credo.Check.Refactor.WithClauses, []},
{Credo.Check.Refactor.FilterFilter, []},
{Credo.Check.Refactor.RejectReject, []},
{Credo.Check.Refactor.RedundantWithClauseResult, []},
#
## Warnings
#
{Credo.Check.Warning.ApplicationConfigInModuleAttribute, []},
{Credo.Check.Warning.BoolOperationOnSameValues, []},
{Credo.Check.Warning.ExpensiveEmptyEnumCheck, []},
{Credo.Check.Warning.IExPry, []},
{Credo.Check.Warning.IoInspect, []},
{Credo.Check.Warning.OperationOnSameValues, []},
{Credo.Check.Warning.OperationWithConstantResult, []},
{Credo.Check.Warning.RaiseInsideRescue, []},
{Credo.Check.Warning.SpecWithStruct, []},
{Credo.Check.Warning.WrongTestFileExtension, []},
{Credo.Check.Warning.UnusedEnumOperation, []},
{Credo.Check.Warning.UnusedFileOperation, []},
{Credo.Check.Warning.UnusedKeywordOperation, []},
{Credo.Check.Warning.UnusedListOperation, []},
{Credo.Check.Warning.UnusedPathOperation, []},
{Credo.Check.Warning.UnusedRegexOperation, []},
{Credo.Check.Warning.UnusedStringOperation, []},
{Credo.Check.Warning.UnusedTupleOperation, []},
{Credo.Check.Warning.UnsafeExec, []}
],
disabled: [
#
# Checks scheduled for next check update (opt-in for now, just replace `false` with `[]`)
#
# Controversial and experimental checks (opt-in, just move the check to `:enabled`
# and be sure to use `mix credo --strict` to see low priority checks)
#
{Credo.Check.Consistency.MultiAliasImportRequireUse, []},
{Credo.Check.Consistency.UnusedVariableNames, []},
{Credo.Check.Design.DuplicatedCode, []},
{Credo.Check.Design.SkipTestWithoutComment, []},
{Credo.Check.Readability.AliasAs, []},
{Credo.Check.Readability.BlockPipe, []},
{Credo.Check.Readability.ImplTrue, []},
{Credo.Check.Readability.MultiAlias, []},
{Credo.Check.Readability.NestedFunctionCalls, []},
{Credo.Check.Readability.SeparateAliasRequire, []},
{Credo.Check.Readability.SingleFunctionToBlockPipe, []},
{Credo.Check.Readability.SinglePipe, []},
{Credo.Check.Readability.Specs, []},
{Credo.Check.Readability.StrictModuleLayout, []},
{Credo.Check.Readability.WithCustomTaggedTuple, []},
{Credo.Check.Refactor.ABCSize, []},
{Credo.Check.Refactor.AppendSingleItem, []},
{Credo.Check.Refactor.DoubleBooleanNegation, []},
{Credo.Check.Refactor.FilterReject, []},
{Credo.Check.Refactor.IoPuts, []},
{Credo.Check.Refactor.MapMap, []},
{Credo.Check.Refactor.ModuleDependencies, []},
{Credo.Check.Refactor.NegatedIsNil, []},
{Credo.Check.Refactor.PipeChainStart, []},
{Credo.Check.Refactor.RejectFilter, []},
{Credo.Check.Refactor.VariableRebinding, []},
{Credo.Check.Warning.LazyLogging, []},
{Credo.Check.Warning.LeakyEnvironment, []},
{Credo.Check.Warning.MapGetUnsafePass, []},
{Credo.Check.Warning.MixEnv, []},
{Credo.Check.Warning.UnsafeToAtom, []}
# {Credo.Check.Refactor.MapInto, []},
#
# Custom checks can be created using `mix credo.gen.check`.
#
]
}
}
]
}

View File

@ -7,4 +7,3 @@ export default function Money({ formatted }) {
return "-"
}
}

View File

@ -7,16 +7,17 @@ import { CR_METRIC } from '../reports/metrics';
import ListReport from '../reports/list';
export default function Conversions(props) {
const {site, query} = props
const { site, query } = props
function fetchConversions() {
return api.get(url.apiPath(site, '/conversions'), query, {limit: 9})
return api.get(url.apiPath(site, '/conversions'), query, { limit: 9 })
}
function getFilterFor(listItem) {
return {goal: escapeFilterValue(listItem.name)}
return { goal: escapeFilterValue(listItem.name) }
}
/*global BUILD_EXTRA*/
return (
<ListReport
fetchData={fetchConversions}
@ -24,11 +25,11 @@ export default function Conversions(props) {
keyLabel="Goal"
onClick={props.onGoalFilterClick}
metrics={[
{name: 'visitors', label: "Uniques", plot: true},
{name: 'events', label: "Total", hiddenOnMobile: true},
{ name: 'visitors', label: "Uniques", plot: true },
{ name: 'events', label: "Total", hiddenOnMobile: true },
CR_METRIC,
{name: 'total_revenue', label: 'Revenue', hiddenOnMobile: true},
{name: 'average_revenue', label: 'Average', hiddenOnMobile: true}
BUILD_EXTRA && { name: 'total_revenue', label: 'Revenue', hiddenOnMobile: true },
BUILD_EXTRA && { name: 'average_revenue', label: 'Average', hiddenOnMobile: true }
]}
detailsLink={url.sitePath(site, '/conversions')}
maybeHideDetails={true}

View File

@ -1,199 +0,0 @@
import React from 'react';
import { Link } from 'react-router-dom'
import * as storage from '../../util/storage'
import Bar from '../bar'
import numberFormatter from '../../util/number-formatter'
import * as api from '../../api'
import Money from './money'
import { isValidHttpUrl, trimURL } from '../../util/url'
const MOBILE_UPPER_WIDTH = 767
const DEFAULT_WIDTH = 1080
const BREAKDOWN_LIMIT = 100
export default class PropertyBreakdown extends React.Component {
constructor(props) {
super(props)
let propKey = props.goal.prop_names[0]
this.storageKey = 'goalPropTab__' + props.site.domain + props.goal.name
const storedKey = storage.getItem(this.storageKey)
if (props.goal.prop_names.includes(storedKey)) {
propKey = storedKey
}
if (props.query.filters['props']) {
propKey = Object.keys(props.query.filters['props'])[0]
}
this.state = {
loading: true,
propKey: propKey,
viewport: DEFAULT_WIDTH,
breakdown: [],
page: 1,
moreResultsAvailable: false
}
this.handleResize = this.handleResize.bind(this);
this.fetch = this.fetch.bind(this)
this.fetchAndReplace = this.fetchAndReplace.bind(this)
this.fetchAndConcat = this.fetchAndConcat.bind(this)
}
componentDidMount() {
window.addEventListener('resize', this.handleResize, false);
this.handleResize();
this.fetchAndReplace()
if (this.props.query.period === 'realtime') {
document.addEventListener('tick', this.fetchAndReplace)
}
}
componentWillUnmount() {
window.removeEventListener('resize', this.handleResize, false);
document.removeEventListener('tick', this.fetchAndReplace)
}
handleResize() {
this.setState({ viewport: window.innerWidth });
}
fetch({ concat }) {
if (!this.props.query.filters['goal']) return
api.get(`/api/stats/${encodeURIComponent(this.props.site.domain)}/property/${encodeURIComponent(this.state.propKey)}`, this.props.query, { limit: BREAKDOWN_LIMIT, page: this.state.page })
.then((res) => {
let breakdown = concat ? this.state.breakdown.concat(res) : res
this.setState(() => ({
loading: false,
breakdown: breakdown,
moreResultsAvailable: res.length >= BREAKDOWN_LIMIT
}))
})
}
fetchAndReplace() {
this.fetch({ concat: false })
}
fetchAndConcat() {
this.fetch({ concat: true })
}
loadMore() {
this.setState({ loading: true, page: this.state.page + 1 }, this.fetchAndConcat.bind(this))
}
renderUrl(value) {
if (isValidHttpUrl(value.name)) {
return (
<a target="_blank" href={value.name} rel="noreferrer" className="hidden group-hover:block">
<svg className="inline h-4 w-4 ml-1 -mt-1 text-gray-600 dark:text-gray-400" fill="currentColor" viewBox="0 0 20 20"><path d="M11 3a1 1 0 100 2h2.586l-6.293 6.293a1 1 0 101.414 1.414L15 6.414V9a1 1 0 102 0V4a1 1 0 00-1-1h-5z"></path><path d="M5 5a2 2 0 00-2 2v8a2 2 0 002 2h8a2 2 0 002-2v-3a1 1 0 10-2 0v3H5V7h3a1 1 0 000-2H5z"></path></svg>
</a>
)
}
return null
}
renderPropContent(value, query) {
return (
<span className="flex px-2 py-1.5 group dark:text-gray-300 relative z-9 break-all">
<Link
to={{ pathname: window.location.pathname, search: query.toString() }}
className="md:truncate hover:underline block"
>
{trimURL(value.name, 100)}
</Link>
{this.renderUrl(value)}
</span>
)
}
renderPropValue(value) {
const query = new URLSearchParams(window.location.search)
query.set('props', JSON.stringify({ [this.state.propKey]: value.name }))
const { viewport } = this.state;
return (
<div className="flex items-center justify-between my-2" key={value.name}>
<div className="flex-1 truncate">
<Bar
count={value.unique_conversions}
plot="unique_conversions"
all={this.state.breakdown}
bg="bg-red-50 dark:bg-gray-500 dark:bg-opacity-15"
>
{this.renderPropContent(value, query)}
</Bar>
</div>
<div className="flex dark:text-gray-200">
<span className="font-medium inline-block w-20 text-right">{numberFormatter(value.unique_conversions)}</span>
{
viewport > MOBILE_UPPER_WIDTH ?
(
<span
className="font-medium inline-block w-20 text-right"
>{numberFormatter(value.total_conversions)}
</span>
)
: null
}
<span className="font-medium inline-block w-20 text-right">{numberFormatter(value.conversion_rate)}%</span>
{this.props.renderRevenueColumn && <span className="hidden md:inline-block md:w-20 font-medium text-right"><Money formatted={value.total_revenue} /></span>}
{this.props.renderRevenueColumn && <span className="hidden md:inline-block md:w-20 font-medium text-right"><Money formatted={value.average_revenue} /></span>}
</div>
</div>
)
}
changePropKey(newKey) {
storage.setItem(this.storageKey, newKey)
this.setState({ propKey: newKey, loading: true, breakdown: [], page: 1, moreResultsAvailable: false }, this.fetchAndReplace)
}
renderLoading() {
if (this.state.loading) {
return <div className="px-4 py-2"><div className="loading sm mx-auto"><div></div></div></div>
} else if (this.state.moreResultsAvailable && this.props.query.period !== 'realtime') {
return (
<div className="w-full text-center my-4">
<button onClick={this.loadMore.bind(this)} type="button" className="button">
Load more
</button>
</div>
)
}
}
renderBody() {
return this.state.breakdown.map((propValue) => this.renderPropValue(propValue))
}
renderPill(key) {
const isActive = this.state.propKey === key
if (isActive) {
return <li key={key} className="inline-block h-5 text-indigo-700 dark:text-indigo-500 font-bold mr-2 active-prop-heading">{key}</li>
} else {
return <li key={key} className="hover:text-indigo-600 cursor-pointer mr-2" onClick={this.changePropKey.bind(this, key)}>{key}</li>
}
}
render() {
return (
<div className="w-full pl-3 sm:pl-6 mt-4">
<div className="flex-col sm:flex-row flex items-center pb-1">
<span className="text-xs font-bold text-gray-600 dark:text-gray-300 self-start sm:self-auto mb-1 sm:mb-0">Breakdown by:</span>
<ul className="flex flex-wrap font-medium text-xs text-gray-500 dark:text-gray-400 leading-5 pl-1 sm:pl-2">
{this.props.goal.prop_names.map(this.renderPill.bind(this))}
</ul>
</div>
{this.renderBody()}
{this.renderLoading()}
</div>
)
}
}

View File

@ -12,7 +12,7 @@ export default function Properties(props) {
const { site, query } = props
const propKeyStorageName = `prop_key__${site.domain}`
const propKeyStorageNameForGoal = `${query.filters.goal}__prop_key__${site.domain}`
const [propKey, setPropKey] = useState(choosePropKey())
useEffect(() => {
@ -42,7 +42,7 @@ export default function Properties(props) {
const storedForGoal = storage.getItem(propKeyStorageNameForGoal)
if (storedForGoal) { return storedForGoal }
}
return storage.getItem(propKeyStorageName)
}
@ -69,6 +69,7 @@ export default function Properties(props) {
}
}
/*global BUILD_EXTRA*/
function renderBreakdown() {
return (
<ListReport
@ -76,11 +77,11 @@ export default function Properties(props) {
getFilterFor={getFilterFor}
keyLabel={propKey}
metrics={[
{name: 'visitors', label: 'Visitors', plot: true},
{name: 'events', label: 'Events', hiddenOnMobile: true},
{ name: 'visitors', label: 'Visitors', plot: true },
{ name: 'events', label: 'Events', hiddenOnMobile: true },
query.filters.goal ? CR_METRIC : PERCENTAGE_METRIC,
{name: 'total_revenue', label: 'Revenue', hiddenOnMobile: true},
{name: 'average_revenue', label: 'Average', hiddenOnMobile: true}
BUILD_EXTRA && { name: 'total_revenue', label: 'Revenue', hiddenOnMobile: true },
BUILD_EXTRA && { name: 'average_revenue', label: 'Average', hiddenOnMobile: true }
]}
detailsLink={url.sitePath(site, `/custom-prop-values/${propKey}`)}
maybeHideDetails={true}
@ -91,16 +92,16 @@ export default function Properties(props) {
)
}
const getFilterFor = (listItem) => { return {'props': JSON.stringify({[propKey]: escapeFilterValue(listItem.name)})} }
const comboboxValues = propKey ? [{value: propKey, label: propKey}] : []
const getFilterFor = (listItem) => { return { 'props': JSON.stringify({ [propKey]: escapeFilterValue(listItem.name) }) } }
const comboboxValues = propKey ? [{ value: propKey, label: propKey }] : []
const boxClass = 'pl-2 pr-8 py-1 bg-transparent dark:text-gray-300 rounded-md shadow-sm border border-gray-300 dark:border-gray-500'
return (
<div className="w-full mt-4">
<div>
<Combobox isDisabled={!!query.filters.props} boxClass={boxClass} fetchOptions={fetchPropKeyOptions()} singleOption={true} values={comboboxValues} onSelect={onPropKeySelect()} placeholder={'Select a property'} />
</div>
{ propKey && renderBreakdown() }
<div>
<Combobox isDisabled={!!query.filters.props} boxClass={boxClass} fetchOptions={fetchPropKeyOptions()} singleOption={true} values={comboboxValues} onSelect={onPropKeySelect()} placeholder={'Select a property'} />
</div>
{propKey && renderBreakdown()}
</div>
)
}
}

View File

@ -1,15 +1,26 @@
import React, { useEffect, useState } from "react";
import { Link } from 'react-router-dom'
import { withRouter } from 'react-router-dom'
import Money from "../behaviours/money";
import Modal from './modal'
import * as api from '../../api'
import * as url from "../../util/url";
import numberFormatter from '../../util/number-formatter'
import {parseQuery} from '../../query'
import { parseQuery } from '../../query'
import { escapeFilterValue } from '../../util/filters'
/*global BUILD_EXTRA*/
/*global require*/
function maybeRequire() {
if (BUILD_EXTRA) {
return require('../../extra/money')
} else {
return { default: null }
}
}
const Money = maybeRequire().default
function ConversionsModal(props) {
const site = props.site
const query = parseQuery(props.location.search, site)
@ -24,7 +35,7 @@ function ConversionsModal(props) {
}, [])
function fetchData() {
api.get(url.apiPath(site, `/conversions`), query, {limit: 100, page})
api.get(url.apiPath(site, `/conversions`), query, { limit: 100, page })
.then((res) => {
setLoading(false)
setList(list.concat(res))
@ -59,16 +70,16 @@ function ConversionsModal(props) {
<tr className="text-sm dark:text-gray-200" key={listItem.name}>
<td className="p-2">
<Link
to={{pathname: url.siteBasePath(site), search: filterSearchLink(listItem)}}
to={{ pathname: url.siteBasePath(site), search: filterSearchLink(listItem) }}
className="hover:underline block truncate">
{listItem.name}
{listItem.name}
</Link>
</td>
<td className="p-2 w-24 font-medium" align="right">{numberFormatter(listItem.visitors)}</td>
<td className="p-2 w-24 font-medium" align="right">{numberFormatter(listItem.events)}</td>
<td className="p-2 w-24 font-medium" align="right">{listItem.conversion_rate}%</td>
{ hasRevenue && <td className="p-2 w-24 font-medium" align="right"><Money formatted={listItem.total_revenue}/></td> }
{ hasRevenue && <td className="p-2 w-24 font-medium" align="right"><Money formatted={listItem.average_revenue}/></td> }
{hasRevenue && <td className="p-2 w-24 font-medium" align="right"><Money formatted={listItem.total_revenue} /></td>}
{hasRevenue && <td className="p-2 w-24 font-medium" align="right"><Money formatted={listItem.average_revenue} /></td>}
</tr>
)
}
@ -78,7 +89,7 @@ function ConversionsModal(props) {
}
function renderBody() {
const hasRevenue = list.some((goal) => goal.total_revenue)
const hasRevenue = BUILD_EXTRA && list.some((goal) => goal.total_revenue)
return (
<>
@ -98,7 +109,7 @@ function ConversionsModal(props) {
</tr>
</thead>
<tbody>
{ list.map((item) => renderListItem(item, hasRevenue)) }
{list.map((item) => renderListItem(item, hasRevenue))}
</tbody>
</table>
</main>
@ -108,9 +119,9 @@ function ConversionsModal(props) {
return (
<Modal site={site}>
{ renderBody() }
{ loading && renderLoading() }
{ !loading && moreResultsAvailable && renderLoadMore() }
{renderBody()}
{loading && renderLoading()}
{!loading && moreResultsAvailable && renderLoadMore()}
</Modal>
)
}

View File

@ -1,7 +1,6 @@
import React, { useEffect, useState } from "react";
import { Link } from 'react-router-dom'
import { withRouter } from 'react-router-dom'
import Money from "../behaviours/money";
import Modal from './modal'
import * as api from '../../api'
@ -11,6 +10,18 @@ import { parseQuery } from '../../query'
import { specialTitleWhenGoalFilter } from "../behaviours/goal-conversions";
import { escapeFilterValue } from "../../util/filters"
/*global BUILD_EXTRA*/
/*global require*/
function maybeRequire() {
if (BUILD_EXTRA) {
return require('../../extra/money')
} else {
return { default: null }
}
}
const Money = maybeRequire().default
function PropsModal(props) {
const site = props.site
const query = parseQuery(props.location.search, site)
@ -81,11 +92,11 @@ function PropsModal(props) {
}
function renderBody() {
const hasRevenue = list.some((prop) => prop.total_revenue)
const hasRevenue = BUILD_EXTRA && list.some((prop) => prop.total_revenue)
return (
<>
<h1 className="text-xl font-bold dark:text-gray-100">{ specialTitleWhenGoalFilter(query, 'Custom Property Breakdown') }</h1>
<h1 className="text-xl font-bold dark:text-gray-100">{specialTitleWhenGoalFilter(query, 'Custom Property Breakdown')}</h1>
<div className="my-4 border-b border-gray-300"></div>
<main className="modal__content">

View File

@ -1,6 +1,17 @@
import numberFormatter from "../../util/number-formatter"
import React from "react"
import Money from "../behaviours/money"
/*global BUILD_EXTRA*/
/*global require*/
function maybeRequire() {
if (BUILD_EXTRA) {
return require('../../extra/money')
} else {
return { default: null }
}
}
const Money = maybeRequire().default
export const VISITORS_METRIC = {
name: 'visitors',
@ -32,7 +43,7 @@ export function displayMetricValue(value, metric) {
} else if (metric === CR_METRIC) {
return `${value}%`
} else {
return <span tooltip={value}>{ numberFormatter(value) }</span>
return <span tooltip={value}>{numberFormatter(value)}</span>
}
}
@ -40,4 +51,4 @@ export function metricLabelFor(metric, query) {
if (metric.realtimeLabel && query.period === 'realtime') { return metric.realtimeLabel }
if (metric.goalFilterLabel && query.filters.goal) { return metric.goalFilterLabel }
return metric.label
}
}

View File

@ -0,0 +1,22 @@
defmodule Plausible.Goal.Revenue do
@moduledoc """
Currency specific functions for revenue goals
"""
def revenue?(%Plausible.Goal{currency: currency}) do
!!currency
end
def valid_currencies() do
Ecto.Enum.dump_values(Plausible.Goal, :currency)
end
def currency_options() do
options =
for code <- valid_currencies() do
{code, "#{code} - #{Cldr.Currency.display_name!(code)}"}
end
options
end
end

View File

@ -0,0 +1,37 @@
defmodule Plausible.Ingestion.Event.Revenue do
@moduledoc """
Revenue specific functions for the ingestion scope
"""
def get_revenue_attrs(
%Plausible.Ingestion.Event{request: %{revenue_source: %Money{} = revenue_source}} = event
) do
matching_goal =
Enum.find(event.site.revenue_goals, &(&1.event_name == event.clickhouse_event_attrs.name))
cond do
is_nil(matching_goal) ->
%{}
matching_goal.currency == revenue_source.currency ->
%{
revenue_source_amount: Money.to_decimal(revenue_source),
revenue_source_currency: to_string(revenue_source.currency),
revenue_reporting_amount: Money.to_decimal(revenue_source),
revenue_reporting_currency: to_string(revenue_source.currency)
}
matching_goal.currency != revenue_source.currency ->
converted = Money.to_currency!(revenue_source, matching_goal.currency)
%{
revenue_source_amount: Money.to_decimal(revenue_source),
revenue_source_currency: to_string(revenue_source.currency),
revenue_reporting_amount: Money.to_decimal(converted),
revenue_reporting_currency: to_string(converted.currency)
}
end
end
def get_revenue_attrs(_event), do: %{}
end

View File

@ -0,0 +1,35 @@
defmodule Plausible.Ingestion.Request.Revenue do
@moduledoc """
Revenue specific functions for the ingestion scope
"""
def put_revenue_source(%Ecto.Changeset{} = changeset, %{} = request_body) do
with revenue_source <- request_body["revenue"] || request_body["$"],
%{"amount" => _, "currency" => _} = revenue_source <-
Plausible.Helpers.JSON.decode_or_fallback(revenue_source) do
parse_revenue_source(changeset, revenue_source)
else
_any -> changeset
end
end
@valid_currencies Plausible.Goal.Revenue.valid_currencies()
defp parse_revenue_source(changeset, %{"amount" => amount, "currency" => currency}) do
with true <- currency in @valid_currencies,
{%Decimal{} = amount, _rest} <- parse_decimal(amount),
%Money{} = amount <- Money.new(currency, amount) do
Ecto.Changeset.put_change(changeset, :revenue_source, amount)
else
_any -> changeset
end
end
defp parse_decimal(value) do
case value do
value when is_binary(value) -> Decimal.parse(value)
value when is_float(value) -> {Decimal.from_float(value), nil}
value when is_integer(value) -> {Decimal.new(value), nil}
_any -> :error
end
end
end

View File

@ -0,0 +1,97 @@
defmodule Plausible.Stats.Goal.Revenue do
@moduledoc """
Revenue specific functions for the stats scope
"""
import Ecto.Query
@revenue_metrics [:average_revenue, :total_revenue]
def revenue_metrics() do
@revenue_metrics
end
def total_revenue_query(query) do
from(e in query,
select_merge: %{
total_revenue:
fragment("toDecimal64(sum(?) * any(_sample_factor), 3)", e.revenue_reporting_amount)
}
)
end
def average_revenue_query(query) do
from(e in query,
select_merge: %{
average_revenue:
fragment("toDecimal64(avg(?) * any(_sample_factor), 3)", e.revenue_reporting_amount)
}
)
end
@spec get_revenue_tracking_currency(Plausible.Site.t(), Plausible.Stats.Query.t(), [atom()]) ::
{atom() | nil, [atom()]}
@doc """
Returns the common currency for the goal filters in a query. If there are no
goal filters, multiple currencies or the site owner does not have access to
revenue goals, `nil` is returned and revenue metrics are dropped.
Aggregating revenue data works only for same currency goals. If the query is
filtered by goals with different currencies, for example, one USD and other
EUR, revenue metrics are dropped.
"""
def get_revenue_tracking_currency(site, query, metrics) do
goal_filters =
case query.filters do
%{"event:goal" => {:is, {_, goal_name}}} -> [goal_name]
%{"event:goal" => {:member, list}} -> Enum.map(list, fn {_, goal_name} -> goal_name end)
_any -> []
end
requested_revenue_metrics? = Enum.any?(metrics, &(&1 in @revenue_metrics))
filtering_by_goal? = Enum.any?(goal_filters)
revenue_goals_available? = fn ->
site = Plausible.Repo.preload(site, :owner)
Plausible.Billing.Feature.RevenueGoals.check_availability(site.owner) == :ok
end
if requested_revenue_metrics? && filtering_by_goal? && revenue_goals_available?.() do
revenue_goals_currencies =
Plausible.Repo.all(
from rg in Ecto.assoc(site, :revenue_goals),
where: rg.event_name in ^goal_filters,
select: rg.currency,
distinct: true
)
if length(revenue_goals_currencies) == 1,
do: {List.first(revenue_goals_currencies), metrics},
else: {nil, metrics -- @revenue_metrics}
else
{nil, metrics -- @revenue_metrics}
end
end
def cast_revenue_metrics_to_money([%{goal: _goal} | _rest] = results, revenue_goals)
when is_list(revenue_goals) do
for result <- results do
if matching_goal = Enum.find(revenue_goals, &(&1.event_name == result.goal)) do
cast_revenue_metrics_to_money(result, matching_goal.currency)
else
result
end
end
end
def cast_revenue_metrics_to_money(results, currency) when is_map(results) do
for {metric, value} <- results, into: %{} do
if metric in @revenue_metrics && currency do
{metric, Money.new!(value || 0, currency)}
else
{metric, value}
end
end
end
def cast_revenue_metrics_to_money(results, _), do: results
end

View File

@ -0,0 +1,27 @@
defmodule PlausibleWeb.Controllers.API.Revenue do
@moduledoc """
Revenue specific functions for the API scope
"""
@revenue_metrics Plausible.Stats.Goal.Revenue.revenue_metrics()
def format_revenue_metric({metric, value}) do
if metric in @revenue_metrics do
{metric, format_money(value)}
else
{metric, value}
end
end
def format_money(value) do
case value do
%Money{} ->
%{
short: Money.to_string!(value, format: :short, fractional_digits: 1),
long: Money.to_string!(value)
}
_any ->
value
end
end
end

View File

@ -12,18 +12,22 @@ defmodule Plausible do
end
end
defmacro on_small_build(do: block) do
if Mix.env() in @small_builds do
quote do
unquote(block)
end
end
defmacro on_full_build(clauses) do
do_on_full_build(clauses)
end
defmacro on_full_build(do: block) do
def do_on_full_build(do: block) do
do_on_full_build(do: block, else: nil)
end
def do_on_full_build(do: do_block, else: else_block) do
if Mix.env() not in @small_builds do
quote do
unquote(block)
unquote(do_block)
end
else
quote do
unquote(else_block)
end
end
end
@ -31,8 +35,10 @@ defmodule Plausible do
defmacro full_build?() do
full_build? = Mix.env() not in @small_builds
# Tricking dialyzer as per:
# https://github.com/elixir-lang/elixir/blob/v1.12.3/lib/elixir/lib/gen_server.ex#L771-L778
quote do
unquote(full_build?)
:erlang.phash2(1, 1) == 0 and unquote(full_build?)
end
end

View File

@ -231,14 +231,12 @@ defmodule Plausible.Billing.Quota do
def features_usage(%Site{} = site) do
props_exist = is_list(site.allowed_event_props) && site.allowed_event_props != []
on_full_build do
funnels_exist =
funnels_exist =
on_full_build do
Plausible.Repo.exists?(from f in Plausible.Funnel, where: f.site_id == ^site.id)
end
on_small_build do
funnels_exist = false
end
else
false
end
revenue_goals_exist =
Plausible.Repo.exists?(

View File

@ -8,13 +8,12 @@ defmodule Plausible.Goal do
schema "goals" do
field :event_name, :string
field :page_path, :string
field :currency, Ecto.Enum, values: Money.Currency.known_current_currencies()
on_full_build do
field :currency, Ecto.Enum, values: Money.Currency.known_current_currencies()
many_to_many :funnels, Plausible.Funnel, join_through: Plausible.Funnel.Step
end
on_small_build do
else
field :currency, :string, virtual: true, default: nil
field :funnels, {:array, :map}, virtual: true, default: []
end
@ -23,26 +22,11 @@ defmodule Plausible.Goal do
timestamps()
end
def revenue?(%__MODULE__{currency: currency}) do
!!currency
end
def valid_currencies do
Ecto.Enum.dump_values(__MODULE__, :currency)
end
def currency_options do
options =
for code <- valid_currencies() do
{code, "#{code} - #{Cldr.Currency.display_name!(code)}"}
end
options
end
@fields [:id, :site_id, :event_name, :page_path] ++ on_full_build(do: [:currency], else: [])
def changeset(goal, attrs \\ %{}) do
goal
|> cast(attrs, [:id, :site_id, :event_name, :page_path, :currency])
|> cast(attrs, @fields)
|> validate_required([:site_id])
|> cast_assoc(:site)
|> update_leading_slash()
@ -93,7 +77,7 @@ defmodule Plausible.Goal do
end
defp maybe_drop_currency(changeset) do
if get_field(changeset, :page_path) do
if full_build?() and get_field(changeset, :page_path) do
delete_change(changeset, :currency)
else
changeset

View File

@ -1,11 +1,13 @@
defmodule Plausible.Goals do
use Plausible
use Plausible.Repo
use Plausible.Funnel.Const
import Ecto.Query
alias Plausible.Goal
alias Ecto.Multi
use Plausible.Funnel.Const
@spec create(Plausible.Site.t(), map(), Keyword.t()) ::
{:ok, Goal.t()} | {:error, Ecto.Changeset.t()} | {:error, :upgrade_required}
@doc """
@ -15,15 +17,17 @@ defmodule Plausible.Goals do
refreshed by the sites cache, as revenue goals are used during ingestion.
"""
def create(site, params, opts \\ []) do
now = Keyword.get(opts, :now, DateTime.utc_now())
upsert? = Keyword.get(opts, :upsert?, false)
Repo.transaction(fn ->
case insert_goal(site, params, upsert?) do
{:ok, :insert, goal} ->
# credo:disable-for-next-line Credo.Check.Refactor.Nesting
if Goal.revenue?(goal) do
Plausible.Site.Cache.touch_site!(site, now)
on_full_build do
now = Keyword.get(opts, :now, DateTime.utc_now())
# credo:disable-for-next-line Credo.Check.Refactor.Nesting
if Plausible.Goal.Revenue.revenue?(goal) do
Plausible.Site.Cache.touch_site!(site, now)
end
end
Repo.preload(goal, :site)
@ -118,22 +122,13 @@ defmodule Plausible.Goals do
end
def delete(id, site_id) do
on_full_build do
goal_query =
from(g in Goal,
where: g.id == ^id,
where: g.site_id == ^site_id,
preload: [funnels: :steps]
)
end
goal_query =
from(g in Goal,
where: g.id == ^id,
where: g.site_id == ^site_id
)
on_small_build do
goal_query =
from(g in Goal,
where: g.id == ^id,
where: g.site_id == ^site_id
)
end
goal_query = on_full_build(do: preload(goal_query, funnels: :steps), else: goal_query)
result =
Multi.new()

View File

@ -0,0 +1,15 @@
defmodule Plausible.Helpers.JSON do
@moduledoc """
Common helpers for JSON handling
"""
def decode_or_fallback(raw) do
with raw when is_binary(raw) <- raw,
{:ok, %{} = decoded} <- Jason.decode(raw) do
decoded
else
already_a_map when is_map(already_a_map) -> already_a_map
_any -> %{}
end
end
end

View File

@ -5,6 +5,7 @@ defmodule Plausible.Ingestion.Event do
are uniformly either buffered in batches (to Clickhouse) or dropped
(e.g. due to spam blocklist) from the processing pipeline.
"""
use Plausible
alias Plausible.Ingestion.Request
alias Plausible.ClickhouseEventV2
alias Plausible.Site.GateKeeper
@ -220,36 +221,15 @@ defmodule Plausible.Ingestion.Event do
defp put_props(%__MODULE__{} = event), do: event
defp put_revenue(%__MODULE__{request: %{revenue_source: %Money{} = revenue_source}} = event) do
matching_goal =
Enum.find(event.site.revenue_goals, &(&1.event_name == event.clickhouse_event_attrs.name))
cond do
is_nil(matching_goal) ->
event
matching_goal.currency == revenue_source.currency ->
update_attrs(event, %{
revenue_source_amount: Money.to_decimal(revenue_source),
revenue_source_currency: to_string(revenue_source.currency),
revenue_reporting_amount: Money.to_decimal(revenue_source),
revenue_reporting_currency: to_string(revenue_source.currency)
})
matching_goal.currency != revenue_source.currency ->
converted = Money.to_currency!(revenue_source, matching_goal.currency)
update_attrs(event, %{
revenue_source_amount: Money.to_decimal(revenue_source),
revenue_source_currency: to_string(revenue_source.currency),
revenue_reporting_amount: Money.to_decimal(converted),
revenue_reporting_currency: to_string(converted.currency)
})
defp put_revenue(event) do
on_full_build do
attrs = Plausible.Ingestion.Event.Revenue.get_revenue_attrs(event)
update_attrs(event, attrs)
else
event
end
end
defp put_revenue(event), do: event
defp put_salts(%__MODULE__{} = event) do
%{event | salts: Plausible.Session.Salts.fetch()}
end

View File

@ -23,6 +23,7 @@ defmodule Plausible.Ingestion.Request do
"""
use Ecto.Schema
use Plausible
alias Ecto.Changeset
@max_url_size 2_000
@ -40,7 +41,11 @@ defmodule Plausible.Ingestion.Request do
field :hash_mode, :integer
field :pathname, :string
field :props, :map
field :revenue_source, :map
on_full_build do
field :revenue_source, :map
end
field :query_params, :map
field :timestamp, :naive_datetime
@ -90,6 +95,14 @@ defmodule Plausible.Ingestion.Request do
end
end
on_full_build do
defp put_revenue_source(changeset, request_body) do
Plausible.Ingestion.Request.Revenue.put_revenue_source(changeset, request_body)
end
else
defp put_revenue_source(changeset, _request_body), do: changeset
end
defp put_remote_ip(changeset, conn) do
Changeset.put_change(changeset, :remote_ip, PlausibleWeb.RemoteIp.get(conn))
end
@ -192,7 +205,7 @@ defmodule Plausible.Ingestion.Request do
defp put_props(changeset, %{} = request_body) do
props =
(request_body["m"] || request_body["meta"] || request_body["p"] || request_body["props"])
|> decode_json_or_fallback()
|> Plausible.Helpers.JSON.decode_or_fallback()
|> Enum.reject(fn {_k, v} -> is_nil(v) || is_list(v) || is_map(v) || v == "" end)
|> Enum.take(@max_props)
|> Map.new()
@ -224,46 +237,6 @@ defmodule Plausible.Ingestion.Request do
end
end
defp put_revenue_source(%Ecto.Changeset{} = changeset, %{} = request_body) do
with revenue_source <- request_body["revenue"] || request_body["$"],
%{"amount" => _, "currency" => _} = revenue_source <-
decode_json_or_fallback(revenue_source) do
parse_revenue_source(changeset, revenue_source)
else
_any -> changeset
end
end
@valid_currencies Plausible.Goal.valid_currencies()
defp parse_revenue_source(changeset, %{"amount" => amount, "currency" => currency}) do
with true <- currency in @valid_currencies,
{%Decimal{} = amount, _rest} <- parse_decimal(amount),
%Money{} = amount <- Money.new(currency, amount) do
Changeset.put_change(changeset, :revenue_source, amount)
else
_any -> changeset
end
end
defp decode_json_or_fallback(raw) do
with raw when is_binary(raw) <- raw,
{:ok, %{} = decoded} <- Jason.decode(raw) do
decoded
else
already_a_map when is_map(already_a_map) -> already_a_map
_any -> %{}
end
end
defp parse_decimal(value) do
case value do
value when is_binary(value) -> Decimal.parse(value)
value when is_float(value) -> {Decimal.from_float(value), nil}
value when is_integer(value) -> {Decimal.new(value), nil}
_any -> :error
end
end
defp put_query_params(changeset) do
case Changeset.get_field(changeset, :uri) do
%{query: query} when is_binary(query) ->

View File

@ -29,7 +29,7 @@ defmodule Plausible.Plugins.API.Goals do
@spec get_goals(Plausible.Site.t(), map()) :: {:ok, Paginator.Page.t()}
def get_goals(site, params) do
query = Plausible.Goals.for_site_query(site, preload_funnels?: true)
query = Plausible.Goals.for_site_query(site, preload_funnels?: false)
{:ok, paginate(query, params, cursor_fields: [{:id, :desc}])}
end
@ -42,24 +42,11 @@ defmodule Plausible.Plugins.API.Goals do
|> Repo.one()
end
on_full_build do
defp get_query(site) do
from g in Plausible.Goal,
where: g.site_id == ^site.id,
order_by: [desc: g.id],
left_join: assoc(g, :funnels),
group_by: g.id,
preload: [:funnels]
end
end
on_small_build do
defp get_query(site) do
from g in Plausible.Goal,
where: g.site_id == ^site.id,
order_by: [desc: g.id],
group_by: g.id
end
defp get_query(site) do
from g in Plausible.Goal,
where: g.site_id == ^site.id,
order_by: [desc: g.id],
group_by: g.id
end
defp convert_to_create_params(%CreateRequest.CustomEvent{goal: %{event_name: event_name}}) do

View File

@ -1,21 +1,28 @@
defmodule Plausible.Stats.Aggregate do
alias Plausible.Stats.Query
use Plausible.ClickhouseRepo
use Plausible
import Plausible.Stats.{Base, Imported, Util}
import Ecto.Query
@revenue_metrics on_full_build(do: Plausible.Stats.Goal.Revenue.revenue_metrics(), else: [])
@event_metrics [
:visitors,
:pageviews,
:events,
:sample_percent,
:average_revenue,
:total_revenue
]
:visitors,
:pageviews,
:events,
:sample_percent
] ++ @revenue_metrics
@session_metrics [:visits, :bounce_rate, :visit_duration, :views_per_visit, :sample_percent]
def aggregate(site, query, metrics) do
{currency, metrics} = get_revenue_tracking_currency(site, query, metrics)
{currency, metrics} =
on_full_build do
Plausible.Stats.Goal.Revenue.get_revenue_tracking_currency(site, query, metrics)
else
{nil, metrics}
end
event_metrics = Enum.filter(metrics, &(&1 in @event_metrics))
event_task = fn -> aggregate_events(site, query, event_metrics) end
@ -197,4 +204,12 @@ defmodule Plausible.Stats.Aggregate do
end
defp maybe_round_value(entry), do: entry
on_full_build do
defp cast_revenue_metrics_to_money(results, revenue_goals) do
Plausible.Stats.Goal.Revenue.cast_revenue_metrics_to_money(results, revenue_goals)
end
else
defp cast_revenue_metrics_to_money(results, _revenue_goals), do: results
end
end

View File

@ -1,5 +1,6 @@
defmodule Plausible.Stats.Base do
use Plausible.ClickhouseRepo
use Plausible
alias Plausible.Stats.{Query, Filters}
import Ecto.Query
@ -301,24 +302,18 @@ defmodule Plausible.Stats.Base do
|> select_event_metrics(rest)
end
def select_event_metrics(q, [:total_revenue | rest]) do
from(e in q,
select_merge: %{
total_revenue:
fragment("toDecimal64(sum(?) * any(_sample_factor), 3)", e.revenue_reporting_amount)
}
)
|> select_event_metrics(rest)
end
on_full_build do
def select_event_metrics(q, [:total_revenue | rest]) do
q
|> Plausible.Stats.Goal.Revenue.total_revenue_query()
|> select_event_metrics(rest)
end
def select_event_metrics(q, [:average_revenue | rest]) do
from(e in q,
select_merge: %{
average_revenue:
fragment("toDecimal64(avg(?) * any(_sample_factor), 3)", e.revenue_reporting_amount)
}
)
|> select_event_metrics(rest)
def select_event_metrics(q, [:average_revenue | rest]) do
q
|> Plausible.Stats.Goal.Revenue.average_revenue_query()
|> select_event_metrics(rest)
end
end
def select_event_metrics(q, [:sample_percent | rest]) do
@ -331,7 +326,7 @@ defmodule Plausible.Stats.Base do
|> select_event_metrics(rest)
end
def select_event_metrics(_, [unknown | _]), do: raise("Unknown metric " <> unknown)
def select_event_metrics(_, [unknown | _]), do: raise("Unknown metric: #{unknown}")
def select_session_metrics(q, [], _query), do: q

View File

@ -1,5 +1,6 @@
defmodule Plausible.Stats.Breakdown do
use Plausible.ClickhouseRepo
use Plausible
import Plausible.Stats.{Base, Imported, Util}
require OpenTelemetry.Tracer, as: Tracer
alias Plausible.Stats.Query
@ -7,9 +8,12 @@ defmodule Plausible.Stats.Breakdown do
@no_ref "Direct / None"
@not_set "(not set)"
@event_metrics [:visitors, :pageviews, :events, :average_revenue, :total_revenue]
@session_metrics [:visits, :bounce_rate, :visit_duration]
@revenue_metrics [:average_revenue, :total_revenue]
@revenue_metrics on_full_build(do: Plausible.Stats.Goal.Revenue.revenue_metrics(), else: [])
@event_metrics [:visitors, :pageviews, :events] ++ @revenue_metrics
@event_props Plausible.Stats.Props.event_props()
def breakdown(site, query, "event:goal" = property, metrics, pagination) do
@ -23,7 +27,10 @@ defmodule Plausible.Stats.Breakdown do
event_results =
if Enum.any?(event_goals) do
revenue_goals = Enum.filter(event_goals, &Plausible.Goal.revenue?/1)
revenue_goals =
on_full_build do
Enum.filter(event_goals, &Plausible.Goal.Revenue.revenue?/1)
end
site
|> breakdown(event_query, "event:name", metrics, pagination)
@ -68,7 +75,13 @@ defmodule Plausible.Stats.Breakdown do
end
def breakdown(site, query, "event:props:" <> custom_prop = property, metrics, pagination) do
{currency, metrics} = get_revenue_tracking_currency(site, query, metrics)
{currency, metrics} =
on_full_build do
Plausible.Stats.Goal.Revenue.get_revenue_tracking_currency(site, query, metrics)
else
{nil, metrics}
end
{_limit, page} = pagination
none_result =
@ -674,4 +687,12 @@ defmodule Plausible.Stats.Breakdown do
{"plausible.query.breakdown_metrics", metrics}
])
end
on_full_build do
defp cast_revenue_metrics_to_money(results, revenue_goals) do
Plausible.Stats.Goal.Revenue.cast_revenue_metrics_to_money(results, revenue_goals)
end
else
defp cast_revenue_metrics_to_money(results, _revenue_goals), do: results
end
end

View File

@ -1,5 +1,6 @@
defmodule Plausible.Stats.Timeseries do
use Plausible.ClickhouseRepo
use Plausible
alias Plausible.Stats.Query
import Plausible.Stats.{Base, Util}
use Plausible.Stats.Fragments
@ -15,7 +16,9 @@ defmodule Plausible.Stats.Timeseries do
@typep value :: nil | integer() | float()
@type results :: nonempty_list(%{required(:date) => Date.t(), required(metric()) => value()})
@event_metrics [:visitors, :pageviews, :events, :average_revenue, :total_revenue]
@revenue_metrics on_full_build(do: Plausible.Stats.Goal.Revenue.revenue_metrics(), else: [])
@event_metrics [:visitors, :pageviews, :events] ++ @revenue_metrics
@session_metrics [:visits, :bounce_rate, :visit_duration, :views_per_visit]
def timeseries(site, query, metrics) do
steps = buckets(query)
@ -23,7 +26,12 @@ defmodule Plausible.Stats.Timeseries do
event_metrics = Enum.filter(metrics, &(&1 in @event_metrics))
session_metrics = Enum.filter(metrics, &(&1 in @session_metrics))
{currency, event_metrics} = get_revenue_tracking_currency(site, query, event_metrics)
{currency, event_metrics} =
on_full_build do
Plausible.Stats.Goal.Revenue.get_revenue_tracking_currency(site, query, event_metrics)
else
{nil, event_metrics}
end
[event_result, session_result] =
Plausible.ClickhouseRepo.parallel_tasks([
@ -234,4 +242,12 @@ defmodule Plausible.Stats.Timeseries do
end
end)
end
on_full_build do
defp cast_revenue_metrics_to_money(results, revenue_goals) do
Plausible.Stats.Goal.Revenue.cast_revenue_metrics_to_money(results, revenue_goals)
end
else
defp cast_revenue_metrics_to_money(results, _revenue_goals), do: results
end
end

View File

@ -3,8 +3,6 @@ defmodule Plausible.Stats.Util do
Utilities for modifying stat results
"""
import Ecto.Query
@doc """
`__internal_visits` is fetched when querying bounce rate and visit duration, as it
is needed to calculate these from imported data. This function removes that metric
@ -22,73 +20,4 @@ defmodule Plausible.Stats.Util do
def remove_internal_visits_metric(result) when is_map(result) do
Map.delete(result, :__internal_visits)
end
@revenue_metrics [:average_revenue, :total_revenue]
@spec get_revenue_tracking_currency(Plausible.Site.t(), Plausible.Stats.Query.t(), [atom()]) ::
{atom() | nil, [atom()]}
@doc """
Returns the common currency for the goal filters in a query. If there are no
goal filters, multiple currencies or the site owner does not have access to
revenue goals, `nil` is returned and revenue metrics are dropped.
Aggregating revenue data works only for same currency goals. If the query is
filtered by goals with different currencies, for example, one USD and other
EUR, revenue metrics are dropped.
"""
def get_revenue_tracking_currency(site, query, metrics) do
goal_filters =
case query.filters do
%{"event:goal" => {:is, {_, goal_name}}} -> [goal_name]
%{"event:goal" => {:member, list}} -> Enum.map(list, fn {_, goal_name} -> goal_name end)
_any -> []
end
requested_revenue_metrics? = Enum.any?(metrics, &(&1 in @revenue_metrics))
filtering_by_goal? = Enum.any?(goal_filters)
revenue_goals_available? = fn ->
site = Plausible.Repo.preload(site, :owner)
Plausible.Billing.Feature.RevenueGoals.check_availability(site.owner) == :ok
end
if requested_revenue_metrics? && filtering_by_goal? && revenue_goals_available?.() do
revenue_goals_currencies =
Plausible.Repo.all(
from rg in Ecto.assoc(site, :revenue_goals),
where: rg.event_name in ^goal_filters,
select: rg.currency,
distinct: true
)
if length(revenue_goals_currencies) == 1,
do: {List.first(revenue_goals_currencies), metrics},
else: {nil, metrics -- @revenue_metrics}
else
{nil, metrics -- @revenue_metrics}
end
end
def cast_revenue_metrics_to_money([%{goal: _goal} | _rest] = results, revenue_goals)
when is_list(revenue_goals) do
for result <- results do
if matching_goal = Enum.find(revenue_goals, &(&1.event_name == result.goal)) do
cast_revenue_metrics_to_money(result, matching_goal.currency)
else
result
end
end
end
def cast_revenue_metrics_to_money(results, currency) when is_map(results) do
for {metric, value} <- results, into: %{} do
if metric in @revenue_metrics && currency do
{metric, Money.new!(value || 0, currency)}
else
{metric, value}
end
end
end
def cast_revenue_metrics_to_money(results, _), do: results
end

View File

@ -9,6 +9,8 @@ defmodule PlausibleWeb.Api.StatsController do
require Logger
@revenue_metrics on_full_build(do: Plausible.Stats.Goal.Revenue.revenue_metrics(), else: [])
plug(:validate_common_input)
@doc """
@ -339,7 +341,7 @@ defmodule PlausibleWeb.Api.StatsController do
defp fetch_top_stats(site, %Query{filters: %{"event:goal" => _}} = query, comparison_query) do
query_without_filters = Query.remove_event_filters(query, [:goal, :props])
metrics = [:visitors, :events, :average_revenue, :total_revenue]
metrics = [:visitors, :events] ++ @revenue_metrics
results_without_filters =
site
@ -386,8 +388,12 @@ defmodule PlausibleWeb.Api.StatsController do
top_stats_entry(results, comparison, "Unique visitors", :unique_visitors),
top_stats_entry(results, comparison, "Unique conversions", :converted_visitors),
top_stats_entry(results, comparison, "Total conversions", :completions),
top_stats_entry(results, comparison, "Average revenue", :average_revenue, &format_money/1),
top_stats_entry(results, comparison, "Total revenue", :total_revenue, &format_money/1),
on_full_build do
top_stats_entry(results, comparison, "Average revenue", :average_revenue, &format_money/1)
end,
on_full_build do
top_stats_entry(results, comparison, "Total revenue", :total_revenue, &format_money/1)
end,
top_stats_entry(conversion_rate, comparison_conversion_rate, "Conversion rate", :cr)
]
|> Enum.reject(&is_nil/1)
@ -1145,8 +1151,12 @@ defmodule PlausibleWeb.Api.StatsController do
%{visitors: %{value: total_visitors}} = Stats.aggregate(site, total_q, [:visitors])
metrics =
if Enum.any?(site.goals, &Plausible.Goal.revenue?/1) do
[:visitors, :events, :average_revenue, :total_revenue]
on_full_build do
if Enum.any?(site.goals, &Plausible.Goal.Revenue.revenue?/1) do
[:visitors, :events] ++ @revenue_metrics
else
[:visitors, :events]
end
else
[:visitors, :events]
end
@ -1173,28 +1183,6 @@ defmodule PlausibleWeb.Api.StatsController do
end
end
@revenue_metrics [:average_revenue, :total_revenue]
defp format_revenue_metric({metric, value}) do
if metric in @revenue_metrics do
{metric, format_money(value)}
else
{metric, value}
end
end
defp format_money(value) do
case value do
%Money{} ->
%{
short: Money.to_string!(value, format: :short, fractional_digits: 1),
long: Money.to_string!(value)
}
_any ->
value
end
end
def custom_prop_values(conn, params) do
site = Plausible.Repo.preload(conn.assigns.site, :owner)
@ -1244,8 +1232,8 @@ defmodule PlausibleWeb.Api.StatsController do
|> Map.put(:include_imported, false)
metrics =
if Map.has_key?(query.filters, "event:goal") do
[:visitors, :events, :average_revenue, :total_revenue]
if full_build?() and Map.has_key?(query.filters, "event:goal") do
[:visitors, :events] ++ @revenue_metrics
else
[:visitors, :events]
end
@ -1486,4 +1474,13 @@ defmodule PlausibleWeb.Api.StatsController do
true -> false
end
end
on_full_build do
defdelegate format_revenue_metric(metric_value), to: PlausibleWeb.Controllers.API.Revenue
defdelegate format_money(money), to: PlausibleWeb.Controllers.API.Revenue
else
defp format_revenue_metric({metric, value}) do
{metric, value}
end
end
end

View File

@ -96,9 +96,7 @@ defmodule PlausibleWeb.StatsController do
defp list_funnels(site) do
Plausible.Funnels.list(site)
end
end
on_small_build do
else
defp list_funnels(_site) do
[]
end

View File

@ -3,6 +3,7 @@ defmodule PlausibleWeb.Live.GoalSettings.Form do
Live view for the goal creation form
"""
use Phoenix.LiveView
use Plausible
import PlausibleWeb.Live.Components.Form
alias PlausibleWeb.Live.Components.ComboBox
@ -144,6 +145,7 @@ defmodule PlausibleWeb.Live.GoalSettings.Form do
</div>
<div
:if={full_build?()}
class="mt-6 space-y-3"
x-data={
Jason.encode!(%{
@ -162,10 +164,11 @@ defmodule PlausibleWeb.Live.GoalSettings.Form do
<button
class={[
"flex items-center w-max mb-3",
if(assigns.has_access_to_revenue_goals?,
do: "cursor-pointer",
else: "cursor-not-allowed"
)
if @has_access_to_revenue_goals? do
"cursor-pointer"
else
"cursor-not-allowed"
end
]}
aria-labelledby="enable-revenue-tracking"
role="switch"
@ -204,14 +207,17 @@ defmodule PlausibleWeb.Live.GoalSettings.Form do
submit_name={@f[:currency].name}
module={ComboBox}
suggest_fun={
fn
"", [] ->
Plausible.Goal.currency_options()
on_full_build do
fn
"", [] ->
Plausible.Goal.Revenue.currency_options()
input, options ->
ComboBox.StaticSearch.suggest(input, options, weight_threshold: 0.8)
input, options ->
ComboBox.StaticSearch.suggest(input, options, weight_threshold: 0.8)
end
end
}
}
/>
</div>
</div>

View File

@ -125,16 +125,18 @@ defmodule PlausibleWeb.Router do
get "/timeseries", ExternalStatsController, :timeseries
end
scope "/api/v1/sites", PlausibleWeb.Api do
pipe_through [:public_api, PlausibleWeb.AuthorizeSitesApiPlug]
on_full_build do
scope "/api/v1/sites", PlausibleWeb.Api do
pipe_through [:public_api, PlausibleWeb.AuthorizeSitesApiPlug]
post "/", ExternalSitesController, :create_site
put "/shared-links", ExternalSitesController, :find_or_create_shared_link
put "/goals", ExternalSitesController, :find_or_create_goal
delete "/goals/:goal_id", ExternalSitesController, :delete_goal
get "/:site_id", ExternalSitesController, :get_site
put "/:site_id", ExternalSitesController, :update_site
delete "/:site_id", ExternalSitesController, :delete_site
post "/", ExternalSitesController, :create_site
put "/shared-links", ExternalSitesController, :find_or_create_shared_link
put "/goals", ExternalSitesController, :find_or_create_goal
delete "/goals/:goal_id", ExternalSitesController, :delete_goal
get "/:site_id", ExternalSitesController, :get_site
put "/:site_id", ExternalSitesController, :update_site
delete "/:site_id", ExternalSitesController, :delete_site
end
end
scope "/api", PlausibleWeb do

View File

@ -339,6 +339,7 @@ defmodule Plausible.Billing.PlansTest do
assert Plans.suggest_tier(user) == :growth
end
@tag :full_build_only
test "suggests Business tier for a user who used the Revenue Goals, even when they signed up before Business tier release" do
user = insert(:user, inserted_at: ~N[2023-10-25 10:00:00])
site = insert(:site, members: [user])

View File

@ -2,10 +2,11 @@ defmodule Plausible.Billing.QuotaTest do
use Plausible.DataCase, async: true
use Plausible
alias Plausible.Billing.{Quota, Plans}
alias Plausible.Billing.Feature.{Goals, RevenueGoals, Props, StatsAPI}
alias Plausible.Billing.Feature.{Goals, Props, StatsAPI}
on_full_build do
alias Plausible.Billing.Feature.Funnels
alias Plausible.Billing.Feature.RevenueGoals
end
@legacy_plan_id "558746"
@ -460,15 +461,15 @@ defmodule Plausible.Billing.QuotaTest do
assert [Funnels] == Quota.features_usage(site)
assert [Funnels] == Quota.features_usage(user)
end
end
test "returns [RevenueGoals] when user/site uses revenue goals" do
user = insert(:user)
site = insert(:site, memberships: [build(:site_membership, user: user, role: :owner)])
insert(:goal, currency: :USD, site: site, event_name: "Purchase")
test "returns [RevenueGoals] when user/site uses revenue goals" do
user = insert(:user)
site = insert(:site, memberships: [build(:site_membership, user: user, role: :owner)])
insert(:goal, currency: :USD, site: site, event_name: "Purchase")
assert [RevenueGoals] == Quota.features_usage(site)
assert [RevenueGoals] == Quota.features_usage(user)
assert [RevenueGoals] == Quota.features_usage(site)
assert [RevenueGoals] == Quota.features_usage(user)
end
end
test "returns [StatsAPI] when user has a stats api key" do

View File

@ -48,6 +48,7 @@ defmodule Plausible.GoalsTest do
assert {"has already been taken", _} = changeset.errors[:event_name]
end
@tag :full_build_only
test "create/2 sets site.updated_at for revenue goal" do
site_1 = insert(:site, updated_at: DateTime.add(DateTime.utc_now(), -3600))
@ -63,6 +64,7 @@ defmodule Plausible.GoalsTest do
:eq
end
@tag :full_build_only
test "create/2 creates revenue goal" do
site = insert(:site)
{:ok, goal} = Goals.create(site, %{"event_name" => "Purchase", "currency" => "EUR"})
@ -71,6 +73,7 @@ defmodule Plausible.GoalsTest do
assert goal.currency == :EUR
end
@tag :full_build_only
test "create/2 returns error when site does not have access to revenue goals" do
user = insert(:user, subscription: build(:growth_subscription))
site = insert(:site, members: [user])
@ -79,6 +82,7 @@ defmodule Plausible.GoalsTest do
Goals.create(site, %{"event_name" => "Purchase", "currency" => "EUR"})
end
@tag :full_build_only
test "create/2 fails for unknown currency code" do
site = insert(:site)

View File

@ -100,6 +100,7 @@ defmodule Plausible.Ingestion.EventTest do
assert dropped.drop_reason == :throttle
end
@tag :full_build_only
test "saves revenue amount" do
site = insert(:site)
_goal = insert(:goal, event_name: "checkout", currency: "USD", site: site)

View File

@ -171,6 +171,7 @@ defmodule Plausible.Ingestion.RequestTest do
assert request.props["custom2"] == "property2"
end
@tag :full_build_only
test "parses revenue source field from a json string" do
payload = %{
name: "pageview",
@ -186,6 +187,7 @@ defmodule Plausible.Ingestion.RequestTest do
assert Decimal.new("20.2") == amount
end
@tag :full_build_only
test "sets revenue source with integer amount" do
payload = %{
name: "pageview",
@ -204,6 +206,7 @@ defmodule Plausible.Ingestion.RequestTest do
assert Decimal.equal?(amount, Decimal.new("20.0"))
end
@tag :full_build_only
test "sets revenue source with float amount" do
payload = %{
name: "pageview",
@ -222,6 +225,7 @@ defmodule Plausible.Ingestion.RequestTest do
assert Decimal.equal?(amount, Decimal.new("20.1"))
end
@tag :full_build_only
test "parses string amounts into money structs" do
payload = %{
name: "pageview",
@ -240,6 +244,7 @@ defmodule Plausible.Ingestion.RequestTest do
assert Decimal.equal?(amount, Decimal.new("12.3"))
end
@tag :full_build_only
test "ignores revenue data when currency is invalid" do
payload = %{
name: "pageview",
@ -424,6 +429,7 @@ defmodule Plausible.Ingestion.RequestTest do
assert changeset.errors[:request]
end
@tag :full_build_only
test "encodable" do
params = %{
name: "pageview",

View File

@ -53,6 +53,7 @@ defmodule Plausible.Site.CacheTest do
refute Cache.get("site3.example.com", cache_name: test, force?: true)
end
@tag :full_build_only
test "cache caches revenue goals", %{test: test} do
{:ok, _} =
Supervisor.start_link(
@ -84,6 +85,7 @@ defmodule Plausible.Site.CacheTest do
] = Enum.sort_by(cached_goals, & &1.event_name)
end
@tag :full_build_only
test "cache caches revenue goals with event refresh", %{test: test} do
{:ok, _} =
Supervisor.start_link(

View File

@ -672,6 +672,7 @@ defmodule PlausibleWeb.Api.ExternalControllerTest do
assert Map.get(event, :"meta.value") == ["true"]
end
@tag :full_build_only
test "converts revenue values into the goal currency", %{conn: conn, site: site} do
params = %{
name: "Payment",
@ -688,6 +689,7 @@ defmodule PlausibleWeb.Api.ExternalControllerTest do
assert Decimal.equal?(Decimal.new("7.14"), amount)
end
@tag :full_build_only
test "revenue values can be sent with minified keys", %{conn: conn, site: site} do
params = %{
"n" => "Payment",
@ -704,6 +706,7 @@ defmodule PlausibleWeb.Api.ExternalControllerTest do
assert Decimal.equal?(Decimal.new("7.14"), amount)
end
@tag :full_build_only
test "saves the exact same amount when goal currency is the same as the event", %{
conn: conn,
site: site

View File

@ -219,6 +219,7 @@ defmodule PlausibleWeb.Api.StatsController.ConversionsTest do
assert resp == []
end
@tag :full_build_only
test "returns formatted average and total values for a conversion with revenue value", %{
conn: conn,
site: site
@ -259,6 +260,7 @@ defmodule PlausibleWeb.Api.StatsController.ConversionsTest do
]
end
@tag :full_build_only
test "returns revenue metrics as nil for non-revenue goals", %{
conn: conn,
site: site

View File

@ -787,6 +787,7 @@ defmodule PlausibleWeb.Api.StatsController.CustomPropBreakdownTest do
]
end
@tag :full_build_only
test "returns revenue metrics when filtering by a revenue goal", %{conn: conn, site: site} do
prop_key = "logged_in"
@ -844,6 +845,7 @@ defmodule PlausibleWeb.Api.StatsController.CustomPropBreakdownTest do
]
end
@tag :full_build_only
test "returns revenue metrics when filtering by many revenue goals with same currency", %{
conn: conn,
site: site

View File

@ -930,6 +930,7 @@ defmodule PlausibleWeb.Api.StatsController.MainGraphTest do
end
end
@tag :full_build_only
describe "GET /api/stats/main-graph - total_revenue plot" do
setup [:create_user, :log_in, :create_new_site, :add_imported_data]
@ -1009,6 +1010,7 @@ defmodule PlausibleWeb.Api.StatsController.MainGraphTest do
end
end
@tag :full_build_only
describe "GET /api/stats/main-graph - average_revenue plot" do
setup [:create_user, :log_in, :create_new_site, :add_imported_data]

View File

@ -764,6 +764,7 @@ defmodule PlausibleWeb.Api.StatsController.TopStatsTest do
assert %{"name" => "Conversion rate", "value" => 33.3} in res["top_stats"]
end
@tag :full_build_only
test "returns average and total when filtering by a revenue goal", %{conn: conn, site: site} do
insert(:goal, site: site, event_name: "Payment", currency: "USD")
insert(:goal, site: site, event_name: "AddToCart", currency: "EUR")
@ -806,6 +807,7 @@ defmodule PlausibleWeb.Api.StatsController.TopStatsTest do
} in top_stats
end
@tag :full_build_only
test "returns average and total when filtering by many revenue goals with same currency", %{
conn: conn,
site: site

View File

@ -254,6 +254,7 @@ defmodule PlausibleWeb.Live.ChoosePlanTest do
refute text_of_element(doc, @business_plan_box) =~ "Recommended"
end
@tag :full_build_only
test "recommends Business tier when Revenue Goals were used during trial", %{
conn: conn,
user: user
@ -475,6 +476,7 @@ defmodule PlausibleWeb.Live.ChoosePlanTest do
assert class_of_element(doc, @growth_checkout_button) =~ "pointer-events-none"
end
@tag :full_build_only
test "warns about losing access to a feature", %{conn: conn, user: user} do
site = insert(:site, members: [user])
@ -710,6 +712,7 @@ defmodule PlausibleWeb.Live.ChoosePlanTest do
refute element_exists?(doc, @business_highlight_pill)
end
@tag :full_build_only
test "recommends Business tier when premium features used", %{conn: conn, user: user} do
site = insert(:site, members: [user])
insert(:goal, currency: :USD, site: site, event_name: "Purchase")

View File

@ -35,7 +35,8 @@ defmodule PlausibleWeb.Live.GoalSettings.FormTest do
describe "Goal submission" do
setup [:create_user, :log_in, :create_site]
test "renders form fields", %{conn: conn, site: site} do
@tag :full_build_only
test "renders form fields (with currency)", %{conn: conn, site: site} do
lv = get_liveview(conn, site)
html = render(lv)
@ -52,6 +53,22 @@ defmodule PlausibleWeb.Live.GoalSettings.FormTest do
assert name_of(page_path) == "goal[page_path]"
end
@tag :small_build_only
test "renders form fields (no currency)", %{conn: conn, site: site} do
lv = get_liveview(conn, site)
html = render(lv)
[event_name] = find(html, "input")
assert name_of(event_name) == "goal[event_name]"
html = lv |> element(~s/a#pageview-tab/) |> render_click()
[page_path_display, page_path] = find(html, "input")
assert name_of(page_path_display) == "display-page_path_input"
assert name_of(page_path) == "goal[page_path]"
end
test "renders error on empty submission", %{conn: conn, site: site} do
lv = get_liveview(conn, site)
html = lv |> element("form") |> render_submit()
@ -70,6 +87,7 @@ defmodule PlausibleWeb.Live.GoalSettings.FormTest do
assert parent_html =~ "Custom Event"
end
@tag :full_build_only
test "creates a revenue goal", %{conn: conn, site: site} do
{parent, lv} = get_liveview(conn, site, with_parent?: true)
refute render(parent) =~ "SampleRevenueGoal"
@ -96,6 +114,7 @@ defmodule PlausibleWeb.Live.GoalSettings.FormTest do
describe "Combos integration" do
setup [:create_user, :log_in, :create_site]
@tag :full_build_only
test "currency combo works", %{conn: conn, site: site} do
lv = get_liveview(conn, site)

View File

@ -45,6 +45,7 @@ defmodule PlausibleWeb.Plugins.API.Controllers.GoalsTest do
end
describe "business tier" do
@tag :full_build_only
test "fails on revenue goal creation attempt with insufficient plan", %{
site: site,
token: token,
@ -71,6 +72,7 @@ defmodule PlausibleWeb.Plugins.API.Controllers.GoalsTest do
|> assert_schema("PaymentRequiredError", spec())
end
@tag :full_build_only
test "fails on bulk revenue goal creation attempt with insufficient plan", %{
site: site,
token: token,
@ -147,6 +149,7 @@ defmodule PlausibleWeb.Plugins.API.Controllers.GoalsTest do
assert [%{event_name: "Signup"}] = Plausible.Goals.for_site(site)
end
@tag :full_build_only
test "creates a revenue goal", %{conn: conn, token: token, site: site} do
url = Routes.goals_url(base_uri(), :create)
@ -177,6 +180,7 @@ defmodule PlausibleWeb.Plugins.API.Controllers.GoalsTest do
assert [%{event_name: "Purchase", currency: :EUR}] = Plausible.Goals.for_site(site)
end
@tag :full_build_only
test "fails to create a revenue goal with unknown currency", %{
conn: conn,
token: token,
@ -205,6 +209,7 @@ defmodule PlausibleWeb.Plugins.API.Controllers.GoalsTest do
assert [%{detail: "currency: is invalid"}] = resp.errors
end
@tag :full_build_only
test "edge case - revenue goal exists under the same name and different currency", %{
conn: conn,
token: token,
@ -277,6 +282,7 @@ defmodule PlausibleWeb.Plugins.API.Controllers.GoalsTest do
end
describe "put /goals - bulk creation" do
@tag :full_build_only
test "creates a goal of each type", %{conn: conn, token: token, site: site} do
url = Routes.goals_url(base_uri(), :create)
@ -351,6 +357,7 @@ defmodule PlausibleWeb.Plugins.API.Controllers.GoalsTest do
} in resp.errors
end
@tag :full_build_only
test "is idempotent", %{conn: conn, token: token, site: site} do
url = Routes.goals_url(base_uri(), :create)
@ -385,6 +392,7 @@ defmodule PlausibleWeb.Plugins.API.Controllers.GoalsTest do
|> assert_schema("Goal.ListResponse", spec())
end
@tag :full_build_only
test "edge case - revenue goals exist under the same name and different currency", %{
conn: conn,
token: token,
@ -430,6 +438,7 @@ defmodule PlausibleWeb.Plugins.API.Controllers.GoalsTest do
assert %{errors: [%{detail: "Invalid integer. Got: string"}]} = resp
end
@tag :full_build_only
test "retrieves revenue goal by ID", %{conn: conn, site: site, token: token} do
{:ok, goal} =
Plausible.Goals.create(site, %{"event_name" => "Purchase", "currency" => "EUR"})
@ -507,6 +516,7 @@ defmodule PlausibleWeb.Plugins.API.Controllers.GoalsTest do
assert resp.meta.pagination.links == %{}
end
@tag :full_build_only
test "returns a list of goals of each possible goal type", %{
conn: conn,
site: site,