docs/subsystems/analytics.md
Zulip has a cool analytics system for tracking various useful statistics
that currently power the /stats page, and over time will power other
features, like showing usage statistics for the various channels. It is
designed around the following goals:
There are a few important things you need to understand in order to effectively modify the system.
There are three main components:
UserCount, StreamCount, RealmCount, and InstallationCount
tables (analytics/models.py) collect and store time series data.CountStat objects in the COUNT_STATS dictionary
(analytics/lib/counts.py) define the set of stats Zulip collects.FillState table (analytics/models.py) keeps track of what
has been collected for which CountStat.The next several sections will dive into the details of these components.
*Count database tablesThe Zulip analytics system is built around collecting time series data in a set of database tables. Each of these tables has the following fields:
CountStat
object. Example: "active_users_audit:is_bot:hour" or
"messages_sent:client:day".CountStat objects are further sliced by subgroup. For
"active_users_audit:is_bot:day", this column will be False for measurements of
humans, and True for measurements of bots. For "messages_sent:client:day",
this column is the client_id of the client under consideration.CountStat.Realm, UserProfile, Stream, or
nothing. E.g., the RealmCount table has a foreign key into Realm."active_users_audit:is_bot:hour" in the
RealmCount table, this is the number of active humans or bots (depending
on subgroup) in a particular realm at a particular end_time. For
"messages_sent:client:day" in the UserCount table, this is the number of
messages sent by a particular user, from a particular client, on the day
ending at end_time.There are four tables: UserCount, StreamCount, RealmCount, and
InstallationCount. Every CountStat is initially collected into UserCount,
StreamCount, or RealmCount. Every stat in UserCount and StreamCount is
aggregated into RealmCount, and then all stats are aggregated from
RealmCount into InstallationCount. So for example,
"messages_sent:client:day" has rows in UserCount corresponding to
(user, end_time, client) triples. These are summed to rows in RealmCount
corresponding to triples of (realm, end_time, client). And then these are
summed to rows in InstallationCount with totals for pairs of (end_time, client).
Note: In most cases, we do not store rows with value 0. See Performance strategy below.
CountStat objects declare what analytics data should be generated and stored. The
CountStat class definition and instances live in analytics/lib/counts.py.
These declarations specify at a high level which tables should be populated
by the system and with what data.
The default Zulip production configuration runs a cron job once an hour that
updates the *Count tables for each of the CountStat objects in the COUNT_STATS
dictionary. The FillState table simply keeps track of the last end_time that
we successfully updated each stat. It also enables the analytics system to
recover from errors (by retrying) and to monitor that the cron job is
running and running to completion.
An important consideration with any analytics system is performance, since it's easy to end up processing a huge amount of data inefficiently and needing a system like Hadoop to manage it. For the built-in analytics in Zulip, we've designed something lightweight and fast that can be available on any Zulip server without any extra dependencies through the carefully designed set of tables in PostgreSQL.
This requires some care to avoid making the analytics tables larger than the
rest of the Zulip database or adding a ton of computational load, but with
careful design, we can make the analytics system very low cost to operate.
Also, note that a Zulip application database has 2 huge tables: Message and
UserMessage, and everything else is small and thus not performance or
space-sensitive, so it's important to optimize how many expensive queries we
do against those 2 tables.
There are a few important principles that we use to make the system efficient:
FillState)*Count tables to avoid our endpoints hitting the core
Message/UserMessage tables is key, because some queries could take minutes
to calculate. This allows any expensive operations to run offline, and
then the endpoints to server data to users can be fast."insert into .. select" type SQL query that's needed
for this, which is why we use raw database queries (which we usually avoid
in Zulip) rather than the ORM.Message and UserMessage tables. E.g., rather than querying the Message
table both to generate sent message counts for each realm and again for
each user, we just query for each user, and then add up the numbers for
the users to get the totals for the realm.There are a few types of automated tests that are important for this sort of system:
For manual backend testing, it sometimes can be valuable to use
./manage.py dbshell to inspect the tables manually to check that
things look right; but usually anything you feel the need to check
manually, you should add some sort of assertion for to the backend
analytics tests, to make sure it stays that way as we refactor.
The system discussed above is designed primarily around the technical
problem of showing useful analytics about things where the raw data is
already stored in the database (e.g., Message, UserMessage). This is great
because we can always backfill that data to the beginning of time, but of
course sometimes one wants to do analytics on things that aren't worth
storing every data point for (e.g., activity data, request performance
statistics, etc.). There is currently a reference implementation of a
LoggingCountStat that shows how to handle such a situation.
The main testing approach for the /stats page UI is manual testing.
For most UI testing, you can visit /stats/realm/analytics while
logged in as Iago (this is the server administrator view of stats for
a given realm). The only piece that you can't test here is the "Me"
buttons, which won't have any data. For those, you can instead log in
as the [email protected] in the analytics realm and visit
/stats there (which is only a bit more work). Note that the
analytics realm is a shell with no channels, so you'll only want to
use it for testing the graphs.
If you're adding a new stat/table, you'll want to edit
analytics/management/commands/populate_analytics_db.py and add code
to generate fake data of the form needed for your new stat/table;
you'll then run ./manage.py populate_analytics_db before looking at
the updated graphs.
The relevant files are:
analytics/views/stats.py: All chart data requests from the /stats page call
get_chart_data in this file.web/src/stats/stats.ts: The JavaScript and Plotly code.templates/analytics/stats.htmlweb/styles/stats.css and web/styles/legacy_portico.css: We are in the
process of re-styling this page to use in-app CSS instead of the portico CSS,
but there is currently still a lot of portico influence.analytics/urls.py: Has the URL routes; it's unlikely you will have to
modify this, including for adding a new graph.Most of the code is self-explanatory, and for adding say a new graph, the
answer to most questions is to copy what the other graphs do. It is easy
when writing this sort of code to have a lot of semi-repeated code blocks
(especially in stats.ts); it's good to do what you can to reduce this.
Tips and tricks:
$.get to fetch data from the backend. You can grep through stats.ts
to find examples of this./stats page is nice but not critical, and we've
run into a lot of small bugs when trying to use Plotly's retrace/relayout.'paper' as a Plotly option refers to the bounding box of the graph (or
something related to that)./activity page, for server
administrators, showing data on all the realms on a server. To
access it, you need to have the is_staff bit set on your
UserProfile object. You can set it using manage.py shell and
editing the UserProfile object directly. A great future project is
to clean up that page's data sources, and make this a documented
interface.