A core/Statistics extension is one graph on the ACP Statistics tab. It is the thing an administrator sees when they open Statistics → Registrations or Statistics → Posts: a chart with a timescale selector, a date range, optional filter tabs, a data table underneath, and a "save this chart" action. Saving is the part that makes the extension worth writing rather than just building a chart inline in your controller. A saved chart is pinned to Statistics → Key Statistics → Saved Reports (app=core&module=overview&controller=mycharts, restriction mycharts_manage — menu__core_keystats is "Key Statistics" and menu__core_keystats_mycharts is "Saved Reports") and can be added to a Report, where it is re-rendered alongside charts from other applications and exported to CSV. Core cannot re-render a saved chart from a database row on its own; it needs a class it can find again and ask for the chart definition a second time. That class is your extension.
Note that this extension point does not put anything in the ACP menu. Nothing scans for core/Statistics extensions and builds a page out of them. You still have to write an ACP module controller and register it in your app's acpmenu.json; the extension is what that controller calls, and what the saved-chart machinery calls later.
The contract
The base class is not under system/Extensions/. It is IPS\core\Statistics\Chart, in applications/core/sources/Statistics/Chart.php. (system/Extensions/OverviewStatisticsAbstract.php is a different extension point — core/OverviewStatistics — which produces the small text blocks on the same screens. The two are unrelated apart from sharing some static helpers on this class.)
There are two properties and exactly one abstract method:
abstract class Chart
{
/* The key written into core_saved_charts.chart_controller when an admin
saves this chart, and the key used to find this extension again when
the saved chart is rendered. Note the default is the four-character
STRING "NULL", not null. */
public ?string $controller = 'NULL';
/* When true the chart cannot have per-admin filter tabs, and the save form
forces "show to all members" on. Core's own docblock says such a chart
"can only be saved to reports"; the code does not enforce that. */
public bool $noTabs = false;
/* You MUST implement this. $url is the URL the chart is being rendered
on; the chart helper appends its own query strings to it. */
abstract public function getChart( Url $url ): \IPS\Helpers\Chart;
}
That is the whole contract. $controller is at line 55, $noTabs at line 62, getChart() at line 71.
The class also carries four static helpers you call rather than implement:
public static function loadFromExtension( string $app, string $extension ): static // line 80 public static function loadFromController( string $controller ): static // line 98 public static function constructMemberChartFromData( array|int $data, Url $url, Member|bool $check = TRUE ): \IPS\Helpers\Chart // line 122 public static function getChartsForMember( Url $url, bool $idsOnly = FALSE, ?Member $member = NULL ): array // line 220
loadFromExtension() is the one you use from your own controller. $extension is the key from data/extensions.json, not the class name and not prefixed with the app directory.
The remaining statics on the same file are a mixed bag, and only two of them are really about the core/OverviewStatistics blocks. getSavedBlocks() (line 281) iterates Application::allExtensions( 'core', 'OverviewStatistics', TRUE ) at line 296, and getAppsToFilterBy() (line 403) is built on top of it; those two have nothing to do with charts. getBlockDateRangeForm() (line 330) and getChartDateRanges() (line 427) are shared by both — the saved-reports screen renders one date-range form for the whole page, and getChartDateRanges() is what constructMemberChartFromData() calls at line 197 to override every saved chart's start and end. They are all on this class because the saved-reports screen renders both kinds of thing.
What getChart() must return
The declared return type is \IPS\Helpers\Chart, which is a plain concrete class (system/Helpers/Chart/Chart.php, line 58). In practice you must return one of its dynamic subclasses:
IPS\Helpers\Chart\Database— one table, one date column, series built from SQL aggregates. This is what almost every core implementation uses.IPS\Helpers\Chart\Callback— you supply a callable that returns the rows. Used byapplications/core/extensions/core/Statistics/OnlineUsers.phpline 77 when the data cannot be expressed as a single query.
Both extend the abstract IPS\Helpers\Chart\Dynamic. That matters, because setExtension() is declared on Dynamic (system/Helpers/Chart/Dynamic.php, line 793), not on Chart. Almost every shipped implementation calls it immediately after constructing the chart:
$chart = new Database( $url, 'core_members', 'joined', '', array( ... ) ); $chart->setExtension( $this );
Three of the 41 shipped implementations do not call it — applications/core/extensions/core/Statistics/Language.php, RankProgression.php and Theme.php. Those charts therefore fall through to the request-derived controller string described below, which is a real bug in two of the three cases. Call it.
The only thing setExtension() does is store the object and reset the cached tab list. But that stored object is what Dynamic::getController() reads (line 705):
public function getController() : string
{
if ( is_string( $this->extension?->controller ) )
{
return $this->extension->controller;
}
return Request::i()->app . '_' . Request::i()->module . '_' . Request::i()->controller . ( Request::i()->tab ? '_' . Request::i()->tab : '' );
}
and the return value of getController() is written to core_saved_charts.chart_controller when the admin saves the chart (line 620), and used to look the extension up again afterwards.
Who calls it
There are only two entry points into the extension, and they are reached in very different ways. (Two APIs, not two lines of code: loadFromExtension() is called from about forty places in the shipped apps, loadFromController() from exactly one.)
Your own ACP controller, via loadFromExtension(). This is a straight one-liner. applications/core/modules/admin/stats/registrationstats.php line 55:
Output::i()->output = Chart::loadFromExtension( 'core', 'Registrations' )->getChart( Url::internal( 'app=core&module=stats&controller=registrationstats' ) );
Other examples: applications/forums/modules/admin/stats/topics.php lines 84–85, applications/forums/modules/admin/stats/posts.php lines 201 and 205, applications/forums/modules/admin/stats/solved.php lines 109–113, applications/calendar/modules/admin/stats/rsvp.php line 53. It is not only ACP stats controllers: applications/core/extensions/core/Dashboard/Registrations.php line 51 uses the same call to draw the ACP dashboard block, so a core/Statistics extension can be reused anywhere you can pass it a URL. Note that loadFromExtension() goes through Application::load( $app )->extensions( 'core', 'Statistics' ), whose $checkAccess parameter defaults to FALSE (system/Application/Application.php line 911), so your class is constructed with NULL and no application access check is applied.
The saved-chart machinery, via loadFromController(). constructMemberChartFromData() reads a core_saved_charts row and then, at line 161:
$extension = static::loadFromController( $data['chart_controller'] );
loadFromController() walks every registered extension and returns the first whose $controller matches:
foreach( Application::allExtensions( 'core', 'Statistics', FALSE ) AS $extension )
{
if ( $extension->controller AND $extension->controller === $controller )
{
return $extension;
}
}
throw new OutOfRangeException;
It then calls getChart( $url ) again (line 183), overwrites title, and forces showFilterTabs, showIntervals and showDateRange to FALSE (lines 192–195), because the Key Statistics screen supplies one shared date range for every chart on the page. Whatever you set for those three properties in getChart() is discarded in that context.
This path is reached from applications/core/modules/admin/overview/mycharts.php in two places: line 295 (do=getChart, one chart at a time) and line 624 (via getChartsForMember(), for the CSV export). The grid itself at line 225 calls getChartsForMember( ..., TRUE ) with $idsOnly set, which returns bare IDs and never touches loadFromController() — every tile in the grid is then lazy-loaded over AJAX through line 295. That is why a broken chart shows up as an empty tile rather than as a missing tile.
Failure mode: the extension file exists but nothing appears
Registering the class file is not enough. Application::extensions() reads applications/<app>/data/extensions.json (line 917) and never scans the extensions/ directory. Use the Developer Center rather than hand-editing the JSON. This much is generic to every IPS 5 extension point and is covered in more detail in the ContentRouter article.
What is worth knowing here is that the two entry points cache differently, which produces a confusing half-working state. Application::extensions() re-reads the JSON on every request and caches only in a per-request static (static::$_loadedExtensions), so loadFromExtension() — your own page — picks up a new extension immediately. Application::allExtensions() caches the resolved list of all apps' extensions in the datastore under the key extensions and rebuilds only when that key is missing, so loadFromController() — the saved-chart path — keeps using the stale list. The symptom is a chart that renders perfectly on its own ACP page while saved copies of it vanish from Saved Reports. Clear caches from ACP → Support.
What is specific here is how you find out. loadFromExtension() throws a bare OutOfRangeException (line 88) when the key is not in the list. Nothing catches it, so you get an ACP error page pointing at your own controller — noisy, but at least it is obvious. loadFromController() throws the same exception from line 108, and there it is swallowed: getChartsForMember() catches OutOfRangeException and does continue (lines 265–268). So a saved chart whose extension can no longer be resolved simply disappears from Key Statistics with no error and no log entry, while its row stays in core_saved_charts forever.
Failure mode: fatal error when any statistics screen is opened
If you do not declare getChart(), PHP refuses to load the class:
Fatal error: Class IPS\acme\extensions\core\Statistics\Tickets contains 1 abstract method and must therefore be declared abstract or implement the remaining methods (IPS\core\Statistics\Chart::getChart)
This is a compile-time error raised by the autoloader inside the class_exists() call in Application::extensions() (line 926). It is a genuine PHP fatal, not an Error object, so none of the catch blocks discussed below can absorb it.
The blast radius is narrower than you might expect, and it is worth being precise about it. loadFromExtension( 'acme', 'Tickets' ) resolves to Application::load( 'acme' )->extensions( ... ), which reads only applications/acme/data/extensions.json — so a broken class of yours kills your own stats page and leaves core's alone. loadFromController() goes through allExtensions(), which walks every enabled app, so it also kills Statistics → Key Statistics → Saved Reports and the CSV export for everybody. Core's own Statistics → Registrations and the rest keep working.
The Developer Center skeleton (applications/core/data/defaults/extensions/Statistics.txt) generates getChart() with an empty body and a ChartClass return type. That compiles, but returns null, which is a TypeError at runtime. Fill the body in before you wire the extension to a controller.
Failure mode: the chart is blank on Key Statistics but fine on its own page
This is the one that costs the most time. mycharts::getChart() wraps the whole render in catch( Throwable ) (lines 290–310; the catch itself is line 300):
try
{
$report = $this->_getReport();
Chart::getBlockDateRangeForm( $report ? $report['id'] : null );
$chart = Chart::constructMemberChartFromData( ... );
$chart->options['height'] = '300px';
Output::i()->output = (string) $chart;
}
catch( Throwable )
{
if ( Request::i()->isAjax() )
{
Output::i()->output = '';
}
else
{
Output::i()->redirect( Url::internal( "app=core&module=overview&controller=mycharts" ) );
}
}
Throwable catches Error as well as Exception, so a TypeError, an undefined method, a SQL error or a missing language class inside your getChart() all produce the same thing: an empty AJAX response, an empty tile in the grid, and nothing in the system log. The same code on your own ACP controller is not wrapped, so the identical bug throws a visible ACP error there. If a chart works on Statistics → Your Page and renders as an empty box on Key Statistics, reproduce it by requesting app=core&module=overview&controller=mycharts&do=getChart&chartId=<id> directly and temporarily commenting out the catch.
Two things differ between the two contexts and are the usual culprits. First, Request::i()->app, module and controller are core/overview/mycharts, not yours — anything in getChart() that reads the request to decide what to draw will behave differently. applications/nexus/extensions/core/Statistics/Income.php line 72 does exactly this (Request::i()->tab == 'members' ? 'PieChart' : 'AreaChart') and therefore silently falls back to the area chart when re-rendered. Second, the date range comes from getChartDateRanges() and is assigned to $chart->start and $chart->end after your method returns, so any date defaults you set are overridden.
Failure mode: a saved chart renders somebody else's data
This is the trap that is unique to this extension point, and it is caused by the base class default.
public ?string $controller = 'NULL';
That is the literal four-character string NULL, not the null value. If you never override $controller but do call setExtension(), Dynamic::getController() sees is_string( 'NULL' ) === true and returns 'NULL'. Every chart the admin saves from your extension is stored with chart_controller = 'NULL'. So is every chart saved from any other third-party extension that made the same omission. loadFromController() returns the first match in allExtensions() order, so from then on all of those saved charts render whichever extension happens to sort first — with the saved title still attached, because the title is applied afterwards at line 192. The admin sees a chart labelled "New registrations" showing sales figures, and there is no error anywhere.
Core demonstrates the first-match-wins behaviour with duplicates of its own. applications/core/extensions/core/Statistics/Registration.php and Registrations.php are byte-identical apart from the class name, and both declare $controller = 'core_stats_registrationstats'. Only Registrations is ever loaded by registrationstats.php, but because Registration sorts first in applications/core/data/extensions.json, saved charts resolve to the unused duplicate. Here it makes no difference because the two produce the same chart. Reactions and ReactionsApp also share core_activitystats_reactions_type, and Language and Theme share core_stats_preferences_theme; those pairs do not produce the same chart, and Reactions and Language respectively sort first.
The Language/Theme pair is worth reading in full, because it is the whole failure mode in one place. Neither extension calls setExtension(), so getController() ignores their declared $controller and builds the string from the request instead. Both are rendered by applications/core/modules/admin/stats/preferences.php (lines 73 and 78) under tab=theme and tab=lang. So a chart saved from the Themes tab is stored as core_stats_preferences_theme, which loadFromController() resolves to Language — the wrong chart, under the saved title. A chart saved from the Languages tab is stored as core_stats_preferences_lang, which matches no extension at all and is silently dropped. Language.php line 38 declaring $controller = 'core_stats_preferences_theme' looks like a copy-paste slip; either way, the resolution rule is not in doubt.
Always set $controller, and make it globally unique. Core's convention is <app>_<module>_<controller>[_<tab>] matching the URL the chart lives on — forums_stats_posts_byforum for app=forums&module=stats&controller=posts&tab=byforum — which is the same string getController() would have built from the request. Following it means saved charts resolve identically whether or not setExtension() was called.
Failure mode: charts save but never come back
The opposite mistake. The Developer Center skeleton ships:
public ?string $controller = NULL;
with a real null. Now is_string( null ) is false, so getController() falls back to the request-derived string and the row is saved with, say, acme_stats_tickets. When Key Statistics later calls loadFromController( 'acme_stats_tickets' ), the guard is:
if ( $extension->controller AND $extension->controller === $controller )
null is falsy, so your extension is skipped, no other extension matches, OutOfRangeException is thrown, and getChartsForMember() quietly drops the row. The admin clicks "save", gets a success response, and the chart never appears on Key Statistics. Nothing is logged. The same happens if you forget setExtension() and your $controller string does not exactly equal the app/module/controller/tab string for the page — the row is saved under one key and looked up under another.
Failure mode: the chart is missing from the CSV export
The Reports CSV builder in mycharts::_compileCharts() skips silently in two cases (lines 626–638):
$chart = $chartData['chart'];
if ( !( $chart instanceof Dynamic ) )
{
continue;
}
...
// Cannot add pie charts
if ( $chart->type === 'PieChart' or $chart->type === 'GeoChart' )
{
continue; // todo this should probably be indicated to the user if the chart cannot be included in the CSV
}
Returning a bare IPS\Helpers\Chart rather than a Database or Callback therefore excludes you from CSV export — and, more importantly, a bare Chart has no setExtension() at all, so calling it is a fatal "call to undefined method". If your chart's resolved type is PieChart or GeoChart it is dropped from the CSV too; the todo comment in core confirms this is not surfaced to the admin.
Failure mode: TypeError from a constructor you added
IPS\core\Statistics\Chart declares no constructor, but Application::constructExtensionClass() instantiates every extension with one argument (system/Application/Application.php, line 469 onwards) — NULL in both call paths here, since neither passes a member. PHP tolerates the extra argument on a class with no constructor. If you add a constructor with a required, non-nullable parameter you get a TypeError, and the surrounding catch( RuntimeException | OutOfRangeException $e ){} at line 497 does not catch Error subclasses. Declare it as public function __construct( $member = NULL ) or not at all.
The $url argument, and why it matters
getChart() is handed the URL the chart is being rendered on, and the chart helper appends its own query strings to that URL for the timescale, type, date range and filter controls. Passing a different URL breaks those controls when the chart is rendered anywhere other than its own page. applications/gallery/extensions/core/Statistics/Bandwidth.php line 50 ignores the argument and hard-codes Url::internal( "app=gallery&module=stats&controller=bandwidth" ); the effect of that on the Key Statistics grid is not something we have measured, so treat it as a pattern to avoid rather than to copy. Use $url.
A minimal, complete example
This is applications/core/extensions/core/Statistics/Registration.php verbatim, minus the licence header and with one over-long inline comment trimmed. It is the shortest complete implementation in the suite and shows every part of the contract.
<?php
namespace IPS\core\extensions\core\Statistics;
use IPS\Helpers\Chart;
use IPS\Helpers\Chart\Database;
use IPS\Http\Url;
use IPS\Member;
use function defined;
if ( !defined( '\IPS\SUITE_UNIQUE_KEY' ) )
{
header( ( $_SERVER['SERVER_PROTOCOL'] ?? 'HTTP/1.0' ) . ' 403 Forbidden' );
exit;
}
class Registration extends \IPS\core\Statistics\Chart
{
/**
* @brief Controller
*/
public ?string $controller = 'core_stats_registrationstats';
/**
* Render Chart
*
* @param Url $url URL the chart is being shown on.
* @return Chart
*/
public function getChart( Url $url ): Chart
{
$chart = new Database( $url, 'core_members', 'joined', '', array(
'isStacked' => FALSE,
'backgroundColor' => '#ffffff',
'colors' => array( '#10967e' ),
'hAxis' => array( 'gridlines' => array( 'color' => '#f5f5f5' ) ),
'lineWidth' => 1,
'areaOpacity' => 0.4
) );
$chart->addSeries( Member::loggedIn()->language()->addToStack('stats_new_registrations'), 'number', 'COUNT(*)', FALSE );
$chart->title = Member::loggedIn()->language()->addToStack('stats_registrations_title');
$chart->availableTypes = array( 'AreaChart', 'ColumnChart', 'BarChart' );
$chart->setExtension( $this );
/* fetch only successful registered members */
$chart->where[] = array( 'completed=?', true );
return $chart;
}
}
The Database constructor signature (system/Helpers/Chart/Database.php, line 117) is:
public function __construct( Url $url, string $table, string $dateField, string $title='', array $options=array(), string $defaultType='AreaChart', string $defaultTimescale='monthly', array $defaultTimes=array( 'start' => 0, 'end' => 0 ), array $tableInclude=array(), string $identifier='', DateTime $minimumDate=null )
Give $identifier a distinct value if you render more than one chart on the same page; it is folded into the query-string keys for that chart's controls.
The matching entry in applications/acme/data/extensions.json:
{
"core": {
"Statistics": {
"Tickets": "IPS\\acme\\extensions\\core\\Statistics\\Tickets"
}
}
}
and the ACP controller that renders it:
protected function manage() : void
{
Dispatcher::i()->checkAcpPermission( 'tickets_manage', 'acme', 'stats' );
Output::i()->title = Member::loggedIn()->language()->addToStack( 'menu__acme_stats_tickets' );
Output::i()->output = Chart::loadFromExtension( 'acme', 'Tickets' )->getChart( Url::internal( 'app=acme&module=stats&controller=tickets' ) );
}
With $controller = 'acme_stats_tickets' on the extension, the string core derives from that URL and the string you declared agree, and saved charts resolve correctly whichever route they came in by.
Custom filters
If your chart needs its own filter form, set $chart->customFiltersForm to an array with form, where, groupBy, series and defaultSeries keys. applications/core/extensions/core/Statistics/ModeratorsByAction.php lines 103–171 is the reference implementation. The saved values come back to you as $chart->savedCustomFilters. It is filled in Dynamic::__construct() from two sources: any customform_* request parameters (line 424), and the chart_configuration JSON of a saved chart being viewed as a tab (line 406). When the chart is re-rendered from Saved Reports, the row's configuration gets into the constructor by a documented hack — constructMemberChartFromData() stashes it on Request::i()->storedFilters (line 180) and Database::__construct() line 129 moves it into Request::i()->filters. That transfer exists only in Database, not in Callback, so a Callback chart with custom filters will not see saved values in time to seed its form. Because the read happens after the chart object is constructed, the pattern in core is to construct the chart, call setExtension(), then read $chart->savedCustomFilters to seed the form defaults (line 88):
$customActionValues = ( isset( $chart->savedCustomFilters['chart_actions'] ) ) ? array_values( explode( ',', $chart->savedCustomFilters['chart_actions'] ) ) : -1;
Values arrive as comma-joined strings when they came from a saved row and as arrays when they came from the live form, which is why core's where callbacks all begin with an is_array() test.
$noTabs
No shipped extension sets $noTabs; every implementation in applications/*/extensions/core/Statistics/ leaves the inherited false. When it is true, Dynamic::loadAvailableChartTabs() short-circuits to an empty array (test at line 758, assignment at line 760) and the "show to all members" toggle on the save form is defaulted on and disabled (lines 558–559), with the submitted value forced to true again at line 613 so a tampered POST cannot get round it. The saved row therefore always has chart_member = 0.
Core's docblock on the property says such a chart "can only be saved to reports". Nothing in Dynamic.php implements that: statsreports_include_in_report is a separate, independent toggle, and a $noTabs chart saved without it lands with chart_report_id = NULL and shows up on Saved Reports for every admin. Read $noTabs as "no per-admin tabs, always shared", not as "reports only". Use it for charts that are meaningless per-admin.
Verified against
Read from Invision Community 5.0.19 source (applications/core/data/versions.json long version 5001908): applications/core/sources/Statistics/Chart.php, system/Helpers/Chart/Chart.php, system/Helpers/Chart/Dynamic.php, system/Helpers/Chart/Database.php, system/Helpers/Chart/Callback.php, system/Application/Application.php, system/Extensions/OverviewStatisticsAbstract.php, applications/core/modules/admin/overview/mycharts.php, applications/core/modules/admin/stats/registrationstats.php, applications/core/modules/admin/stats/preferences.php, applications/core/data/defaults/extensions/Statistics.txt, applications/core/data/extensions.json, applications/core/data/acpmenu.json, applications/core/data/acprestrictions.json, applications/core/dev/lang/stats.php, all 27 implementations in applications/core/extensions/core/Statistics/, and the implementations in applications/forums, applications/calendar, applications/downloads, applications/gallery and applications/nexus. Line numbers are from that source tree. Nothing here is carried over from IPS 4.x.
Related application: Community Stats — Community Stats reads the figures the suite already keeps through interfaces like this one, rather than running its own counting queries against your tables.
Recommended Comments