All writingAnalytics and BI · 6 min read

Putting dashboards in front of customers turns reporting into a product

The charts are the easy part. What changes when customers log in is isolation, load shape and a support queue, and none of those is a dashboard setting.

Apache SupersetPostgressemantic layerPower BIdbtAnalytics and BIApplications and automationSaaSFintechLogistics

The request usually arrives in the same shape. An internal dashboard has been running for a year, account managers have started screenshotting it into customer emails, and someone asks whether customers could just have a login of their own. The charts already exist. The data already refreshes. It looks like a week of work.

It is not a week of work, and the reason has almost nothing to do with the charts. The moment a dashboard is shown to somebody who pays you, three things change at once. It acquires an availability expectation, it acquires a support queue, and it acquires an absolute requirement that no tenant ever sees another tenant's row. The first two are cost you can plan for. The third is the one that ends contracts.

Most teams meet the third one after launch, when a customer forwards a screenshot with somebody else's order volume in it. That is the expensive order in which to learn it.

Internal reporting is allowed to be wrong

Internal analytics runs on an enormous amount of unwritten tolerance. The refresh fails and someone mentions it in the standup. The revenue figure is approximate until finance closes the month, and everybody knows that. There is a chart nobody trusts on Mondays because of how the weekend batch lands, and the team has simply learned to ignore it. None of this is written down, because none of it needs to be. There is always a person standing next to the number who can explain it.

External audiences do not get that person. They get the number, at whatever hour they look, with whatever caveats you managed to fit into a tooltip. Every piece of tolerance you were relying on quietly disappears, and it disappears on the day of launch rather than gradually.

The same chart drawn twice. On the internal side a person stands beside it and three unwritten tolerances hold: a refresh that failed quietly, a figure that is approximate until close, the chart nobody trusts on Mondays. On the customer-facing side the person is missing and all three tolerances are struck through.
Nothing about the chart changed. What went missing is the person who used to stand next to it.

An internal number has a person standing next to it. A customer-facing number is on its own.

The pattern, stated plainly

Where the tenant filter lives is the whole question

In almost every first attempt, the filter lives in the dashboard. There is a WHERE tenant_id = :current_tenant in the chart's query, or a filter component pinned to a value handed in through the embed URL, or a role in the BI tool mapped to a customer. It works, and it keeps working, right up until one of the ordinary things happens: a chart is copied from an older one and the predicate does not come with it, a drill-through generates its own SQL, an export reruns the query without the runtime parameter, a cached result is served from a key that does not include the tenant, or a user notices the embed token and starts editing what is inside it.

The property you actually want is not that cross-tenant queries are unusual. It is that they are impossible to write. That means the predicate has to be applied somewhere below the layer where chart authors work. Postgres row-level security is one honest answer: set the tenant on the connection, write the policy once against the table, and it applies to whatever SQL arrives, including SQL nobody reviewed. A semantic layer is the other: every request compiles through it, and the tenant predicate is injected before anything reaches the warehouse. Both are defensible. A filter that a dashboard author can delete is not.

A request path diagram: browser to application to query layer to warehouse, with a single marked enforcement point below the BI layer where the tenant predicate is injected, and dotted arrows showing the bypass routes (export, drill-through, cache, alert email) all passing through that same point.
Every path that can produce rows has to cross the same enforcement point, including the ones nobody thinks of as queries.

Test it like an attacker, not like an analyst

An analyst tests by opening the dashboard as a test tenant and checking the numbers look right. That catches nothing, because the main dashboard path is the one path you definitely got right. The interesting routes are the side doors.

  • The export CSV and scheduled PDF often rerun the query through a different code path with a different session context.
  • The drill-through Detail views frequently compose their own SQL from the clicked row rather than reusing the chart's query.
  • The cache If the cache key is the query text and the tenant lives in session state, two tenants share a result. This is the most common real leak we see.
  • The alert Scheduled emails and threshold alerts run without a logged-in user, so whatever supplies tenant identity at request time is missing.
  • The share link Any "send this view to a colleague" feature is a question about whose colleague, answered by your permission model rather than your good intentions.

Then give yourself evidence. Log every executed query with the tenant identity that was resolved for it, and run a job that asserts each logged query carried a tenant predicate. It is a few hours of work and it is the only thing that will let you answer, on the day somebody asks, whether a leak happened or merely could have.

Performance stops being an average

Internal load is predictable. The same forty people open the same dashboards at nine in the morning, and if it is slow they say so in a channel. Customer-facing load is thousands of tenants of wildly different size, arriving at all hours, and the tenant with forty times the row count experiences a slow dashboard as your product being broken rather than as a query needing tuning.

Median response time is close to useless here, because the median tenant is small and the complaint comes from the large one. Watch the worst tenant instead. The practical fix is per-tenant precomputation: aggregates cut per tenant, materialised on a schedule that matches what you promised, cached under a key that includes the tenant, invalidated per tenant when their data lands. That last part matters twice, once for speed and once because a cache keyed without the tenant is the isolation problem wearing a different hat.

Median tenant
The tenant who complains40×
Rows held, relative to the median tenant. A median response time is a measurement of the customer who was never going to call you.

Every number becomes a claim

Internally, a disagreement about what counts as an active customer gets resolved in a meeting. Externally, your customer has their own number, computed their own way, and yours is simply wrong until you can explain the difference. This becomes a support queue with a shape you can predict: not "the dashboard is down" but "why does your delivered-orders figure disagree with mine".

Two things reduce it. Publish the definition next to the number, in the customer's words, including the exclusions. And hold definitions in one place that both the SQL and the tooltip read from, whether that is dbt models or a semantic layer, so the published definition cannot drift away from the computation. Once customers depend on a metric you also lose the right to redefine it on a Tuesday afternoon. Metric changes become releases: announced, dated, with the previous series still retrievable for anyone who built a report on it.

The version you should probably build first

Here is the part that argues against our own work. Full embedded analytics is often the wrong answer. If what the customer actually wants is their data, a per-tenant export or a small API is cheaper to build, cheaper to support, and has a far smaller surface to get isolation wrong on. If what they want is the four numbers named in their contract, four numbers rendered in your own application will beat an embedded BI tool on load time, on look, and on the number of ways it can betray you. Reach for the interactive, slice-it-yourself version when customers genuinely want to explore, and be honest with yourself about how many of them do.

  1. 01
    Pick the enforcement pointOne place, below the layer where charts are authored, where the tenant predicate is applied. Decide it before anyone builds a chart.
  2. 02
    Get the tenant onto the dataEvery fact table carries a tenant key. Anything that cannot be attributed to a tenant does not go into the customer-facing model.
  3. 03
    Break it deliberatelyAdversarial tests across exports, drill-throughs, alerts, share links and cache keys, plus a query audit log you can actually query.
  4. 04
    Shape the load per tenantPrecompute and cache per tenant, and measure the slowest tenant rather than the average one.
  5. 05
    Write the definitions downHeld in code, published beside the number, versioned like an interface, because that is what it now is.
  6. 06
    Then draw the chartsThis is the week of work everyone estimated at the start.
The order matters more than the contents. Every step above gets more expensive after launch.

None of this is exotic engineering. It is mostly a decision about where policy lives, made early enough to be cheap. When we built Antvia's governance model we attached policy to the query rather than to the dashboard for exactly this reason: whatever people build on top, the boundary holds, and nobody has to remember it.

Before you open it up to customers

If you are scoping embedded or customer-facing analytics, the useful conversation is a short one about where your tenant boundary sits and what happens on the export path. We are happy to have it, whether or not it ends in work for us.