Skip to main content

Checkout Shield

Checkout Shield (plg_j2commerce_app_checkoutshield) is a non-core J2Commerce app plugin that runs a layered, score-based risk pipeline against the checkout funnel — honeypots, timing traps, velocity limits, a card-testing signature detector, optional IP/email reputation, native-captcha challenges, and an escalating ban ladder — with its own Analytics-menu dashboard and a companion task plugin (plg_task_j2commerce_checkoutshield, group task) for scheduled maintenance.

This page documents the plugin's extensibility surface: the three events other extensions can subscribe to in order to inject content into the Checkout Shield dashboard, the database schema, the full configuration parameter reference, and the task-plugin routines. It does not re-document the internal detection pipeline (signals, velocity, response ladder) — that is internal to the plugin and not a public API.

Identity

Element (app plugin)app_checkoutshield
Element (task plugin)j2commerce_checkoutshield
Namespace (app plugin)J2Commerce\Plugin\J2Commerce\AppCheckoutshield
Namespace (task plugin)J2Commerce\Plugin\Task\J2commerceCheckoutshield
Path (app plugin)plugins/j2commerce/app_checkoutshield/
Path (task plugin)plugins/task/j2commerce_checkoutshield/
Dependencycom_j2commerce (installer script checks for it and refuses to install without it)

The two plugins are independent Joomla extensions that cooperate at runtime: the task plugin reads the app plugin's own params (PluginHelper::getPlugin('j2commerce', 'app_checkoutshield')) and constructs the app plugin's PSR-4 service classes directly (ListSyncService, Notifier, CardTestingService, GeoService, StateStore) rather than reaching into the running plugin instance. Neither extension owns any core file.

Architecture

Enforcement is gated by the mode param (monitor default, enforce). In monitor mode nothing is ever denied — every request is logged with the action it would have taken. The single gating decision (allowlist -> active ban -> lockdown guest block -> velocity cooldown -> block band -> challenge band) is shared between onAfterRoute (the earliest possible gate, and the only veto path for event-less tasks such as checkout.confirm and the AJAX lookups) and the onJ2CommerceCheckoutValidate* listeners (which own scoring for tasks that do have a validate event). Every enforcement path is wrapped in try/catch and fails open — a DB, DNS, or HTTP error is logged and the checkout is allowed to continue.

Dashboard Extensibility Events

The Checkout Shield dashboard (view=appplugin&plugin=app_checkoutshield&pluginview=dashboard) dispatches three events that let other plugins inject their own quick-link tiles, widget cards, and KPI tiles — without any core-file coupling. This mirrors the app_marketplace filter-style event pattern (EventHelper::getPayoutDashboardQuickLinks()). No other reference dashboard in the codebase (app_avalaratax included) exposes injection events like this — it is unique to Checkout Shield.

All three are dispatched from CheckoutShieldDashboardHelper (src/Helper/CheckoutShieldDashboardHelper.php), a static, dependency-free helper class — not an MVC model — that never makes a network call. Verified dispatch code:

// File: plugins/j2commerce/app_checkoutshield/src/Helper/CheckoutShieldDashboardHelper.php

public static function dashboardQuickLinks(): array
{
$links = [];

try {
$event = J2CommerceHelper::plugin()->event('CheckoutShieldDashboardQuickLinks', ['links' => &$links]);
$links = (array) $event->getArgument('links', $links);
} catch (\Throwable $e) {
Log::add('dashboardQuickLinks dispatch failed: ' . $e->getMessage(), Log::WARNING, self::LOG_CATEGORY);
}

return $links;
}

J2CommerceHelper::plugin()->event() prepends onJ2Commerce to the string you pass it — calling ->event('CheckoutShieldDashboardQuickLinks', ...) dispatches onJ2CommerceCheckoutShieldDashboardQuickLinks, which is the event name your listener subscribes to. All three dispatch calls are wrapped in try/catch: a broken or missing listener logs a WARNING under the plg_j2commerce_app_checkoutshield log category and the dashboard simply renders without that extension's content — a third-party plugin can never break the dashboard page.

Timing

All three events fire once, from AppCheckoutshield::renderDashboard(), on a full page load of pluginview=dashboard. They are not re-dispatched by the dashboard.data AJAX handler that powers the KPI-band/chart date-filter refresh (ajaxDashboardData()) — third-party quick links, widgets, and KPI tiles only update on page reload, not on every date-range change.

Adds tiles to an extra "Extension Links" quick-icon row (rendered only when non-empty, via the core dashboard.quickicon layout — the same layout the shield's own Attempts/Blocked IPs/Allowlist/Settings row uses).

Argument['links' => &$links]
Item shape['link', 'image', 'name', 'id'?]
// File: plugins/j2commerce/app_example/src/Extension/AppExample.php

declare(strict_types=1);

namespace J2Commerce\Plugin\J2Commerce\AppExample\Extension;

use Joomla\CMS\Language\Text;
use Joomla\CMS\Plugin\CMSPlugin;
use Joomla\Event\Event;
use Joomla\Event\SubscriberInterface;

final class AppExample extends CMSPlugin implements SubscriberInterface
{
public static function getSubscribedEvents(): array
{
return ['onJ2CommerceCheckoutShieldDashboardQuickLinks' => 'onShieldQuickLinks'];
}

public function onShieldQuickLinks(Event $event): void
{
$links = (array) $event->getArgument('links', []);
$links[] = [
'link' => 'index.php?option=com_j2commerce&view=appplugin&plugin=app_example&pluginview=report',
'image' => 'fa-solid fa-chart-column',
'name' => Text::_('PLG_J2COMMERCE_APP_EXAMPLE_SHIELD_LINK'),
'id' => 'example-shield-link',
];
$event->setArgument('links', $links);
}
}

onJ2CommerceCheckoutShieldDashboardWidgets

Adds a card to the dashboard's data-card grid, after the shield's own "Recent attempts / Top offenders / Top domains / List sync status" cards.

Arguments['widgets' => &$widgets, 'fromDate' => $fromDate, 'toDate' => $toDate]
Item shape['title', 'html', 'col'?]col defaults to col-md-6
ContractThe provider MUST pre-escape html. The dashboard template echoes it raw: <?php echo (string) ($widget['html'] ?? ''); ?> — this is the one documented spot in Checkout Shield where trusted-provider raw HTML is intentional, on the same trust model as core pluginWidgets.
// File: plugins/j2commerce/app_example/src/Extension/AppExample.php

public static function getSubscribedEvents(): array
{
return [
'onJ2CommerceCheckoutShieldDashboardQuickLinks' => 'onShieldQuickLinks',
'onJ2CommerceCheckoutShieldDashboardWidgets' => 'onShieldWidgets',
];
}

public function onShieldWidgets(Event $event): void
{
$widgets = (array) $event->getArgument('widgets', []);
$fromDate = (string) $event->getArgument('fromDate', '');
$toDate = (string) $event->getArgument('toDate', '');

$flagged = $this->countExampleFlagsInRange($fromDate, $toDate);

$widgets[] = [
'title' => Text::_('PLG_J2COMMERCE_APP_EXAMPLE_SHIELD_WIDGET_TITLE'),
// Contract: pre-escape everything yourself — the shield echoes 'html' RAW.
'html' => '<p>' . htmlspecialchars(
Text::sprintf('PLG_J2COMMERCE_APP_EXAMPLE_SHIELD_WIDGET_BODY', $flagged),
ENT_QUOTES,
'UTF-8'
) . '</p>',
'col' => 'col-md-6',
];
$event->setArgument('widgets', $widgets);
}

onJ2CommerceCheckoutShieldDashboardKpis

Adds a tile to a second KPI band, rendered below the shield's own six-tile KPI band (same quickicon/alert alert-{accent} markup), only when at least one listener contributes a tile.

Arguments['kpis' => &$kpis, 'fromDate' => $fromDate, 'toDate' => $toDate]
Item shape['id', 'label', 'value', 'accent', 'change'?]
accent valuessuccess | info | warning | danger | purple (an invalid value silently falls back to info in the template)
public static function getSubscribedEvents(): array
{
return ['onJ2CommerceCheckoutShieldDashboardKpis' => 'onShieldKpis'];
}

public function onShieldKpis(Event $event): void
{
$kpis = (array) $event->getArgument('kpis', []);
$fromDate = (string) $event->getArgument('fromDate', '');
$toDate = (string) $event->getArgument('toDate', '');

$kpis[] = [
'id' => 'example-flags',
'label' => Text::_('PLG_J2COMMERCE_APP_EXAMPLE_SHIELD_KPI_LABEL'),
'value' => (string) $this->countExampleFlagsInRange($fromDate, $toDate),
'accent' => 'info',
'change' => null,
];
$event->setArgument('kpis', $kpis);
}

Other Events Checkout Shield Subscribes To

Not extensibility hooks — listed for context on how the plugin integrates with J2Commerce core. All handlers are registered in AppCheckoutshield::getSubscribedEvents().

EventPriorityPurpose
onAfterRoute (core Joomla)defaultEarliest enforcement gate for option=com_j2commerce; the only veto path for event-less tasks.
onJ2CommerceBeforeCheckoutdefaultCheckout-only asset hook (WebAssetManager unlocked here) — registers shield-checkout.js/css, issues signed tokens via script options, sets the session-continuity marker.
onAfterRender (core Joomla)defaultSplices the off-screen honeypot input into the checkout page HTML buffer.
onAjaxApp_checkoutshield (com_ajax, site)defaultServes challenge state + captcha widget markup to the checkout JS. Guest-accessible by design, CSRF-checked, no personal data, self velocity-limited.
onJ2CommercePostPaymentPriority::LOWCaptures the payment-attempt outcome for the card-testing detector, after payment plugins have populated the event result.
onJ2CommerceAfterPaymentdefaultSuccess-confirmation fallback (rarely fires — onPostPayment normally already logs).
onJ2CommerceCheckoutValidateGuest / ValidateBilling / BeforeCheckoutValidateShipping / BeforeCheckoutValidateGuestShippingPriority::HIGHScoring + veto for the checkout steps that have a validate event.
onJ2CommerceRegisterEmailTypes / GetEmailTemplateCards / GetEmailTemplates / ProcessEmailTagsdefaultRegisters the "Checkout Shield Alert" and "Checkout Shield Digest" email types under Design → Email Templates (J2Commerce has no Joomla MailTemplate — it rolls its own DB-backed template system).
onJ2CommerceAddDashboardMenuInJ2CommercedefaultAppends the "Checkout Shield" child under the Analytics menu group — only when the current user passes both j2commerce.viewreports and j2commerce.viewsetup.
onJ2CommerceAppPluginViewdefaultThe plugin-owned admin container: routes pluginview (dashboard/attempts/blocklist/allowlist) to a render method.
onJ2CommerceAppPluginAjaxdefaultAll admin AJAX actions (see below) — three-gated: CSRF token, authenticated, core.admin on com_j2commerce.
onJ2CommerceAfterAdminOrderDetailsdefaultInjects the read-only per-order risk card into the admin order view via the addResult pattern.
onJ2CommerceGetAnalyticsWidgetsdefaultOptional widget on the core Statistics Dashboard (attempts/blocked in range + health badge).
onPrivacyExportRequest / onPrivacyRemoveData (core Joomla)defaultGDPR export/anonymize of the subject's own attempt rows.

Admin AJAX actions

onAppPluginAjax routes on the action GET/POST param (task=appPlugin.ajax&plugin=app_checkoutshield&action=...). Every action passes through the same three-gate check before the match: CSRF token (Session::checkToken('request')), authenticated user ((int) $user->id !== 0), and $user->authorise('core.admin', 'com_j2commerce').

action valuePurpose
toggleBlockPer-row Attempts Log / Blocked IPs jgrid-style block/unblock toggle.
blockBulk-block the stored IPs of selected Attempts Log rows (duration from a toolbar dropdown).
blockManualBlocklist "Add Block…" form — blocks a single admin-typed IP.
unblockBulk-unblock selected Blocked IPs rows.
makePermanentPromote selected temporary bans to permanent.
allowlistIpAllowlist the stored IPs of selected Attempts Log rows.
allowlistAddAllowlist "Add Entry…" form — IP/CIDR/Email/Email domain.
allowlistDeleteDelete selected Allowlist rows.
lockdownToggle Lockdown Mode (state=on|off).
clearCountersTruncates the velocity counters table.
purgePurges the entire attempts log.
sendTestAlertSends a test alert through the configured notification channels.
exportCsvStreams a formula-injection-guarded CSV export for attempts, blocklist, or allowlist.
dashboard.dataRefresh payload for the dashboard date-filter (KPIs + charts only — does not re-dispatch the three F.1a extensibility events).

Database Schema

Eight new tables, all CREATE TABLE IF NOT EXISTS, InnoDB, utf8mb4_unicode_ci, prefixed #__j2commerce_appcheckoutshield_*. Shipped via sql/install.mysql.utf8.sql / sql/uninstall.mysql.utf8.sql / sql/updates/mysql/.

#__j2commerce_appcheckoutshield_attempts

The core log — one row per evaluated checkout request. Primary key id (BIGINT UNSIGNED AUTO_INCREMENT).

ColumnTypeNotes
created_onDATETIMEUTC.
ipVARCHAR(64)Raw, hashed, or truncated per the ip_storage privacy setting.
countryCHAR(2)Set only when Geolocation is enabled.
session_hashVARCHAR(64)Keyed hash of the checkout session.
fingerprintVARCHAR(64)Lightweight, no-canvas composite device hash.
user_idINT UNSIGNED0 for guest.
email_hashVARCHAR(64)Keyed hash — never a raw email.
email_domainVARCHAR(190)Bare domain only.
taskVARCHAR(64)e.g. checkout.confirmPayment.
signalsTEXTJSON: triggered signals + values.
scoreSMALLINT UNSIGNED0-100 composite.
bandTINYINT UNSIGNED0 monitor, 1 challenge, 2 block.
actionTINYINT UNSIGNED0 logged, 1 would-block, 2 challenged, 3 throttled, 4 blocked, 5 banned.
outcomeTINYINTPayment rows only: NULL n/a, 1 success, 0 declined.
gateway_codeVARCHAR(32)Best-effort.
amountDECIMAL(15,4)
card_hashVARCHAR(64)Best-effort BIN+last4 hash when a gateway exposes a short masked fragment — never a full PAN.
user_agentVARCHAR(255)

Indexed on (ip, created_on), (session_hash, created_on), (fingerprint, created_on), (created_on), (action, created_on). Retention: retention_days (default 180, purged daily by the task plugin).

#__j2commerce_appcheckoutshield_blocklist

id INT UNSIGNED AUTO_INCREMENT. ip VARCHAR(64) (raw or SHA-256 hex in hashed-IP mode), cidr TINYINT UNSIGNED (prefix length, NULL = single IP), type TINYINT UNSIGNED (0 temp / 1 permanent), reason VARCHAR(255), source TINYINT UNSIGNED (0 auto / 1 manual / 2 reputation), hits INT UNSIGNED, ban_count TINYINT UNSIGNED (escalation level), created_on DATETIME, expires_on DATETIME (NULL = permanent), created_by INT UNSIGNED. UNIQUE (ip, cidr).

#__j2commerce_appcheckoutshield_allowlist

id INT UNSIGNED AUTO_INCREMENT. type TINYINT UNSIGNED (0 ip / 1 cidr / 2 email / 3 email_domain), value VARCHAR(190), note VARCHAR(255), created_on DATETIME, created_by INT UNSIGNED. UNIQUE (type, value).

#__j2commerce_appcheckoutshield_counters

The velocity engine's sliding-window store. id BIGINT UNSIGNED AUTO_INCREMENT. counter_key VARCHAR(120) (e.g. ip:1.2.3.4:attempts), bucket INT UNSIGNED (unix minute — 0 is a reserved namespace holding a cooldown-expiry unix timestamp in value, not a count), value INT UNSIGNED. UNIQUE (counter_key, bucket).

warning

The task plugin's purgeCounters routine runs two separate deletes: bucket > 0 AND bucket < minBucket ages out real sliding-window buckets, and bucket = 0 AND value < now ages out expired cooldowns only. An age-based purge that ignored the bucket = 0 reservation would silently un-throttle active offenders.

#__j2commerce_appcheckoutshield_reputation

The per-IP live-lookup cache — strictly separate from _iplists below. id INT UNSIGNED AUTO_INCREMENT. ip VARCHAR(45), provider VARCHAR(32), score SMALLINT, flags VARCHAR(190) (csv: tor,vpn,proxy,datacenter,abuse), checked_on DATETIME, expires_on DATETIME. UNIQUE (ip). Stores raw IPs only — hashed-IP privacy mode disables L3 reputation lookups entirely because this table has nowhere to put a hash.

#__j2commerce_appcheckoutshield_iplists

Bulk lists synced wholesale by the task plugin's syncLists routine — never merged into _reputation. id INT UNSIGNED AUTO_INCREMENT. list ENUM('tor','sfs'), ip VARCHAR(64), cidr VARCHAR(64), synced_on DATETIME.

#__j2commerce_appcheckoutshield_maildomains

Disposable-email domain list. id INT UNSIGNED AUTO_INCREMENT. domain VARCHAR(190) (UNIQUE), source TINYINT UNSIGNED (0 bundled seed / 1 synced / 2 manual), created_on DATETIME.

#__j2commerce_appcheckoutshield_state

Generic key-value store for Lockdown state, spike-detection baselines, sync stamps, and alert dedup counters. id INT UNSIGNED AUTO_INCREMENT. keyname VARCHAR(64) (UNIQUE), value TEXT (JSON), modified_on DATETIME. Read via StateStore (src/Service/StateStore.php) — the app plugin and the task plugin both construct their own StateStore instance against the same table, so state set by one is visible to the other.

Configuration Parameter Reference

All fields live on the app_checkoutshield plugin's own params (administrator/components/com_j2commerce/... never touched — this is entirely the plugin's own manifest XML). Yes/no fields use type="radio" layout="joomla.form.field.radio.switcher"; multi-selects use type="list" multiple="true" layout="joomla.form.field.list-fancy-select".

General

ParamTypeDefaultNotes
modelistmonitormonitor | enforce. Master switch.
protect_tasksmulti-selectall 7 stepsguest,billing,shipping,payment,confirm,register,ajax_lookups

Signals

ParamTypeDefault
honeypot_enable / honeypot_weightradio / number1 / 60
jstoken_enable / jstoken_weightradio / number1 / 35
timing_enable / timing_min_seconds / timing_weight / timing_max_minutes / timing_stale_weightradio / numbers1 / 2 / 20 / 30 / 15
session_enable / session_weightradio / number1 / 25
header_enable / header_weightradio / number1 / 10
behavior_enable / behavior_weightradio / number1 / 15
fingerprint_enableradio1

Velocity

ParamTypeDefault
velocity_enableradio1
window_minutesnumber10
guest_ip_limit / auth_user_limitnumber30 / 60
session_limit / email_limit / fingerprint_limitnumber15 / 10 / 15
confirm_ip_limitnumber10
cooldown_multipliernumber3
velocity_weight_ip / _session / _email / _fingerprintnumber30 / 35 / 30 / 35

Card Testing

ParamTypeDefault
ct_enableradio1
ct_window_minutes / ct_max_attemptsnumber5 / 5
ct_decline_ratio / ct_min_attemptsnumber0.5 / 5
ct_distinct_cardsnumber3
ct_small_amount / ct_small_amount_weightnumber5.00 / 10
ct_weightnumber40

IP & Email Reputation

ParamTypeDefaultNotes
rep_enableradio0Gates AbuseIPDB/httpBL/IPQS only; requires ip_storage=full.
rep_cache_hoursnumber24
abuseipdb_key / abuseipdb_min_confidence / abuseipdb_weighttext / number— / 75 / 30
httpbl_key / httpbl_weighttext / number— / 25Project Honey Pot.
ipqs_key / ipqs_threshold / ipqs_weighttext / number— / 85 / 30
stopforumspam_enable / _weightradio / number1 / 25Local match against _iplists.
tor_mode / tor_weightlist / numberscore / 35off | score | block.
disposable_enable / _weightradio / number1 / 25

Geolocation

ParamTypeDefault
geo_enableradio0
maxmind_license_keytext
geo_modelistblocklisted (blocklisted | allowlisted)
geo_countriesmulti-select
geo_actionlistscore (score | challenge | block)
geo_weightnumber25
asn_enable / asn_weightradio / number0 / 15

Challenge

ParamTypeDefault
challenge_captchatype="plugins" folder="captcha"site default
challenge_min_scorenumber40
challenge_always_stepsmulti-selectnone
challenge_pass_ttlnumber (minutes)30

Response & Banning

ParamTypeDefault
block_min_scorenumber70
ban_enableradio1
ban_after_blocks / ban_window_minutesnumber3 / 60
ban_laddertext (csv minutes)60,360,1440,10080
perm_after_bansnumber4

Notifications

ParamTypeDefault
notify_email_enableradio1
notify_recipientstext(site mailfrom)
notify_min_severitylisthigh
notify_min_scorenumber40
notify_cooldown_hoursnumber6
spike_multiplier / spike_min_attempts / decline_spike_rationumber5 / 10 / 0.5
digest_modelistdaily (off | hourly | daily)
webhook_url / webhook_min_severityurl / list— / critical

Privacy

ParamTypeDefault
retention_daysnumber180 (range 30-365)
ip_storagelistfull (full | hashed | truncated)

Lockdown

ParamTypeDefault
lockdown_force_challengeradio1
lockdown_block_guestradio1
lockdown_threshold_multipliernumber0.25
lockdown_auto_expire_hoursnumber24 (0 = manual only)

Advanced

ParamTypeDefaultNotes
min_log_scorenumber0Payment-outcome rows are always logged regardless of this setting — they feed the card-testing detector.

Task Plugin Routines

plugins/task/j2commerce_checkoutshield/src/Extension/Checkoutshield.php implements TaskPluginTrait with a 7-entry TASKS_MAP. Every routine returns Status::OK on success or Status::KNOCKOUT on a caught \Throwable — never throws, so a bad run never blocks the next scheduler tick or the checkout path. Routines that need the companion app plugin's params call PluginHelper::getPlugin('j2commerce', 'app_checkoutshield') and return Status::NO_RUN with a warning log if it isn't installed/enabled.

Task nameMethodSuggested cadenceBehavior
checkoutshield.purgeBanspurgeBans()HourlyDeletes _blocklist rows where type=0 (temp) and expires_on is more than 30 days in the past.
checkoutshield.purgeAttemptspurgeAttempts()DailyDeletes _attempts rows older than retention_days (clamped 30-365), or a per-task override_retention_days form field when set.
checkoutshield.purgeCounterspurgeCounters()HourlyTwo-phase delete: stale bucket > 0 velocity buckets, and expired bucket = 0 cooldowns (see the counters-table warning above).
checkoutshield.syncListssyncLists()DailyCalls ListSyncService::syncDisposableDomains() / syncStopForumSpam() / syncTor(), constructed with the bundled seed file at data/disposable-domains-seed.txt.
checkoutshield.sendDigestsendDigest()HourlyCalls Notifier::sendDigest(), which self-gates on digest_mode — safe to schedule hourly regardless of whether the merchant chose hourly or daily.
checkoutshield.recomputeBaselinerecomputeBaseline()HourlyWrites trailing 7-day same-hour attempt-volume and decline-rate baselines into StateStore::KEY_ATTEMPTS_BASELINE / KEY_DECLINE_BASELINE, read by the spike detectors and CheckoutShieldDashboardHelper::declineRatio().
checkoutshield.refreshGeoDbrefreshGeoDb()WeeklyNo-ops (fail-open, Status::OK) if maxmind_license_key is empty. On download/verify failure, fires a medium-severity geo_db_refresh_failed Notifier alert and returns Status::KNOCKOUT; the existing database (if any) stays in use.

The task plugin's geoDataDir() helper mirrors AppCheckoutshield::geoDataDir() exactly ({tmp_path}/plg_j2commerce_app_checkoutshield/geoip) — both plugins must resolve to the identical server-fixed directory, never a request-derived path.

Best Practices for Extension Authors

  • Always guard getArgument() returns with a cast ((array), (string)) — the shield's own dispatch code does this defensively and your listener should too.
  • Escape everything you put in a widgets item's html key. The contract is explicit: the shield template echoes it raw (echo (string) ($widget['html'] ?? '')). This is the one place in the whole extension where unescaped output is intentional and documented.
  • Don't assume your listener runs on every dashboard interaction. The three events fire once per full page load of pluginview=dashboard; they are not re-dispatched by the AJAX date-filter refresh.
  • Fail open in your own listener. Wrap your handler body in try/catch and never let an exception propagate — Checkout Shield's own dispatch already tolerates a broken listener, but a well-behaved third party shouldn't rely on that safety net alone.
  • Never write to the shield's own tables directly. There is no public data-access API for _attempts/_blocklist/_allowlist beyond the dashboard events — reach for onJ2CommerceAfterAdminOrderDetails (per-order data) or the dashboard events (aggregate data) instead.