lytics

Guide

Build a Postgres signup dashboard

Turn a table of accounts into two dashboard panels: the total number of accounts, and signups per UTC day for the last two weeks. You create a small sample table, a user that can only read it, and the two queries — then swap in your own table when it works.

Checked against Postgres 16 and the current lytics query path: every query on this page was run, as the read-only user below, the way lytics runs a panel, and gave the results shown here. Everything is counts; nothing on this page reads an email address or any other personal detail.

Before you start: where the database has to be

lytics runs your queries from its own server, not from your browser. So the database has to be one that server can reach:

Do not make a production database reachable from the internet just to follow this tutorial. A small database at a hosted Postgres provider, made for the purpose, is enough — or a database that is already served over the internet with TLS. Nothing here needs production data.

1. Create the sample table and data

A table of accounts with an id and the time each was created — the two things a signup chart needs. It goes in a schema of its own, lytics_tutorial, so it cannot collide with your tables and is removed with one statement. Run this as the database owner, in psql or any SQL client:

Sample schema
-- SAMPLE SCHEMA for the lytics tutorial. A schema of its own, so that
-- DROP SCHEMA lytics_tutorial CASCADE removes every trace of it.
CREATE SCHEMA lytics_tutorial;

CREATE TABLE lytics_tutorial.accounts (
  id         bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  created_at timestamptz NOT NULL DEFAULT now()
);

CREATE INDEX accounts_created_at ON lytics_tutorial.accounts (created_at);

Then the sample data: 53 fictional accounts, 44 of them in the last fourteen days. The rows are dated relative to today in UTC, so the panels show the same counts whenever you run it. Run it once — running it again adds another 53.

Sample data
-- SAMPLE DATA: 53 fictional accounts and nothing else — no names, no
-- email addresses. Dated relative to today (UTC), so the charts have
-- something to show whenever you run it. Run it once.
INSERT INTO lytics_tutorial.accounts (created_at)
SELECT least(
         now(),
         (date_trunc('day', now() AT TIME ZONE 'UTC')
            - make_interval(days => s.days_ago)
            + make_interval(mins => 37 + n * 151)) AT TIME ZONE 'UTC'
       )
  FROM (VALUES
         (40, 3), (31, 2), (22, 4), (13, 2), (12, 3), (11, 1),
         (10, 4), (8, 2), (7, 5), (6, 3), (5, 2), (4, 6),
         (3, 4), (2, 3), (1, 7), (0, 2)
       ) AS s(days_ago, signups)
 CROSS JOIN LATERAL generate_series(1, s.signups) AS n;

2. Create a user that can only read that table

lytics connects as whatever user you give it, so give it one that can read the table and nothing else. lytics runs every query read-only as well, but the database's own permissions are the part you control:

Least-privilege reader
-- Run as the database owner or an admin, in the database that has the table.
-- Replace your_database, and the password with a long random one.
CREATE ROLE lytics_reader LOGIN PASSWORD 'REPLACE_WITH_A_LONG_RANDOM_PASSWORD';
ALTER ROLE lytics_reader SET default_transaction_read_only = on;

GRANT CONNECT ON DATABASE your_database TO lytics_reader;
GRANT USAGE ON SCHEMA lytics_tutorial TO lytics_reader;
GRANT SELECT ON lytics_tutorial.accounts TO lytics_reader;   -- this table, nothing else

Replace your_database with the database's name and REPLACE_WITH_A_LONG_RANDOM_PASSWORD with a long random password — you type it into lytics once, in step 4. The ALTER ROLE line makes every session of this user read-only, whatever it connects with. To confirm what it can do:

-- Still as the admin: what can lytics_reader do?
SELECT has_table_privilege('lytics_reader', 'lytics_tutorial.accounts', 'SELECT') AS can_read,
       has_table_privilege('lytics_reader', 'lytics_tutorial.accounts', 'INSERT') AS can_insert;
-- can_read = true, can_insert = false

3. Create a lytics account

Create an account, or sign in. You arrive on Databases, where the rest of the tutorial happens. Connecting a database needs no app: an app only matters if you want the panels on its dashboard as well (step 7).

Create an account and open Databases

4. Connect the database

Under Connect a database, fill in the form from your provider's connection details. Each part of a connection string has its own field — do not paste a postgres:// URL into Host.

FieldWhat to enter
NameAnything you will recognise, e.g. Signup tutorial.
HostThe public hostname, e.g. db.example.com — a placeholder, use yours.
Port5432 unless your provider says otherwise.
DatabaseThe database you ran steps 1 and 2 in.
Userlytics_reader
PasswordThe one you chose in step 2.
TLSRequired, or Required, certificate checked if the server's certificate is issued for that hostname by a public authority.

Press Connect. lytics looks the host up first, and refuses it there if it is private. The password is encrypted before it is stored and is never shown again; to change it, remove the connection and add it again.

Then press Test on the new connection. It should say Connected as lytics_reader — PostgreSQL …. If it says something else, see Troubleshooting.

5. Add the total-accounts panel

Open Add a panel and fill it in:

Query: total accounts
SELECT count(*) AS accounts
  FROM lytics_tutorial.accounts

Press Try it: it runs the query without saving and shows 53. Then Save panel, which runs it once more and stores the result.

6. Add the daily-signups panel

Query: signups per UTC day
-- Signups per UTC day, the last 14 days including today.
-- A day with no signups is a 0, not a missing column.
SELECT to_char(d.day, 'YYYY-MM-DD') AS day,
       count(a.id) AS signups
  FROM generate_series(
         date_trunc('day', now() AT TIME ZONE 'UTC') - interval '13 days',
         date_trunc('day', now() AT TIME ZONE 'UTC'),
         interval '1 day'
       ) AS d(day)
  LEFT JOIN lytics_tutorial.accounts a
         ON a.created_at >= d.day AT TIME ZONE 'UTC'
        AND a.created_at <  (d.day + interval '1 day') AT TIME ZONE 'UTC'
 GROUP BY d.day
 ORDER BY d.day

Three things about its shape matter to lytics:

The generate_series makes fourteen days whether or not anyone signed up on them; the LEFT JOIN and count(a.id) turn an empty day into a 0. Try it should show fourteen columns adding up to 44; then Save panel.

7. Check the stored results

With the sample data, the two panels show this. The dates below are worked out in UTC; your columns carry the dates of the fourteen days ending today in UTC, and today's column keeps filling until midnight UTC.

Accounts

Sample result
53

Signups per day

Sample result

44 signups over 14 days — the table below has every day.

daysignups
13 days ago2
12 days ago3
11 days ago1
10 days ago4
9 days ago0
8 days ago2
7 days ago5
6 days ago3
5 days ago2
4 days ago6
3 days ago4
2 days ago3
yesterday7
today2
Last 14 days44

The other 9 accounts are older than two weeks: they count towards the total, not the chart.

Under each panel on Databases, Updated … in … ms says when its stored result was made. That stored result is what every screen shows — Databases, an app's dashboard and its embeds; none of them runs the query. It is replaced when:

To put the panels on an app's dashboard, choose the app under On on each panel, or under On the dashboard of when you add it. Pinned panels also appear in that app's embeds, which is one more reason to keep them to counts.

Limits

LimitValue
Statements per queryOne SELECT (or WITH … SELECT), no trailing script. It runs inside a read-only transaction that is rolled back.
Time15 seconds per query; 5 seconds to connect.
Rows1,000 stored. Tables show the first 100 and columns and lines the first 200.
Columns and cells50 columns; text longer than 500 characters is cut.
Query length20,000 characters.
Runs by handTest, Try it and Run nowtogether: 20 a minute.
Per account20 connections and 100 panels.

Use your own table

Once the sample works, point the same two queries at your own signups. You need the table, and the column that records when each row was created:

SELECT to_char(d.day, 'YYYY-MM-DD') AS day,
       count(t.your_created_column) AS signups
  FROM generate_series(
         date_trunc('day', now() AT TIME ZONE 'UTC') - interval '13 days',
         date_trunc('day', now() AT TIME ZONE 'UTC'),
         interval '1 day'
       ) AS d(day)
  LEFT JOIN your_schema.your_table t
         ON t.your_created_column >= d.day AT TIME ZONE 'UTC'
        AND t.your_created_column <  (d.day + interval '1 day') AT TIME ZONE 'UTC'
       -- AND t.deleted_at IS NULL     -- any filter goes here, in the ON
 GROUP BY d.day
 ORDER BY d.day
  LEFT JOIN your_schema.your_table t
         ON t.your_created_column >= d.day
        AND t.your_created_column <  d.day + interval '1 day'

Built on lp-data.cloud? Its apps share the same user tables, and Add the lp-data.cloud panels writes these queries for you — see the docs.

Troubleshooting

Connection errors appear under the connection after Connect or Test; query errors under the form after Try it, or under the panel after a run.

SymptomWhat to check
Connect answers “… resolves to a private address, which lytics does not connect to” or “That address is on a private network …”.The host is localhost, a container name, or resolves to a private, loopback or link-local address, and lytics refuses those for every account. Use a database with a public hostname — see Before you start. Do not open a production database to the internet to get past this.
Connect answers “… does not resolve”.The hostname has no DNS record that lytics can see. Copy it again from your provider, without postgres://, a port or a path — those go in their own fields.
Test says “Could not connect: timeout expired” or “… ECONNREFUSED …”.The name resolves, but nothing answered on that port within five seconds. Check the port, and that the database accepts connections from outside its own network: providers often have a firewall or an allowed-address list in front of a public endpoint.
Test says “password authentication failed”, “no pg_hba.conf entry …” or “The server does not support SSL connections”.The first is the user or password; a password cannot be changed afterwards, so remove the connection and add it again. The second is the server's own access rules, which must allow lytics_reader from outside. The third means TLS is set to Required and the server has none — use a server that has it rather than switching TLS off.
A panel or Try it says “permission denied for schema …” or “… for table …”.The reader is missing one of the grants. The schema needs USAGE and the table needs SELECT; run the grants above again for the schema and table the query names.
The columns chart says “Nothing to chart: the query needs a label column followed by a numeric one”.Either the query returned no rows, or the columns are the wrong way round. The day must be the first column and the count the next. A query that groups only the rows that exist returns nothing for an empty week; the generate_series form above always returns fourteen rows.
Every day is 0, or the number is lower than expected.The query is reading a different table, schema or database from the one you seeded, or the timestamp column holds something other than signup times. Run the same query in psql as lytics_reader: if it gives the same answer there, the data is the cause, not lytics.
The panel shows yesterday's numbers.Look at the line under it. Updated … is when the stored result was made; a failed: … after it means the last run did not work and the previous result is still showing. Run now runs it again at once. Otherwise it waits for its interval — up to five minutes longer, because refreshes are checked every five minutes.
“canceling statement due to statement timeout”.The query took longer than fifteen seconds. On a large table, index the timestamp column — the range in the join can use it — and keep the window short.
“that is a lot of queries — wait a minute”.Test, Try it and Run now share a limit of twenty a minute per account. Wait a minute and try again.

Clean up

When you have moved the panels to your own table, remove the sample. In lytics, delete the two sample panels. In the database:

Remove the sample
-- When you are done with the sample, as the admin:
DROP SCHEMA lytics_tutorial CASCADE;
-- and, if the reader is not reading anything else:
REVOKE CONNECT ON DATABASE your_database FROM lytics_reader;
DROP ROLE lytics_reader;

Next