# Metrics · Martis docs

> Metrics compute and display analytical data on dashboards and resource index pages. Martis provides six built-in metric types: Value, Trend, Partition, Progress, ActivityFeed, and EndpointTable.

Source: https://getmartis.com/docs/core/metrics

# Metrics

Metrics compute and display analytical data on dashboards and resource index pages. Martis provides six built-in metric types: `Value`, `Trend`, `Partition`, `Progress`, `ActivityFeed`, and `EndpointTable`.

## Metric Types

### Value Metric

Displays a single number with optional period comparison.

```
use App\Models\User;
use Illuminate\Http\Request;
use Martis\Metrics\ValueMetric;
use Martis\Metrics\ValueResult;
 
class TotalUsers extends ValueMetric
{
    public function calculate(Request $request): ValueResult
    {
        return $this->count($request, User::class)
            ->suffix(' users');
    }
}
```

**Query helpers:** `count()`, `sum()`, `average()`, `max()`, `min()`.

**Result formatting:**

```
$this->result(150)->prefix('$')->suffix(' revenue')
$this->count($request, User::class)->currency('€')
$this->sum($request, Order::class, 'total')->dollars()
```

### Trend Metric

Displays time-series data as a line chart.

```
use App\Models\User;
use Illuminate\Http\Request;
use Martis\Metrics\TrendMetric;
use Martis\Metrics\TrendResult;
 
class UsersPerDay extends TrendMetric
{
    public function calculate(Request $request): TrendResult
    {
        return $this->countByDays($request, User::class)
            ->showLatestValue();
    }
}
```

**Query helpers:** `countByDays()`, `countByWeeks()`, `countByMonths()`, `sumByDays()`, `sumByWeeks()`, `sumByMonths()`, `averageByDays()`, `averageByMonths()`.

### Partition Metric

Displays grouped data as a doughnut chart.

```
use App\Models\User;
use Illuminate\Http\Request;
use Martis\Metrics\PartitionMetric;
use Martis\Metrics\PartitionResult;
 
class UsersByRole extends PartitionMetric
{
    public function calculate(Request $request): PartitionResult
    {
        return $this->count($request, User::class, 'role')
            ->colors(['#22c55e', '#ef4444', '#3b82f6'])
            ->label(fn ($l) => strtoupper($l));
    }
}
```

**Query helpers:** `count()`, `sum()`, `average()`.

`colors()` takes a list (parallel to the groups) or a map keyed by group value. A group with no entry of its own in the map takes the next color of the theme palette, whatever its value is spelled like (a group named `constructor` is a group like any other, v2.4.0).

### Progress Metric

Displays progress toward a target as a progress bar.

```
use App\Models\User;
use Illuminate\Http\Request;
use Martis\Metrics\ProgressMetric;
use Martis\Metrics\ProgressResult;
 
class MonthlySignups extends ProgressMetric
{
    public function calculate(Request $request): ProgressResult
    {
        return $this->count(
            $request,
            User::class,
            fn ($q) => $q->where('created_at', '>=', now()->startOfMonth()),
            target: 100,
        );
    }
}
```

Use `->avoid()` when the goal is to minimize rather than maximize.

### Activity Feed Metric (Martis Extension)

Renders a chronological stream of recent events with a coloured Phosphor avatar tile, an actor / verb / target line, and a mono timestamp. Useful for "Recent activity" or "Latest deployments" cards on dashboards.

```
use Illuminate\Http\Request;
use Martis\Metrics\ActivityFeedMetric;
use Martis\Metrics\ActivityFeedResult;
 
class RecentDeploys extends ActivityFeedMetric
{
    public function calculate(Request $request): ActivityFeedResult
    {
        return $this->result()
            ->add(
                actor: 'Ana Pereira',
                verb: 'deployed',
                time: '2m ago',
                target: 'api-core@v2.4.1',
                icon: 'rocket-launch',
                color: 'var(--martis-chart-2)',
            )
            ->add(
                actor: 'João Marques',
                verb: 'rotated key',
                time: '14m ago',
                target: 'production-api',
                icon: 'key',
                color: 'var(--martis-chart-1)',
            );
    }
}
```

`ActivityFeedResult::add()` parameters:

| Parameter | Required | Description |
| --- | --- | --- |
| `actor` | yes | Bold name at the start of the line. |
| `verb` | yes | Muted action description after the actor. |
| `time` | yes | Relative timestamp shown on a second mono line ("2m ago", "yesterday"). |
| `target` | no | Identifier rendered in mono (commit sha, route, key id). |
| `icon` | no | Phosphor icon name for the leading avatar tile. |
| `color` | no | CSS colour or token for the tile background (defaults to `--martis-accent`). |

Bulk-set entries via `$result->items([...])` if the data already comes back from a query in the right shape.

Generator: `php artisan martis:activity-feed RecentDeploys`.

### Endpoint Table Metric (Martis Extension)

Renders a compact HTTP route table with coloured method chips (`GET` / `POST` / `PUT` / `PATCH` / `DELETE`), mono numeric columns (req/min, P95 latency, error %), and a thin share-of-traffic bar. Drops cleanly into a `card-span-3` slot on the dashboard.

```
use Illuminate\Http\Request;
use Martis\Metrics\EndpointTableMetric;
use Martis\Metrics\EndpointTableResult;
 
class TopEndpoints extends EndpointTableMetric
{
    public function calculate(Request $request): EndpointTableResult
    {
        return $this->result()
            ->errorWarnThreshold(0.2)
            ->add(method: 'GET', path: '/v1/resources', rpm: 482, latencyMs: 42, errorRate: 0.02, share: 28)
            ->add(method: 'POST', path: '/v1/events', rpm: 312, latencyMs: 88, errorRate: 0.12, share: 18)
            ->add(method: 'PATCH', path: '/v1/deployments/:id', rpm: 188, latencyMs: 126, errorRate: 0.18, share: 11);
    }
}
```

`EndpointTableResult::add()` parameters:

| Parameter | Required | Description |
| --- | --- | --- |
| `method` | yes | HTTP verb. Drives the coloured chip in the first column. |
| `path` | yes | Route path (rendered in mono). |
| `rpm` | no | Requests per minute. |
| `latencyMs` | no | P95 latency in milliseconds. |
| `errorRate` | no | 0..1. Highlights the cell amber when above `errorWarnThreshold` (default `0.2`). |
| `share` | no | 0..100. Renders the thin share bar. Omit on every row to hide the column entirely. |

Bulk-set via `$result->rows([...])`. Generator: `php artisan martis:endpoint-table TopEndpoints`.

### Sparkline mode on Trend metrics

`TrendResult` accepts a `sparkline` flag. When set, the `MetricCard` renders an inline SVG sparkline + delta pill instead of the full Chart.js panel. Pair with the `.martis-dash-kpis` row layout to fit four trend metrics across the top of a dashboard:

```
class WeeklyRevenue extends TrendMetric
{
    public function calculate(Request $request): TrendResult
    {
        return $this->countByDays($request, Order::class)
            ->sparkline()                // ← compact mode
            ->showLatestValue()
            ->prefix('€');
    }
}
```

The `<Sparkline>` React component used internally is also on `@martis/runtime` (v1.38.0+) for use inside custom cards or framed components: `import { Sparkline } from '@martis/runtime'`.

## Number formatting

Value, trend and progress metrics format their numbers in the user's Martis locale (v2.1.0), with the metric's prefix and suffix around them: `1234.5` reads `1234,5` for `pt_PT` and `1,234.5` for `en_US`. The trend chart's axis and tooltip, and the partition chart's tooltip, use the same locale (Chart.js `locale` option).

## Custom date column on query helpers

Every query helper (`count`, `sum`, `average`, `max`, `min` on `ValueMetric`; `countByDays` / `countByWeeks` / `countByMonths` / `sumByDays` / `sumByWeeks` / `sumByMonths` / `averageByDays` / `averageByMonths` on `TrendMetric`) accepts an optional `?string $dateColumn = null` final argument that controls which timestamp column the active range filter applies against. Defaults to `created_at`.

> **Database support.** `TrendMetric` buckets its periods (day / ISO week / month) in PHP after fetching the in-range rows, so it is database-agnostic — MySQL, PostgreSQL and SQLite all work identically, including ISO-week grouping. No driver-specific SQL date functions are used.

```
public function calculate(Request $request): TrendResult
{
    return $this->sumByMonths($request, Invoice::class, 'total', 'paid_at');
    //                                                          ^^^^^^^
    // Range filter (?range=12) bounds Invoice.paid_at, not Invoice.created_at.
}
```

Use it whenever the metric should track a domain timestamp (`paid_at`, `closed_at`, `delivered_at`, `archived_at`) that differs from row creation.

## Ranges

Metrics support time range selection. Override `ranges()` to customize:

```
public function ranges(): array
{
    return [
        30 => '30 Days',
        60 => '60 Days',
        365 => '1 Year',
        'TODAY' => 'Today',
        'MTD' => 'Month To Date',
        'QTD' => 'Quarter To Date',
        'YTD' => 'Year To Date',
    ];
}
```

**The declared ranges are the only ranges** (v2.4.0+). The `?range=` query parameter is client input, and a trend opens one bucket per step of its window and reads every row in it, so a request cannot choose the window. The metric accepts a value only when it is one of the keys `ranges()` returns; any other value (`?range=10000000`, `?range=forever`, a named range the metric does not declare) reads as the default range, which is `30` when declared and the first declared range otherwise. A metric that declares none (a partition or a progress metric, or a value metric whose `ranges()` returns `[]` to hide the selector) always reads `30`. The same holds for the cache: the result is keyed on the declared range, so requests for made-up values neither compute a window of their own nor add an entry each.

A numeric range is also held to `Metric::MAX_RANGE` (3650 days, weeks or months, as the metric counts them), so a `ranges()` that declares a larger window opens 3650 steps at most.

A metric that reads the range itself in `calculate()` takes it from `$this->requestedRange($request)` (a string: a declared key) and `Metric::rangeWindow($range)` (the numeric window), not from `$request->query('range')`, which the whitelist does not cover. Before v2.4.0 `?range=10000000` on a trend card loaded the whole table, built ten million buckets and cached the result under a key of its own.

## Card Width

Cards use a **12-column grid**. Default width is 4 (one-third).

```
TotalUsers::make('Total Users')->width(4)     // 1/3
UsersPerDay::make('Trend')->width(8)          // 2/3
UsersByRole::make('Roles')->width(6)          // 1/2
Overview::make('Overview')->width('full')      // full width
```

Fraction strings are auto-converted: `'1/3'` → 4, `'1/2'` → 6, `'2/3'` → 8, `'full'` → 12.

For typed callers (and stricter analyzers), `width()` also accepts the `Martis\Enums\MetricWidthPreset` enum:

```
use Martis\Enums\MetricWidthPreset;
 
TotalUsers::make('Total Users')->width(MetricWidthPreset::OneThird);  // = 4
UsersPerDay::make('Trend')->width(MetricWidthPreset::TwoThirds);      // = 8
Overview::make('Overview')->width(MetricWidthPreset::Full);           // = 12
```

Cases: `OneThird`, `Half`, `TwoThirds`, `Full`. The integer / fraction / enum forms are interchangeable.

### Responsive widths (widthMd() / widthLg())

> **Martis extension.** The dashboard grid is responsive at the same breakpoints as the form grid (`md` = 768px, `lg` = 1024px), and the three width methods form a mobile-first cascade, like Tailwind's `col-span-* md:col-span-* lg:col-span-*`:

| Method | Applies from | Fallback |
| --- | --- | --- |
| `width()` | `md` (768px) | 4 |
| `widthMd()` | `md` (768px) | inherits `width()` |
| `widthLg()` | `lg` (1024px) | inherits `widthMd()`, then `width()` |

Below `md` **every card spans the full row, whatever the declared widths**, the same rule the form grid applies to field spans: a single column is always better than a third-width KPI on a 390px screen. So a card that declares only `width(6)` is half-width on tablets and desktops and full-width on phones, with no change to existing dashboards above `md`.

```
UsersPerDay::make('Trend')
    ->width(12)       // tablet (>= 768px): full row
    ->widthLg(8);     // desktop (>= 1024px): two-thirds
 
UsersByRole::make('Roles')
    ->width(12)
    ->widthLg(4);     // shares the desktop row with the trend above
```

`widthMd()` is the explicit form of the `md` tier; use it when the card should change again at `lg` (`width(6)->widthMd(12)->widthLg(4)` reads as tablet 12, desktop 4). A plain custom `Card` (see [Dashboards → Custom cards](https://getmartis.com/docs/core/dashboards#custom-cards-filter-reactive)) accepts the same three methods.

**How it is applied (v1.37.2+).** The SPA never writes `grid-column` inline. Each card carries its resolved tiers as custom properties on the grid item (`--martis-card-span`, `--martis-card-span-md`, `--martis-card-span-lg`) and `.martis-dashboard-grid` in `martis.css` owns the placement per media query. A theme that wants `width()` honoured on phones too can override the mobile rule:

```
@media (max-width: 767px) {
  .martis-dashboard-grid > * {
    grid-column: span var(--martis-card-span);
  }
}
```

Before v1.37.2 the two responsive values were serialised but never read, and the grid stayed 12 columns at every viewport.

## Caching

Metric results are cached by default through the Martis `metrics` cache layer (5 minutes, see [Cache](https://getmartis.com/docs/reference/cache)). Override `cacheFor()` to give one metric its own lifetime instead:

```
public function cacheFor(): ?\DateTimeInterface
{
    return now()->addMinutes(5);
}
```

Since v2.20.0 a metric can set its lifetime in seconds without leaving the layer, and a single metric can opt out of caching:

```
class TotalRevenue extends ValueMetric
{
    protected ?int $cacheSeconds = 60;      // or ->cacheForSeconds(60) where it is built
}
 
class CashBalance extends ValueMetric
{
    protected bool $live = true;            // or ->live(): never cached, computed on every request
}
```

`martis.cache.metrics.default_ttl_seconds` (env `MARTIS_CACHE_METRICS_DEFAULT_TTL_SECONDS`) sets the seconds for every metric that sets none; `null` (the default) keeps the layer's `ttl` in minutes. Precedence: `live()`, then `$cacheSeconds`, then the default. These entries live in the `metrics` layer, so the kill-switch, `?nocache`, `martis:cache:clear metrics` and the app cache scope apply to them, and they stay per user unless the metric sets `$cachePerUser = false` (the way to share one entry across users; the key is per user today and the default does not change). `cacheFor()` reports the moment `$cacheSeconds` expires. A value of `default_ttl_seconds` that is set but is not a whole number of seconds, 1 or more, throws and names the key. See [Cache](https://getmartis.com/docs/reference/cache#metrics-a-ttl-in-seconds-live) for a guide to which cards to leave live.

Custom `Card` classes have no compute path of their own (they travel with the dashboard shape, cached per user under the `dashboards` layer), so these options exist on metrics only; see [Dashboards](https://getmartis.com/docs/core/dashboards).

A metric that overrides `cacheFor()` is cached with Laravel's `Cache::remember()` directly, outside the Martis layer, so the `metrics` kill-switch, the `?nocache` bypass and `martis:cache:clear` do not apply to it. Both paths key the result on the user the Martis guard signed in (by model and identifier; guests share one entry), the locale, the range and the filters, plus the scope an app registered with `Martis::cacheScopeUsing()` (v2.10.0), so a `calculate()` scoped to the user, their tenant or their permissions is never served to another user (v2.0.0+). A user whose identifier is not an int, a string or `Stringable` gets no cached entry, and `protected bool $cachePerUser = false;` shares one entry for a metric whose value is the same for everyone.

## Authorization

Use `canSee()` to control metric visibility:

```
TotalUsers::make('Total Users')
    ->canSee(fn ($request) => $request->user()->isAdmin())
```

## Restricting a card to the resource detail page

`onlyOnDetail()` removes the metric from dashboards and shows it only on the resource's detail surface. Useful for per-record health cards (e.g. "Last 30 days for THIS customer") that have no meaning on a global dashboard.

```
ChurnRiskScore::make('Churn risk')
    ->onlyOnDetail();
```

## Inheriting the resource's active filters

Resource index filters do not flow into metric queries by default. Call `withFilterScope()` to wire the parent resource's filter state into the metric's query builder:

```
PaidRevenue::make('Paid revenue')
    ->withFilterScope(function ($query, $request) {
        if ($plan = $request->input('filters.plan')) {
            $query->where('plan', $plan);
        }
        return $query;
    });
```

The closure receives `($query, $request)` and must return the (possibly modified) `$query`. Pair this with the resource's `filters()` so the metric reacts to the same filter pills the user sees on the index.

## Custom card colour

`color(string)` accepts any CSS colour and overrides the accent the card uses for the icon, value, and accent border:

```
ActiveAlerts::make('Active alerts')->color('#dc2626');
ServerLoad::make('Load avg')->color('var(--martis-chart-3)');
```

Use it when a card needs to stand out from the global accent without committing to one of the `CardStyle` semantic variants.

## Card Icons (Martis Extension)

Add a Phosphor icon to the card header:

```
TotalUsers::make('Total Users')->icon('users')
Revenue::make('Revenue')->icon('currency-dollar')
TasksTrend::make('Tasks')->icon('chart-line-up')
```

The icon renders next to the card title and inherits the card style color.

## Card Styles (Martis Extension)

Apply a colored accent (left border) to highlight card importance:

```
use Martis\Enums\CardStyle;
 
TotalUsers::make('Total Users')->style(CardStyle::Info)        // blue
Revenue::make('Revenue')->style(CardStyle::Success)            // green
OverdueInvoices::make('Overdue')->style(CardStyle::Danger)     // red
PendingTasks::make('Pending')->style(CardStyle::Warning)       // yellow
```

Available styles: `Default`, `Success`, `Warning`, `Danger`, `Info`.

## Card Height (Martis Extension)

Set a minimum height to align cards in a row:

```
UsersByRole::make('By Role')->height(300)  // 300px minimum height
```

## Cache Configuration (Martis Extension)

### Per-metric caching

Override `cacheFor()` on any metric class:

```
public function cacheFor(): ?\DateTimeInterface
{
    return now()->addMinutes(5);
}
```

### Global cache defaults

Defaults live in `config/martis.php` under the `cache` block. Each subsystem (`metrics`, `navigation`, `dashboards`, `schema`) has its own `{enabled, ttl}` pair so an operator can flip a single layer off without touching the others. Individual metrics still win via `cacheFor()`, which bypasses the layer (and its kill-switch) entirely. Every metric cache entry is per user, unless the metric sets `$cachePerUser = false`, and carries the scope of `Martis::cacheScopeUsing()` when the app registered one (v2.10.0).

```
'cache' => [
    'enabled' => env('MARTIS_CACHE_ENABLED', true),     // master kill-switch
 
    'metrics' => [
        'enabled' => env('MARTIS_CACHE_METRICS_ENABLED', true),
        'ttl'     => env('MARTIS_CACHE_METRICS_TTL', env('MARTIS_CACHE_METRICS', 5)),
    ],
    'navigation' => [
        'enabled' => env('MARTIS_CACHE_NAVIGATION_ENABLED', true),
        'ttl'     => env('MARTIS_CACHE_NAVIGATION_TTL', env('MARTIS_CACHE_NAVIGATION', 1)),
    ],
    'dashboards' => [
        'enabled' => env('MARTIS_CACHE_DASHBOARDS_ENABLED', true),
        'ttl'     => env('MARTIS_CACHE_DASHBOARDS_TTL', env('MARTIS_CACHE_DASHBOARDS', null)),
    ],
    'schema' => [
        'enabled' => env('MARTIS_CACHE_SCHEMA_ENABLED', true),
        'ttl'     => env('MARTIS_CACHE_SCHEMA_TTL', env('MARTIS_CACHE_SCHEMA', 1440)),
    ],
 
    'admin_ui' => env('MARTIS_CACHE_ADMIN_UI', true),    // exposes /api/cache/* + the admin page
],
```

`ttl` is in minutes; `null` disables caching for that subsystem. The single-name env vars (`MARTIS_CACHE_METRICS`, `MARTIS_CACHE_NAVIGATION`, …) are still read as a fallback so older `.env` files keep working. Runtime overrides set via `php artisan martis:cache:disable {type}` survive restarts and take precedence over both layers.

## Auto-Refresh (Martis Extension)

Martis supports automatic polling.

```
ActiveUsers::make('Active Now')->refreshEvery(30)  // refresh every 30 seconds
```

Cards with polling show a "LIVE" indicator.

## Tooltip — help(?string)

Attach an explanatory tooltip next to the metric title. The bundled `MetricCard` renders a `?` icon next to the title; hovering reveals the text. Pass `null` (or never call it) to hide the icon entirely.

```
ActiveUsers::make('Active Now')
    ->help('Distinct users with at least one HTTP request in the last 5 minutes. Excludes bot traffic.');
```

HTML is allowed in the tooltip body — line breaks (`<br>`), bold (`<strong>`), inline lists. The same channel field tooltips use (sanitised before it is shown; see [Tooltip Standard](https://getmartis.com/docs/customization/components#tooltip-standard-primereact)). Useful for multi-paragraph explanations on dense metrics:

```
RegimeScore::make('Composite')
    ->help('A 0–10 reading of the current regime.<br><br><strong>5</strong> = neutral.<br><strong>>7</strong> = strong regime.<br><strong>&lt;3</strong> = regime is breaking.<br><br>Not a buy/sell signal.');
```

Use it for anything the value cannot explain on its own:

- the unit of measurement (`bytes per second`, `cents`)
- which rows are included or excluded (`soft-deleted ones are counted`)
- caveats on the time range (`uses the merchant's local timezone, not UTC`)
- regulatory caveats (`not a buy/sell signal`)

> **History**: shipped server-side from v0.x but the React `MetricCard` never consumed the property until v1.8.18 — `help()` was a no-op visually before that release. Custom metric components that read `meta.help` directly were unaffected.

The text is serialized as `help` (top-level, not under `meta`) in the metric payload, so custom metric components can also read it via `metric.help` and render it however they prefer.

## Artisan Commands

```
php artisan martis:value TotalUsers
php artisan martis:trend UsersPerDay
php artisan martis:partition UsersByRole
php artisan martis:progress MonthlyGoal
```

## API

### Compute a dashboard metric

```
GET /api/dashboards/{dashboard}/cards/{card}?range=30
```

### Compute a resource metric

```
GET /api/resources/{resource}/cards/{card}?range=30
```

Both answer `403` with the lock payload when the dashboard, the resource or the card is soft-locked for the user (`lockedFor()`, `requirePlan()`, v2.4.0; see [Soft-gates → What a lock stops on the server](https://getmartis.com/docs/core/gates#what-a-lock-stops-on-the-server)). A metric class has no lock of its own: lock the dashboard, the resource or the `Card` that holds it.

## MetricResult — base class

Every metric type returns a result object that subclasses `Martis\Metrics\MetricResult`. The base class has a single contract method:

```
abstract public function toArray(): array;
```

Concrete subclasses (`ValueResult`, `TrendResult`, `PartitionResult`, `ProgressResult`, `ActivityFeedResult`, `EndpointTableResult`) implement this to serialise their specific shape into the API payload the React component consumes. Most consumers never touch the base class directly — you build results via the helpers on the metric class (`$this->result($value)`, `$this->trend($values)`, etc.) and let the metric handle serialisation.

Subclass `MetricResult` directly only if you're shipping a brand-new metric kind. Document the result shape in your own metric's docblock so consumers can read the payload without diving into source.

Lenses

Dashboards
