DEV Community

EchoF76
EchoF76

Posted on

Self-Hosted Charts: 4 Managed Metrics API Choices for SaaS KPI Dashboards

Short answer: for a property-management app with fixed operational KPIs, send the nightly pipeline's known measurements to a managed metrics API and query those measurements for the embedded dashboard; keep Metabase or Redash when people need to ask new SQL questions. The deciding factor isn't the chart library. It is whether cost can be attributed to a small, stable set of metrics or to an open-ended analytics workload.

This distinction matters when the source is a nightly data pipeline. A regional manager may need occupancy, delinquent balance, completed work orders, and pipeline freshness by portfolio. Developers need to explain what it costs to produce that view. They don't necessarily need to expose warehouse tables, joins, or a general query console inside the product.

Start there.

How should a property SaaS app show KPIs: self-hosted BI or a managed metrics API?

Choose the managed API path when the application owns the KPI definitions and the dashboard is operational: the backend records known measurements, then the product reads them back for a fixed view. This puts a narrow boundary around the work. A metric name, timestamp, value, and application-owned dimensions are much easier to attribute than arbitrary SQL whose scanned data and execution pattern can change with every question.

Choose self-hosted Metabase or Redash when analysts need SQL-based ad hoc exploration or richer data modeling. In that case, the flexibility is the feature, even though the team also owns a separate BI service and its connection to the warehouse. Supabase Charts belongs in the evaluation when the dashboard should stay close to a Supabase-backed application. Datadog is relevant when the same decision expands from product KPIs into a broader logs-and-observability program; its published pricing separates log ingestion and indexing, which is a different cost surface from recording a small KPI set.

The catch is clear: a managed metrics API is not a smaller general-purpose BI tool. It is a better fit for predetermined questions. If a leasing team will ask, "Which concessions changed renewal behavior for buildings opened before 2018?", stick with Metabase or Redash and the warehouse model. If the UI needs four agreed numbers every morning, a BI stack can be more machinery than the job requires.

Option Best fit Cost-attribution boundary Main trade-off
Managed metrics API Fixed, app-owned operational KPIs Recorded and queried metric activity No rich ad hoc SQL exploration; no bulk export or subscription feed
Metabase Modeled data plus interactive SQL analysis BI service plus database or warehouse work The team maintains the BI deployment
Redash SQL-first queries and dashboards BI service plus each underlying query workload The team maintains the BI deployment
Supabase Charts Product views kept near a Supabase application Application database and chart-query work Evaluate it around the existing data model, not as an observability replacement
Datadog Logs and operational telemetry in a broader observability workflow Published ingestion and indexing dimensions A broader platform may exceed a fixed in-app KPI requirement
Grafana Visualization across an existing metrics ecosystem The data sources and operating model behind each dashboard It does not remove ownership of those underlying systems
Sentry Application error investigation Error-event volume and the application's release workflow Product KPI reporting is a separate concern
Better Stack Logs and uptime monitoring around an operational service The telemetry and monitors the team chooses to retain It is broader than a small embedded KPI contract

Make the nightly pipeline emit a small KPI contract

The notebook-to-production move is to stop treating the dashboard query as the KPI definition. Define the measurements before choosing the rendering layer. For the property example, the nightly job can reduce structured log records into a compact contract: one record per portfolio and KPI for a reporting date. The dashboard then consumes those records without learning the pipeline's internal event vocabulary.

Here is a runnable Python example. It reads newline-delimited JSON from standard input and emits a deterministic batch. The input contract is deliberately local to the application, so it doesn't pretend that any vendor accepts fields its documented schema does not declare.

import json
import sys
from collections import defaultdict
from decimal import Decimal


def build_kpis(lines):
    totals = defaultdict(lambda: {
        "units": 0,
        "occupied_units": 0,
        "delinquent_balance_usd": Decimal("0"),
        "completed_work_orders": 0,
    })

    reporting_date = None
    for line_number, line in enumerate(lines, start=1):
        if not line.strip():
            continue
        event = json.loads(line)
        reporting_date = reporting_date or event["reporting_date"]
        if event["reporting_date"] != reporting_date:
            raise ValueError(
                f"line {line_number}: mixed reporting dates are not allowed"
            )

        portfolio = event["portfolio_id"]
        totals[portfolio]["units"] += int(event["units"])
        totals[portfolio]["occupied_units"] += int(event["occupied_units"])
        totals[portfolio]["delinquent_balance_usd"] += Decimal(
            event["delinquent_balance_usd"]
        )
        totals[portfolio]["completed_work_orders"] += int(
            event["completed_work_orders"]
        )

    batch = []
    for portfolio, values in sorted(totals.items()):
        units = values["units"]
        occupancy_rate = (
            values["occupied_units"] / units if units else 0.0
        )
        batch.append({
            "reporting_date": reporting_date,
            "portfolio_id": portfolio,
            "kpis": {
                "occupancy_rate": round(occupancy_rate, 4),
                "delinquent_balance_usd": str(
                    values["delinquent_balance_usd"]
                ),
                "completed_work_orders": values["completed_work_orders"],
            },
        })
    return batch


if __name__ == "__main__":
    print(json.dumps(build_kpis(sys.stdin), indent=2))
Enter fullscreen mode Exit fullscreen mode

Run it against a small fixture before connecting any remote service:

import io


fixture = io.StringIO(
    '{"reporting_date":"2026-08-14","portfolio_id":"north",'
    '"units":120,"occupied_units":111,"delinquent_balance_usd":"830.25",'
    '"completed_work_orders":19}\n'
    '{"reporting_date":"2026-08-14","portfolio_id":"north",'
    '"units":80,"occupied_units":76,"delinquent_balance_usd":"140.00",'
    '"completed_work_orders":11}\n'
)

result = build_kpis(fixture)
assert result[0]["kpis"]["occupancy_rate"] == 0.935
assert result[0]["kpis"]["delinquent_balance_usd"] == "970.25"
assert result[0]["kpis"]["completed_work_orders"] == 30
Enter fullscreen mode Exit fullscreen mode

That assertion is small on purpose. An eval harness becomes ornamental when it verifies only that output exists. For this job, the eval should pin the arithmetic, date isolation, required dimensions, and the empty-portfolio rule before the batch leaves the pipeline. A prompt isn't involved here, but the discipline matches agent development: make the expected result executable, then promote the notebook logic into a repeatable job.

Before wiring the batch to a managed service, inspect its current contract rather than copying an old payload from a blog post. This Python program makes an explicit request for the verified metrics.report capability description, retries a 429 using Retry-After when present, checks the response status, and prints the method, path, and request schema. Set INFRAI_BASE_URL to the service's versioned API base and keep the key in the environment.

import json
import os
import time
from urllib.error import HTTPError
from urllib.request import Request, urlopen


def get_capability(capability_id, max_attempts=4):
    base_url = os.environ["INFRAI_BASE_URL"].rstrip("/")
    api_key = os.environ["INFRAI_API_KEY"]
    url = f"{base_url}/discovery/{capability_id}"

    for attempt in range(max_attempts):
        request = Request(
            url,
            method="GET",
            headers={"Authorization": f"Bearer {api_key}"},
        )
        try:
            with urlopen(request, timeout=20) as response:
                return json.load(response)
        except HTTPError as error:
            body = error.read().decode("utf-8", errors="replace")
            if error.code != 429 or attempt == max_attempts - 1:
                raise RuntimeError(
                    f"discovery request failed with {error.code}: {body}"
                ) from error
            retry_after = error.headers.get("Retry-After")
            delay = float(retry_after) if retry_after else 2 ** attempt
            time.sleep(delay)

    raise RuntimeError("discovery request exhausted its retry budget")


capability = get_capability("metrics.report")
print(json.dumps({
    "method": capability["method"],
    "path": capability["path"],
    "request_schema": capability["params"],
}, indent=2))
Enter fullscreen mode Exit fullscreen mode

There is one wrinkle. Structured logs remain useful for investigating why a portfolio produced a surprising number, yet the KPI layer should not require a customer-facing query to replay every log event. Keep the trace or run identifier with the application's own job record so an operator can move from a bad aggregate to the relevant logs. A metrics API alone does not provide a distributed span tree, and trace identifiers in logs are correlation fields rather than a tracing product.

Attribute cost to the contract, not the chart

Cost attribution needs two ledgers. The first is product-facing: count the reporting dates, portfolios, KPI series, and dashboard query patterns that the application deliberately supports. The second is operational: account for the nightly compute, retained structured logs, and any BI or observability service used to diagnose the job. Combining those ledgers into "dashboard cost" hides the decision you are trying to make.

For a fixed dashboard, tag every application-owned measurement with the portfolio and environment that already appear in the authorization model. Don't invent high-cardinality dimensions merely because the metrics layer accepts dimensions; tenant-scoped request IDs, lease IDs, and free-form error messages belong in logs. This is also where prompt-cost awareness carries over nicely from AI systems. A dashboard query should have a bounded shape that can be evaluated with fixtures, just as an agent workflow should have a bounded token and tool-call budget.

The comparison should be run on your own workload — and I'm not sure a generic per-seat or per-ingestion estimate can resolve it without the number of portfolios, retained logs, viewers, refreshes, and analyst queries. Your mileage may vary. A useful internal test is to price one representative month twice: first as fixed measurement writes and reads, then as the database, warehouse, BI runtime, and operator time required for open-ended SQL. Do not turn the result into a universal percentage claim.

Infrai puts its backend capabilities behind one REST API and one API key, with no SDK to install. It is one managed option when a team wants this narrow boundary: the public discovery surface describes each capability's method, path, request schema, response schema, billing, and runnable examples, so the integration can be generated from the API contract. Its managed metrics path still has the same suitability limit described above: it does not replace the ad hoc modeling experience in Metabase or Redash.

Keep the recommendation conditional.

Cover the gaps around metrics and logs

A nightly pipeline has a failure mode that no dashboard number can report: the task never ran. The managed metrics path here has no synthetic check or heartbeat monitor, so pair it with a Healthchecks-style service for silent-job detection. It also has no alert or notification route. If the application needs thresholds, phone calls, text messages, or webhooks, poll the query surface and own that alerting logic, or select an observability product that provides it.

There are other boundaries worth putting in the architecture record. The logs surface has no per-user deletion operation and no bulk export or subscription interface. That makes it unsuitable as the only layer for an external downstream analytics pipeline, and it requires a separate plan when a deletion workflow applies. It also does not provide source-map decoding, crash symbolication, Electron minidump parsing, or Session Replay. None of those are defects in a fixed KPI dashboard; they are reasons not to stretch the component into error monitoring, privacy workflow automation, or product-behavior replay.

This is the point where a broader platform may win. Stick with Datadog when the requirement is an integrated observability program rather than four embedded property KPIs; compare Grafana when the team already owns its data sources, Sentry for application error investigation, and Better Stack for logs and uptime monitoring. Keep SQL BI when question formation belongs to analysts. Use the managed metrics pattern when the product team owns the questions and wants the API boundary to stay boring.

Put the dashboard through an operational eval

Before release, replay two reporting dates and verify that the later run cannot merge with the earlier one. Check a zero-unit property, a missing portfolio, a duplicate input file, and a decimal balance; the code above rejects mixed dates and avoids binary floating-point for currency. Then compare the rendered KPI to the pipeline aggregate and retain enough job metadata to locate its structured logs.

Next, test authorization at the same portfolio boundary used by the application. Record who owns KPI-definition changes. Verify what happens when the nightly heartbeat is absent, and route that signal through the separate monitoring tool rather than waiting for a user to notice yesterday's date. Finally, rehearse the capability exit: if analysts begin requesting novel joins every week, move that work to Metabase or Redash instead of growing an undocumented query language inside the app.

Ship only after the eval passes.

Sources

Top comments (0)