Custom metrics contract
Custom metrics let your extension's own service push whatever numbers matter for it (conversions, revenue, backend health, funnels) into the same panel that already tracks installs and Chrome Web Store health. The contract is metadata-driven and dual-surface: every metric declares where and how it appears both on the dashboard and in the Telegram status, so the panel stays a generic renderer with no product-specific code.
1. How it works (dual surface)
Your service exposes one metrics endpoint. Ext Ops Panel is the client: it pulls that endpoint (never the reverse) and renders the response. The response drives two surfaces at once:
- Dashboard tabs in the extension Detail view: tabs, sections and per-metric render kinds.
- Telegram status: logical lines composed from the same metrics, filtered and formatted per metric.
The panel merges your metrics with the first-party base metrics (installs / uninstalls / reinstalls / feedback / Chrome Web Store / health), which live in the reserved base tab and Telegram line.
2. The endpoint and auth
The panel calls your endpoint with GET and expects the v2 payload as the 200 response. Two pull modes share the same shape:
- On-demand: triggered when a user opens Detail. Rate limited to at most 1 request per minute per source, on all plans including Free.
- Monitoring: on a schedule on plans that include monitoring, composes the Telegram status and edge alerts.
Authentication is one of none, bearer or api_key_header. Secrets are stored encrypted at rest on the panel backend and are never handed to the dashboard extension. If a pull fails, the panel degrades gracefully (last snapshot or hidden) without breaking the base metrics.
3. Payload structure
The top-level payload carries the layout (tabs, sections, Telegram lines) and the metrics array.
| Field | Type | Notes |
|---|---|---|
schema_version | string | Required. Must be "2.0". |
generated_at | string (date-time) | When the payload was composed (freshness / idle detection). |
tabs | Tab[] | Dashboard tabs. See section 4. |
sections | Section[] | Groups of metrics inside a tab (tab, id, title, order). |
telegram | TelegramMeta | header template + lines[]. See section 7. |
metrics | Metric[] | The metrics themselves. See sections 5 and 6. |
4. Tabs and tab icons
A Tab is { id, title, order, icon? }. The reserved
base tab is the panel's own
(icon="layout-dashboard"); your service adds the rest. When many
tabs are open, the active tab shows icon + title and the others collapse to the
icon only (title stays in the tooltip and aria-label).
Two tab ids are reserved for the panel: base (the Basic tab) and
monitoring (the built-in Monitoring tab that heartbeat reports fill, see the
monitoring contract). Do not declare them in tabs:
a declaration is ignored, and the panel sets their title, icon and order. Metrics still
address them through dashboard.tab.
The icon value is one of two things:
- A Lucide icon name in kebab-case ASCII (matches
^[a-z][a-z0-9-]*$), e.g.trending-up. A well-formed but unsupported name degrades to the default icon (square) so the bar never breaks. - A single emoji, e.g. 💰 or 🚀, rendered as text.
The panel ships a curated catalog of 60 Lucide names (kept lean for the MV3 bundle). The same catalog serves tab icons and goal icons (section 5.3):
- layout-dashboard
- activity
- gauge
- layers
- plug
- dollar-sign
- banknote
- wallet
- credit-card
- receipt
- bar-chart-3
- line-chart
- pie-chart
- trending-up
- trending-down
- users
- user
- alert-triangle
- bell
- clock
- git-branch
- package
- box
- database
- server
- cpu
- zap
- globe
- pencil
- puzzle
- funnel
- filter
- square
- circle
- hash
- percent
- tag
- flag
- user-plus
- log-in
- plus-square
- shopping-cart
- check-circle
- send
- download
- upload
- star
- heart
- eye
- mouse-pointer-click
- share-2
- message-square
- target
- rocket
- file-text
- bookmark
- calendar
- key
- play
5. Metric: value + two placement blocks
A metric carries a typed value plus two independent blocks:
dashboard (where and how in the extension) and
telegram (where and how in the message). Either can be omitted or
hidden via show:false, so a metric can live on one surface only.
| Field | Notes |
|---|---|
id | Stable metric id, unique in the payload. |
kind | Data type (section 5.1). Selects which value fields apply. |
label | Canonical human label. |
value and kind fields | Payload fields depend on kind (section 5.1). |
severity | One of ok, info, warn, crit (maps to color / emoji). |
shareable | Optional boolean, default true. When false the metric is owner-only: on a project shared read-only it is stripped server-side and never reaches an observer / public view (use for financial metrics). Governs access, not placement. |
dashboard | { show, tab, section, order, name, render, options } (section 5.2). |
telegram | { show, line, pos, format, sep, when } (sections 6 and 8). |
5.1. Kinds (data type + payload)
Kinds are an open set: an unknown kind is ignored by the renderer (forward compatible).
| kind | Payload fields | Example use |
|---|---|---|
number | value (+ optional delta, unit) | install / feedback counts |
delta | value, delta (renders 1200 (+72)) | total (+today) |
percent | value (0 to 100) | engine success rate |
duration | seconds or minutes | p50/p95/avg, freshness |
bytes | bytes or kb | output size (~522KB) |
rating | value, count | 5.0★ (1) |
money | value, currency, optional delta | MRR, spend |
status | state + severity | health traffic light |
series | points:[{date,count}], optional multi series:[{name,points}], stacked with stack: true | daily installs, active users |
intraday | series:[{name,intraday:[{minute,value}]}], optional step_min (default 15), tz | today by 15-minute buckets |
hour_histogram | { weekdays:[...], start, end, tz } | conversions by hour |
funnel | steps:[{key,label,value}] | pricing to checkout |
breakdown | items:[{label,value,unit?,sub?/money?,country?,parts?}], optional ranges:{today,week,month,all} | subs by plan, by engine, by surface; visits by source or country |
list | items:[{text,count?,severity?}] at the top level of the metric, not under value | recent errors |
table | items:[{…}], one object per row; columns from dashboard.options.columns, else the keys of the first row | queues, recent sends |
text | value (string) | free text |
A table metric:
{ "id": "queues", "kind": "table", "label": "Mail queues",
"items": [ { "queue": "outbound", "size": 3, "oldest": "2m" },
{ "queue": "deferred", "size": 0, "oldest": "-" } ],
"dashboard": { "show": true, "tab": "backend", "section": "engines", "order": 5, "render": "table",
"options": { "columns": ["queue", "size", "oldest"] } } }5.2. Render (how to show on the dashboard)
dashboard.render picks the visual (usually matches kind,
but may differ, e.g. show a percent as a badge). Also
an open set.
| render | Look | Options (dashboard.options) |
|---|---|---|
kpi | Card: name + large value + delta; with stack, the parts in one row under the value | tone, stack |
badge | Status pill / pictogram by severity | – |
chart | Line chart (Month/Lifetime, rolling-7 avg, hover); with stack: true, stacked columns per day with a Total row | series, defaultMode, stack |
intraday | Today by buckets (96 per UTC day): stacked bars or smoothed lines, time axis HH:MM, hover readout across series | mode (bars | lines, default lines) |
hour_histogram | Hourly bars, grouped Weekdays / Weekend / By day of week | series |
funnel | Steps with conversion between them | – |
breakdown | Horizontal categorical bars with value + share of total. With ranges, a today / 7 days / 30 days / all time toggle; with map: countries, a world map above the list; with stack, bars split into toggleable parts | items, defaultRange, map, stack |
table | Table (e.g. feedback) | columns |
text | Paragraph / inline | – |
status | Traffic-light state by severity | – |
list | List of items with optional count / severity | – |
intraday vs chart. The chart
axis is daily (points[].date = YYYY-MM-DD) and its
Month view slices to the last 30 points, so today's 96 buckets do not fit on it.
intraday is the first-party Today chart exposed as a contract kind: the
same convention where minute counts minutes from the start of the UTC day
(0, 15, …, 1425), but the series and data are declared by the source. Send only
non-empty buckets, the panel densifies the rest.
An empty bucket is null, not 0. For an
average (e.g. mean conversion time) a zero would be a lie, so the line breaks
instead of dropping to the floor. A missing bucket and value: null are
equivalent: the line breaks, no bar is drawn, and the hover readout shows a dash.
Stacked: the total and its parts in one chart and one list. When a metric is made of parts (page views of two sites, say), send the total and the parts as ONE metric: the panel draws the parts on top of each other and the legend switches each one on and off, with no extra tabs and no dropdown filters. Both extensions are additive: a renderer that does not know them draws the same metric as before.
Chart: kind: "series" with dashboard.options.stack: true.
Series stack in array order, the first one is the bottom layer. The panel draws one column per
day (in lifetime, per 7-day window) with a segment per visible series, so the top of a column is
the exact sum of the visible series; a day where they are all zero has no column. Clicking a
legend key hides a layer (the last visible one stays on); the rest is redrawn from zero and the
value axis follows the visible total. Hovering a column, or moving to it with the arrow keys,
reads out each visible layer and a Total row. Lifetime stacks the rolling 7-day average of each
series, marked with ~. Any other value of stack keeps the overlaid lines.
List: kind: "breakdown" with dashboard.options.stack
set to an ordered list of parts [{key, label}], and parts: {"key": number}
on every item, in items and in every ranges window. value
must equal the sum of all parts. The panel shows one chip per part that switches it for the
whole card, across all windows. A row shows the sum of its visible parts, rows at zero are
hidden, the list re-sorts by that sum, and the bar is split into one segment per visible part.
With map: "countries" the circles follow the visible sums. An item without
parts counts whole whatever is switched off.
{ "id": "site_sources", "kind": "breakdown", "label": "Sources",
"items": [ { "label": "Search engines", "value": 812, "parts": { "makcards": 790, "journalingapp": 22 } } ],
"ranges": { "today": [ { "label": "Search engines", "value": 30, "parts": { "makcards": 29, "journalingapp": 1 } } ],
"month": [ { "label": "Search engines", "value": 812, "parts": { "makcards": 790, "journalingapp": 22 } } ] },
"dashboard": { "show": true, "tab": "site", "section": "site_sources", "render": "breakdown",
"options": { "defaultRange": "month",
"stack": [ { "key": "makcards", "label": "makcards.online" },
{ "key": "journalingapp", "label": "journalingapp.app" } ] } } }
Tile: a kpi tile of kind number or delta
takes the same dashboard.options.stack list and parts: {"key": number}
on the metric itself. The large value stays value, which must equal the sum of all
parts. Under it the panel prints the parts in one row in stack order (the row wraps on a narrow tile), with the colour the same
name has on a stacked chart of the tab; a part at zero is muted. The tile has no switches. The
project list headlines and Telegram keep reading value.
{ "id": "site_views_today", "kind": "number", "label": "Page views", "value": 42,
"parts": { "portal": 30, "cabinet": 4, "backoffice": 8 },
"dashboard": { "show": true, "tab": "site", "section": "site_today", "render": "kpi",
"options": { "stack": [ { "key": "portal", "label": "zakazindia.ru" },
{ "key": "cabinet", "label": "Client cabinet" },
{ "key": "backoffice", "label": "Backoffice" } ] } } }5.3. The project list: users and goals from the Basic tab
The project list shows a few numbers of a web service without opening it. The panel reads them
from the base tab only, and only when the metric has a numeric
value. The first match wins.
| Metric | How the panel finds it | Where it is shown |
|---|---|---|
| DAU, WAU, MAU | The id, label or dashboard name is exactly dau, wau or mau (any case). | WAU and MAU in the Users column, DAU in Today. |
| Goals 1 to 3 | The id is exactly goal_1, goal_2 or goal_3. | Today, on the same line as DAU: an icon and a number. |
A goal is a target action of your users: sign-ups, purchases, sent forms. You choose the window
of the number and the panel shows it as is. We recommend counting today and naming the window in
the label (for example, Sign-ups today). The label is the tooltip, up to 80 characters. The
icon field takes a name from the catalog in section 4; emoji are not accepted for
goals. Without an icon, or with an unknown name, the panel draws target. A zero is
shown as a dimmed dash.
{ "id": "goal_1", "kind": "number", "label": "Sign-ups today", "value": 12, "icon": "user-plus",
"dashboard": { "show": true, "tab": "base", "section": "goals", "render": "kpi" } }The panel keeps only the latest values, as with DAU. Every successful pull replaces them, and a goal that is no longer in the response disappears from the list.
Several surfaces
A custom-metrics source belongs to one surface of your service, and the panel keeps the numbers per surface. The project list shows their sum; the project page shows each surface on its own.
- Several sources of one surface count as the latest one pulled. Sources without a surface form one more group.
- Only production surfaces count. A surface in a staging, test or development environment is left out.
- A surface whose latest numbers are more than 48 hours behind the freshest one is left out.
- Each window adds up over the surfaces that send it, and goals add up by id, so a goal must mean the same on every surface.
- The sum is an upper bound: a person who uses two surfaces is counted twice.
Chrome extensions
An extension uses the same convention. When its feed sends dau and goals on the base tab, the project list shows them on one more line under the install, uninstall, feedback and reinstall counters, in the same format as the Today line of a web service.
- WAU and MAU of an extension are not shown in the list; its Users column is the Chrome Web Store user count.
- Count DAU and goals for the current UTC day: the counters on the same row use UTC days.
- Several sources of one extension add up by the rules above. There is no change against yesterday.
- Only the author sees the line. The row's refresh button also pulls the custom metrics.
5.4. Thresholds (warn and crit)
A numeric metric may carry thresholds. The panel then computes the
metric's severity itself, on every pull and at every heartbeat intake, and draws
the limits next to the value: 41 (threshold 20/100), yellow between
warn and crit, red past crit. The severity your service sends for such a metric
is replaced by the computed one.
| Field | Meaning |
|---|---|
direction | above: worse when higher (queue, CPU). below: worse when lower (free disk, days left). |
warn, crit | Numbers. For above, a value at or over warn is yellow and at or over crit is red; below mirrors it. For above warn must not exceed crit, for below it must not be under crit. |
unit | Optional label of up to 16 bytes for the caption: threshold 80/95%. |
recover | Optional hysteresis: a metric that was warn or crit goes back to ok only once the value is past recover; in between it stays warn. |
{
"id": "queue",
"kind": "number",
"label": "Mail queue",
"value": 41,
"thresholds": { "direction": "above", "warn": 20, "crit": 100, "recover": 15 },
"dashboard": { "show": true, "tab": "backend", "section": "mail", "order": 1, "render": "kpi" },
"telegram": { "show": true, "line": "mail", "pos": 10, "format": "queue {value}", "when": "severity>=warn" }
}- The thresholds compare
value; fordurationthey compareseconds(elseminutes), forbytesthey comparebytes(elsekb). Non-numeric kinds such asstatus,listortablecannot carry thresholds. - No number this time means
idle: grey, and it does not count toward any light. - A broken block is refused in a heartbeat report (
422 invalid_report). In a pulled response it is dropped, the metric is drawn as sent and the reason shows as the source's last error. - In a heartbeat report the light of the heartbeat is never below a breached threshold (see the monitoring contract).
Scheduled polling. For a web service you can switch on scheduled polling of a source in its settings. The panel then pulls the endpoint on an interval from your plan's floor (5 minutes on Trial and Indie, 1 minute on Studio and Fleet) up to 24 hours, with the same request as any pull: an honest User-Agent and at most one request a minute. It takes one monitoring unit, so it is not available on Free. A breached threshold opens an incident for that metric and notifies you; a repeated breach does not notify again. The incident closes after one pull without a breach when the interval is 1 hour or longer, and after two pulls in a row without a breach when it is shorter. A failed pull changes nothing and shows its reason as the last error. Up to 20 metrics with thresholds per source give incidents.
6. The panel owns the palette
Chart and series colors are assigned by the panel from brand
tokens, not by the source. options.series[] carries identity only
({key,label,order}); a color field in the payload is
advisory and ignored. Semantic keys map to fixed tokens
(paid to accent, free to muted neutral,
failed/error to danger,
ready/success to success); other keys take the
categorical brand palette positionally. Series fills and lines keep at least 3:1
contrast in both themes (WCAG 1.4.11), text and labels at least 4.5:1. This
protects the brand from arbitrary external hex.
7. Telegram templates
telegram.header is the message header;
telegram.lines[] are logical lines
({ id, order, prefix, when }). The composer gathers every metric
with a given telegram.line, filters by when, sorts by
pos, renders each via its format template, and joins
them with each metric's sep. The line prefix is printed
first. Everything is plain text.
| Placeholder | Output |
|---|---|
{value} | By kind (number 1,200; percent 99%) |
{value:k} | 1.2k / 3.0M (compact) |
{delta:+} | +72 / -3 |
{value:pct} | 99% |
{seconds:dur} | 28s / 5m 47s |
{minutes:age} | 45m / 2h 30m / never |
{bytes:size} | ~522KB / 1.4MB |
{value}★ ({count}) | 5.0★ (1) |
{sev} | Severity emoji: 🟢 ok, 🟡 warn, 🔴 crit, ⚪ idle |
{threshold} | The metric's threshold: threshold 20/100. Without it in the format, a metric with thresholds gets (threshold 20/100) appended. |
{trend} | ↗ / ↘ (±5pp vs 7d) |
8. Visibility conditions (when)
when controls whether a metric field (or a whole Telegram line)
shows. Values:
| when | Shows the field |
|---|---|
always | Always (default). |
nonzero | Only when the value is not 0 / empty. |
severity>=warn | Only when severity is warn or crit. |
changed | Only when it changed since the previous snapshot. |
Unknown when values behave as always (forward compatible).
9. Example payload
{
"schema_version": "2.0",
"generated_at": "2026-07-02T23:17:30Z",
"tabs": [
{ "id": "conv", "title": "Conversions", "order": 1, "icon": "trending-up" }
],
"sections": [
{ "tab": "conv", "id": "by_hour", "title": "By hour", "order": 0 }
],
"telegram": {
"header": "{name} • {cws.users} • {cws.rating}★ ({cws.rating_count})",
"lines": [
{ "id": "base", "order": 0 },
{ "id": "conv", "order": 1, "prefix": "{sev} Conv " }
]
},
"metrics": [
{
"id": "conv_today",
"kind": "number",
"label": "Conversions today",
"value": 254,
"severity": "ok",
"shareable": true,
"dashboard": {
"show": true,
"tab": "conv", "section": "by_hour", "order": 10,
"name": "Today", "render": "kpi", "options": {}
},
"telegram": {
"show": true,
"line": "conv", "pos": 20,
"format": "today {value}", "sep": " • ", "when": "always"
}
}
]
}10. The machine-readable contract
The formal, importable description of this endpoint is the OpenAPI 3.1 file. It carries the same contract version (2.1.0) and is the single canonical machine artifact, shared across languages.