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 (Trial/Paid only), 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).
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 subset of 37 Lucide names (kept lean for the MV3 bundle):
- 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
- puzzle
- funnel
- filter
- square
- circle
- hash
- percent
- tag
- flag
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}] | 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?}] | subs by plan, by engine, by surface |
list | items:[{text,count?,severity?}] | recent errors |
text | value (string) | free text |
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 | tone |
badge | Status pill / pictogram by severity | – |
chart | Line chart (Month/Lifetime, rolling-7 avg, hover) | series, defaultMode |
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 | items |
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.
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. Contrast stays at least 4.5:1 in both
themes. 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 |
{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.0.0) and is the single canonical machine artifact, shared across languages.