Users, revenue and whatever your product counts — from your own database. Either Erdi pulls a route in your app, or a script in the repo posts them.
You need somewhere your numbers already live — your own database, or an analytics tool with an API like TelemetryDeck, PostHog or Mixpanel. Neither path invents counts; both only read what you already store. If nothing counts your users yet, connect RevenueCat and App Store Connect first. They hold what happens outside your app: downloads, subscriptions, revenue.
Events are moments: a deploy, a release, a ticket spike. Metrics are daily totals: how many people used the product, how much you earned, how much you spent. Both land on the same chart. The shape of the request is on the API page — this page is the path for numbers you already keep yourself.
A route in your app — ErdiKnows calls it on a schedule (GET with a shared secret). Prefer this when the app has a reachable host. Erdi notices when nothing arrives, because it is the one pulling. You do not need a cron job or GitHub Action.
A script in your repo — you POST to /api/v1/metrics. Use this when there is no public host to call: pure mobile apps, local-only setups, or a database Erdi cannot reach. Then you need a schedule (CI or cron) and a token expectation under Settings → API so a quiet feed is a signal.
Under Settings → Sources → Your own endpoint, paste the URL and a shared secret. The setup prompt there asks your coding agent to add the route. Erdi GETs {url}?from=YYYY-MM-DD&to=YYYY-MM-DD with Authorization: Bearer {secret}. Wrong or missing secret → 401.
Because Erdi pulls, a quiet window is visible: set Expect data every on the source (same idea as connectors). No scheduler on your side.
The response body is the same shape as POST /api/v1/metrics: a JSON array of metric objects, a single object, or { "metrics": [ … ] }. Each object needs metric, day (YYYY-MM-DD), and value (number). Optional on first appearance: label, unit, group, role, direction, primary. Erdi stores the rows as source endpoint — you do not need to send source.
[
{
"metric": "dau",
"day": "2026-08-21",
"value": 412,
"label": "Daily active users",
"unit": "count",
"group": "Product",
"role": "users",
"direction": "up_good"
},
{
"metric": "scans_photo",
"day": "2026-08-21",
"value": 1803,
"label": "Photo scans",
"unit": "count",
"group": "Scans"
}
]Prefer the route when you can. Choose this path when Erdi cannot reach your data: a native app with no backend URL, a laptop-only database, or a vendor API you only call from CI. Then someone (or a schedule) must post, and you should set how often Erdi expects a send on the metrics token under Settings → API — leave it empty for no monitoring.
You need bash, curl and jq. Under Windows, use WSL.
scripts/erdi-metrics.sh loads the token, calls collect_range, and posts the JSON array to POST /api/v1/metrics. You implement only collect_range — against the database your product already has. Do not invent series the schema cannot compute.
The example uses psql, but collect_range can run anything — a REST call to Supabase, a Node script against Firebase, a query to your analytics provider's API. Erdi only cares that it prints metric, day and value, tab-separated.
The app generates this for you, with your token already in it — Settings → API → Post your own numbers.
#!/usr/bin/env bash
set -euo pipefail
# Post daily product metrics to ErdiKnows.
# Implement collect_range only — do not invent metrics your schema cannot compute.
read_dotenv() {
local file="$1" key="$2" line value
line="$(grep -E "^${key}=" "$file" | tail -n 1 || true)"
[[ -n "$line" ]] || return 1
value="${line#${key}=}"
value="${value%$'\r'}"
case "$value" in
\"*\") value="${value#\"}"; value="${value%\"}" ;;
\'*\') value="${value#\'}"; value="${value%\'}" ;;
esac
printf '%s' "$value"
}
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
repo_root="$(cd "$script_dir/.." && pwd)"
env_file="$repo_root/.env.local"
if [[ -z "${ERDI_TOKEN_METRICS:-}" && -f "$env_file" ]]; then
ERDI_TOKEN_METRICS="$(read_dotenv "$env_file" ERDI_TOKEN_METRICS || true)"
fi
if [[ -z "${ERDI_TOKEN:-}" && -f "$env_file" ]]; then
ERDI_TOKEN="$(read_dotenv "$env_file" ERDI_TOKEN || true)"
fi
ERDI_TOKEN="${ERDI_TOKEN_METRICS:-${ERDI_TOKEN:-}}"
if [[ -z "${ERDI_TOKEN:-}" ]]; then
echo 'No ERDI_TOKEN_METRICS or ERDI_TOKEN found — checked the environment and ./.env.local' >&2
exit 1
fi
if [[ -z "${ERDI_TZ:-}" && -f "$env_file" ]]; then
ERDI_TZ="$(read_dotenv "$env_file" ERDI_TZ || true)"
fi
ERDI_URL="${ERDI_URL:-https://erdiknows.com}"
ERDI_TZ="${ERDI_TZ:-Europe/Berlin}"
FROM_DAY=""
TO_DAY=""
while [[ $# -gt 0 ]]; do
case "$1" in
--from)
FROM_DAY="${2:?--from needs YYYY-MM-DD}"
shift 2
;;
--to)
TO_DAY="${2:?--to needs YYYY-MM-DD}"
shift 2
;;
-h|--help)
echo "Usage: scripts/erdi-metrics.sh [--from YYYY-MM-DD] [--to YYYY-MM-DD]"
echo "Defaults: yesterday in ERDI_TZ (inclusive)."
exit 0
;;
*)
echo "Unknown argument: $1" >&2
exit 1
;;
esac
done
if [[ -z "$TO_DAY" ]]; then
TO_DAY="$(TZ="$ERDI_TZ" date -d 'yesterday' +%Y-%m-%d 2>/dev/null || TZ="$ERDI_TZ" date -v-1d +%Y-%m-%d)"
fi
if [[ -z "$FROM_DAY" ]]; then
FROM_DAY="$TO_DAY"
fi
# ---------------------------------------------------------------------------
# Implement this. Args: FROM_DAY TO_DAY (YYYY-MM-DD, inclusive).
# Print a JSON array to stdout, one object per metric per day, e.g.:
# [{"metric":"dau","day":"2026-08-01","value":412,
# "label":"Daily active users","unit":"count","direction":"up_good"}]
# Only emit metrics your database can actually compute. Empty array is fine
# for a day with nothing to report — inventing numbers is not.
# ---------------------------------------------------------------------------
collect_range() {
local from="$1" to="$2"
echo "collect_range is not implemented ($from → $to)." >&2
echo "Query this project's database and print a JSON array to stdout." >&2
return 1
}
PAYLOAD="$(collect_range "$FROM_DAY" "$TO_DAY")"
export ERDI_URL ERDI_TOKEN PAYLOAD
python3 <<'PY'
import json
import os
import urllib.error
import urllib.request
raw = os.environ["PAYLOAD"].strip() or "[]"
try:
body = json.loads(raw)
except json.JSONDecodeError as err:
raise SystemExit(f"collect_range must print JSON: {err}") from err
if not isinstance(body, list):
raise SystemExit("collect_range must print a JSON array")
url = os.environ["ERDI_URL"].rstrip("/") + "/api/v1/metrics"
req = urllib.request.Request(
url,
data=json.dumps(body).encode(),
headers={
"Authorization": f"Bearer {os.environ['ERDI_TOKEN']}",
"Content-Type": "application/json",
},
method="POST",
)
try:
with urllib.request.urlopen(req) as resp:
print(resp.read().decode() or "Posted.")
except urllib.error.HTTPError as err:
detail = err.read().decode()
raise SystemExit(f"Erdi metrics failed ({err.code}): {detail}") from err
PY
After collect_range works, fill the chart once:
./scripts/erdi-metrics.sh --from 2026-05-27
Ninety days is enough for the curve to mean something. Erdi upserts by project, metric, source and day — re-running the same range is safe and fixes late rows.
The setup prompt under Settings → API creates .github/workflows/erdi-metrics.yml for you when the repo has a GitHub remote. It runs three times a day and on workflow_dispatch, posting a short recent window with ./scripts/erdi-metrics.sh. Cron or another CI is fine if you are not on GitHub. When you create the metrics token under Settings → API, you can set how often Erdi should expect a send. Leave that field empty for no monitoring; if a send is expected and nothing arrives, Erdi emails you.
Before the workflow can succeed, add two repository secrets at https://github.com/<owner>/<repo>/settings/secrets/actions: ERDI_TOKEN_METRICS (the metrics token from Settings → API) and whatever the script needs for the database — usually DATABASE_URL. Paste each value carefully: a trailing newline in the secret breaks the database connection with an error that does not mention the newline.
GitHub disables scheduled workflows in repositories with no commits for 60 days. A manual run or a new commit turns them back on.
Locally, set ERDI_TOKEN_METRICS (or ERDI_TOKEN) and ERDI_TZ in .env.local. Optional: ERDI_URL if you are not on erdiknows.com.
Required on every row: metric, day, value. Everything below is optional and only read the first time that metric appears. Later sends update the number for the day; they do not rename the series or change how Erdi draws it. Rename and adjust under Settings → Metrics.
| Field | What it is |
|---|---|
label | The name on the chart and in pickers. Defaults to the metric key if you omit it. Keep it short and human — "Daily active users", not a column name. |
unit | How the number is formatted. count, currency, percent, average, duration. Defaults to count. Currency uses the project locale; percent expects 0–100 or a fraction you already scale; duration is seconds. |
group | A folder in the metric picker — "Traffic", "App Store sources", "Scans". Metrics in the same group sit together. Omit for a top-level series. |
role | A known product meaning so the overview and alerts can compare projects: revenue, users, signups, tickets, spend, cost, paying_users, active_payers, trials. trials is a flow (trial starts in a window — Acquisition intent before payers). paying_users is a flow (new paying customers in a window — CAC, funnel paying stage). active_payers is a stock (active paying customers on a day — ARPU, serving cost, Growth). Erdi derives active_payers from subscribers; do not send it yourself. Set trials on your own trial-start series if you are not on RevenueCat. spend is what you pay to grow (ads, sponsorships, influencer). cost is what it costs to run (model calls, infrastructure, per-use APIs). Cost-per-customer uses spend next to paying_users — never cost. Omit if the series is just for the chart. |
funnel_stage | Optional funnel step for the Product tab: attention, arrival, signup, activation, trial, paying. Several metrics may share a stage and are summed. You can also set this under Settings → Metrics. Stages with nothing mapped are skipped. |
direction | Whether up is good; used by alerts and the weekly mail. up_good for revenue, users, signups; down_good for cancellations, churn, crash rate. Omit or leave empty when you do not know — Erdi will not colour a delta or suggest a direction for that series. Values: up_good, down_good. |
lag_days | How many calendar days late this series usually arrives. 0 for same-day numbers. App Store and Play use 2 so a missing Tuesday is drawn as pending, not as a crash. Defaults to 0 on create; change under Settings → Metrics after the series appears. |
The Product tab compares sums of daily metrics between stages, not the same people moving through a cohort. A 13% signup→activation rate means those daily totals stood in that ratio — not that 13% of the same cohort activated. That is good for spotting change next to what shipped; it is not an exact conversion rate. Exact conversion needs per-user state Erdi does not store. Transition rates stay grey until both stages have at least five in the window (Not enough data yet · N of 5).
Thresholds live in one place in the product. Five days with a value (or five new / active paying customers as a denominator) before a %, unit economics tile, or week-over-week delta shows. Overview "What moved" lists use a 15% absolute week change; the Investigate detail table uses 10% so you can still see smaller moves on the day you opened. Releases need eight comparable 7-day windows in ninety days before "Typical 7-day swing" damping applies — until then the row says baseline not established yet.
Daily series live in metrics — one value per metric per day. That rollup is what Timeline, Overview, alerts and export read. Some connectors also write metric_rows: the same metric name, same day, plus a small JSON dimension such as {"country":"DE"} or {"campaign":"…"}. Acquisition uses those rows for country and campaign breakdowns. Nothing else in the product reads them yet — alarms and CSV export stay on the rollup so a noisy long-tail country cannot trip a threshold or flood a download.
Rows older than ninety days are deleted on the hourly tick. The rollup in metrics is kept on its own schedule. Keyword, device and other dims can use the same table later without changing how Timeline works.
The six above exist in almost every product. What makes a timeline actually useful is the two or three that only exist in yours — and your agent can find them, because it can read your schema and you can't read it all at once.
Ask it this, any time, in the repo:
Read the schema of this project's database. Ignore what we already send to ErdiKnows. For each of these, tell me whether this product has the data, and name the table and column: 0. First, list what Erdi already receives — the metrics visible under Settings → Metrics, and which source writes each one. Anything on that list is out of scope. Do not propose a metric that a connected vendor already provides, even if our database could answer it too. 1. Cost of operation — model calls, API usage, anything metered per use. If a table logs tokens, requests or units consumed, that's a daily cost metric. 2. The core action — the one thing a user does that means the product worked. Not opens, not sessions. 3. Failure rates — anywhere a success or error column sits next to a count. Both belong on the timeline; the gap explains drops. 4. Funnel steps — signup, activation, first action, payment. Each as its own metric. 5. Anything with a status or type column and a small fixed set of values. One metric per value, sharing a group. 6. A number that should go DOWN — errors, cancellations, refunds, support tickets, cost per unit. 7. Post-install quality — what happens after someone arrives. Signups that finish onboarding on the same day, users still active a week later, first-purchase rate. Ad platforms report up to the install and stop; these are the numbers that say whether the install was worth buying. If the schema can express any of them as a daily count, they belong on the timeline. For each one you find, propose a metric name, a label, a unit, a group and whether up is good. Say what you'd leave out and why. Don't write any code yet.
It comes back with a list. Pick what you want, then tell it to add those to the route handler or to collect_range. For the script path, run with --from to fill them in for the past; for the route, Erdi backfills on the next syncs.
Point six catches the most people. Every product has a number that should go down, and almost nobody puts it next to the ones that should go up.
A metric is one value per metric per day per source. If your script or route sends revenue and you also connect Stripe or RevenueCat, both are stored — and the chart adds them up. Erdi flags this under Settings → Metrics, where you pick which source the chart uses. The simplest fix is to check that page before adding a metric, and skip the ones a connector already covers.