Ext Ops Panel
Open dashboard

Home/Docs/Custom metrics

Custom metrics contract

Contract version 2.1.0 · payload schema_version 2.0

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.

↓ Download OpenAPI 3.1 (YAML) /custom-metrics-openapi.yaml · info.version 2.1.0

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:

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:

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.

FieldTypeNotes
schema_versionstringRequired. Must be "2.0".
generated_atstring (date-time)When the payload was composed (freshness / idle detection).
tabsTab[]Dashboard tabs. See section 4.
sectionsSection[]Groups of metrics inside a tab (tab, id, title, order).
telegramTelegramMetaheader template + lines[]. See section 7.
metricsMetric[]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:

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):

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.

FieldNotes
idStable metric id, unique in the payload.
kindData type (section 5.1). Selects which value fields apply.
labelCanonical human label.
value and kind fieldsPayload fields depend on kind (section 5.1).
severityOne of ok, info, warn, crit (maps to color / emoji).
shareableOptional 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).

kindPayload fieldsExample use
numbervalue (+ optional delta, unit)install / feedback counts
deltavalue, delta (renders 1200 (+72))total (+today)
percentvalue (0 to 100)engine success rate
durationseconds or minutesp50/p95/avg, freshness
bytesbytes or kboutput size (~522KB)
ratingvalue, count5.0★ (1)
moneyvalue, currency, optional deltaMRR, spend
statusstate + severityhealth traffic light
seriespoints:[{date,count}], optional multi series:[{name,points}], stacked with stack: truedaily installs, active users
intradayseries:[{name,intraday:[{minute,value}]}], optional step_min (default 15), tztoday by 15-minute buckets
hour_histogram{ weekdays:[...], start, end, tz }conversions by hour
funnelsteps:[{key,label,value}]pricing to checkout
breakdownitems:[{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
listitems:[{text,count?,severity?}] at the top level of the metric, not under valuerecent errors
tableitems:[{…}], one object per row; columns from dashboard.options.columns, else the keys of the first rowqueues, recent sends
textvalue (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.

renderLookOptions (dashboard.options)
kpiCard: name + large value + delta; with stack, the parts in one row under the valuetone, stack
badgeStatus pill / pictogram by severity–
chartLine chart (Month/Lifetime, rolling-7 avg, hover); with stack: true, stacked columns per day with a Total rowseries, defaultMode, stack
intradayToday by buckets (96 per UTC day): stacked bars or smoothed lines, time axis HH:MM, hover readout across seriesmode (bars | lines, default lines)
hour_histogramHourly bars, grouped Weekdays / Weekend / By day of weekseries
funnelSteps with conversion between them–
breakdownHorizontal 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 partsitems, defaultRange, map, stack
tableTable (e.g. feedback)columns
textParagraph / inline–
statusTraffic-light state by severity–
listList 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.

MetricHow the panel finds itWhere it is shown
DAU, WAU, MAUThe 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 3The 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.

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.

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.

FieldMeaning
directionabove: worse when higher (queue, CPU). below: worse when lower (free disk, days left).
warn, critNumbers. 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.
unitOptional label of up to 16 bytes for the caption: threshold 80/95%.
recoverOptional 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" }
}

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.

PlaceholderOutput
{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:

whenShows the field
alwaysAlways (default).
nonzeroOnly when the value is not 0 / empty.
severity>=warnOnly when severity is warn or crit.
changedOnly 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"
      }
    }
  ]
}
Only native v2 is supported. Your service returns metrics plus a ready layout per this contract; there are no legacy-format connectors. Kinds and render types come from the declared sets above; unknown ones are ignored so a newer source never breaks an older panel build.

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.

↓ Download OpenAPI 3.1 (YAML) /custom-metrics-openapi.yaml