DocsGuides

Guides

Production embedded analytics

5 min readReviewed July 2026

A working iframe is the start of an embedded analytics feature, not the finish. Production quality requires a clear authorization boundary, predictable loading behavior, tenant-safe caching, metric reconciliation, and a fallback when analytics is unavailable.

This guide assumes you can already embed a graph and have modeled users and accounts.

Choose the rendering boundary#

GraphJSON supports two useful product architectures:

Approach Best when Your application owns
Dynamic iframe Fast delivery and GraphJSON rendering are desirable Authorization, configuration, placement, loading shell
Data API + native chart The UI needs custom interaction or a strict design system Authorization, charting, formatting, accessibility, empty/error states

Start with dynamic iframes unless native rendering solves a concrete requirement. Both approaches must call GraphJSON from your server because the workspace API key is secret.

Treat the server as a policy layer#

Do not expose a generic proxy that accepts arbitrary collections, SQL, filters, or graph configuration from the browser. Define allowed reports in code:

const reports = {
  usage_30d: {
    collection: "product_events",
    graph_type: "Single Line",
    start: "30 days ago",
    end: "now",
    filters: [["event", "=", "feature_used"]],
    aggregation: "Count",
    granularity: "Day",
    customizations: { title: "Feature usage" },
  },
} as const;

Then combine one known definition with an account ID derived from the verified application session:

export async function getAccountReport(request, reportName) {
  const session = await requireSession(request);
  const report = reports[reportName];
  if (!report) throw new Error("Unknown report");

  const payload = {
    ...report,
    api_key: process.env.GRAPHJSON_API_KEY,
    IANA_time_zone: session.account.reportingTimeZone || "UTC",
    filters: [
      ...report.filters,
      ["account_id", "=", session.account.id],
    ],
  };

  const response = await fetch(
    "https://api.graphjson.com/api/visualize/iframe",
    {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(payload),
      signal: AbortSignal.timeout(8_000),
    }
  );

  if (!response.ok) {
    throw new Error(`Analytics upstream returned ${response.status}`);
  }

  return response.json();
}

If a user can switch accounts, authorize the selected account before constructing the request. A browser-provided account ID is input to an authorization check, never authorization by itself.

Return the minimum response#

Your application endpoint should return only what the UI needs:

{
  "url": "https://graphjson.com/embed?p=...",
  "generated_at": "2026-07-26T18:42:00Z"
}

Do not forward upstream debugging data or echo the GraphJSON request, because it contains the API key and potentially sensitive filters. Return a stable application-level error code for logging and user messaging.

Cache by authorization scope#

An embed URL is a bearer capability. A safe cache key includes every value that can change the authorized result:

workspace-version:
report-name:
account-id:
reporting-time-zone:
configuration-version

Use a private, server-side cache. Do not place personalized endpoint responses in a public CDN, and do not omit account_id because two customers happen to request the same report.

Choose a short lifetime appropriate to authorization changes. Invalidate cached values when an account is removed, the report contract changes, or access is revoked. Relative time ranges still move when an iframe loads, so caching the configuration URL does not necessarily freeze the displayed data.

Build a polished loading shell#

Reserve the final chart height to prevent layout shift:

.analytics-frame {
  width: 100%;
  min-height: 360px;
  border: 0;
  border-radius: 12px;
  background: #fff;
}

Render four distinct states:

  1. Loading: a quiet placeholder with the same dimensions as the chart.
  2. Ready: the iframe with a specific accessible title.
  3. Empty: an explanation tied to the metric, such as “No API requests in this period.”
  4. Unavailable: a non-alarming retry option that does not block the rest of the product.

Do not show a zero when data failed to load. Zero is a valid business value and should remain distinguishable from an error.

Use loading="lazy" below the fold, but load the first decision-critical chart eagerly. Avoid rendering a large dashboard of iframes simultaneously when only the first tab is visible.

Match product context#

Give each chart:

  • a title that states the metric
  • a visible date range and reporting time zone
  • units and currency where relevant
  • a definition link or tooltip for non-obvious metrics
  • a source-updated or generated timestamp when staleness matters

Use GraphJSON chart customizations to match surrounding typography and colors, but preserve readable contrast and do not remove axes or labels needed to interpret the result.

On narrow screens, stack cards and maintain enough chart height for labels. Test at the same breakpoints as the product, including long translated titles if the interface is localized.

Make analytics non-blocking#

Embedded analytics should not prevent checkout, configuration, or another core workflow from loading. Apply an upstream timeout, catch the failure at the report boundary, and render the rest of the page.

Observe:

  • endpoint latency and timeout rate
  • GraphJSON response status
  • cache hit rate
  • iframe load errors where the browser exposes them
  • report name and authorized tenant ID

Never log the API key or complete embed URL. If a report contains sensitive filters, treat its URL as sensitive too.

Test tenant isolation#

Automated authorization tests should prove:

  • an unauthenticated request is rejected
  • a member of account A can request account A
  • a member of account A cannot request account B
  • a removed member loses access
  • an unknown report name cannot create an arbitrary query
  • cache entries for A and B are distinct

Add a browser test that loads a seeded account with a known total and confirms the chart shell reaches ready state. Keep a separate test for the error fallback by simulating an upstream timeout.

For the most important customer-visible metrics, seed two tenants with intentionally different values. A cache or filter bug is easier to detect when one should show 3 and the other 103.

Reconcile before release#

Select several real customer accounts across small, large, empty, and unusual histories. Compare the embedded value to the application’s source data for a fixed interval.

Document:

  • event and entity definition
  • reporting time zone
  • late-arrival behavior
  • deduplication rule
  • expected refresh behavior
  • owner and escalation path

Run this reconciliation after tracking changes, schema migrations, and report-definition changes—not only at initial launch.

Production checklist#

  • The browser never receives the GraphJSON API key.
  • Reports are selected from a server-owned allowlist.
  • Tenant filters come from an authorized server session.
  • Cache keys include tenant and configuration scope.
  • Loading, empty, and failure states are visually distinct.
  • Iframes have titles, stable dimensions, and responsive behavior.
  • Analytics failures do not block the core product.
  • Logs redact keys, URLs, and sensitive filter values.
  • Cross-tenant, revocation, cache, and timeout tests pass.
  • Customer-visible values reconcile against known source records.

Use Build personalized dashboards for the compact implementation path, or the Data API when the product needs native chart rendering.

Need a hand?

Tell us what you’re building and we’ll point you in the right direction.

Contact support