Embed analytics in Angular — SDK + session route

SDK + session route — what is a BFF? A Backend-for-Frontend (BFF) is a thin server route owned by your app (Node, Next.js Route Handler, etc.) that mints embed sessions with your embed API key. The browser calls your session route (BFF); the BFF calls Embedded Canvas. That keeps ec_live_… off the client, binds customerId to your auth session, and is the production-safe path for SaaS embeds.

Same pattern as React/Vue: an Angular app plus a small Node server.mjs BFF in the project root. Angular proxies /api/analytics/session to the BFF; the BFF mints sessions with your embed API key.

Part I — Embedded Canvas setup

Control plane · do this first

EC-1. Sign in and open Analytics

  1. Sign in at https://embeddedcanvas.com → open Analytics.

EC-2. Step 1 — Start analytics runtime

  1. Provision runtime until Status: active → Launch analytics builder.

EC-3. Step 2 — Configure workspace

  1. Connect warehouse → set Tenant column → Save.
  2. Confirm two test tenant ids with different row counts.

EC-4. Build dashboard + native filters

  1. Create a dataset/chart that changes between tenants → publish the dashboard.
  2. Add native filters in the builder for every column you will drive from your app UI later (tutorial examples: city, service_line, optional tech_name). These native filters are required even if you hide the filter bar — host-owned filters sync to them via filters.extra (see Host-owned filters).
  3. For a white-label product UX, hide the native filter bar in the iframe and own filters in your app UI (Part II).

EC-5. Register dashboard & dual allowlist

  1. Builder → Embed dashboard → Allowed Domains: http://localhost:4200.
  2. Analytics Step 3 → register → iframe parent origins: http://localhost:4200.
  3. Copy the control-plane embed token UUID — you will paste it into src/environments/environment.ts in Part II.
Dual allowlist Step 3 iframe parent origins and builder Allowed Domains must both list every host origin.

EC-6. Step 4 — Create embed API key

  1. Create embed API key → copy ec_live_… once and keep it for Part II (you will create the server env file there).
  2. Never put this key in Vite/React/Next public env (VITE_* / NEXT_PUBLIC_*), browser code, or git.

Values checklist

ValueWhere you get itWhere you put it
EMBED_TOKENAnalytics Step 3 — dashboard embed token UUIDClient (and server mint body on BFF)
CUSTOMER_IDTenant id from your warehouse / authServer derives; client may demo via input
EMBED_API_KEYAnalytics Step 4 — ec_live_…Server .env only
EMBEDDED_CANVAS_API_URLhttps://api.embeddedcanvas.comServer .env only

Where to put each credential

Copy values from the Analytics wizard into the files below. Secrets stay on the server; the browser only gets the public dashboard embed token + short-lived session from your BFF.

CredentialLooks likePut it hereNever put it here
EMBED_TOKEN UUID from Analytics Step 3 Server: BFF .env next to server.mjs as EMBED_TOKEN (session mint body)
Browser: src/environments/environment.ts / mountAngularEmbed({ embedToken }) as embedToken (or NEXT_PUBLIC_EMBED_TOKEN / VITE_EMBED_TOKEN)
—
EMBED_API_KEY ec_live_… from Analytics Step 4 Server only: BFF .env next to server.mjs → read in your BFF; send as Authorization: Bearer … Vite/React client code, NEXT_PUBLIC_*, browser localStorage, repo commits
EMBEDDED_CANVAS_API_URL https://api.embeddedcanvas.com Server only: BFF .env next to server.mjs Client bundles (unless you only proxy via BFF — still prefer server)
CUSTOMER_ID / customerId Tenant key from your auth / warehouse Resolved in the BFF from the signed-in user, then returned/used in session mint. Demo UI may use a test input. Hard-coded production tenant ids that customers can spoof without auth checks
Session guestToken Short-lived token from BFF response Memory only — SDK receives it via fetchSession; do not persist long-term Logs, analytics tools, permanent cookies
Integrator mental model ec_live_… unlocks minting on the server. EMBED_TOKEN selects which dashboard to open (safe in the client). customerId selects which tenant rows the guest token may see.

Part II — Angular + session route (BFF)

Your laptop · follow in order

1. Scaffold the Angular app + install SDKs

In a terminal, run (Angular 17+ standalone is fine):

npx -y @angular/cli@19 new my-analytics-angular --routing=false --style=css --ssr=false --skip-git
cd my-analytics-angular
npm install @embeddedcanvas/embed-sdk@^1.7.0 @superset-ui/embedded-sdk

If the CLI prompts, choose defaults. Stay in my-analytics-angular for every remaining step. Newer CLIs may use src/app/app.ts + app.html instead of app.component.* — use the filenames your scaffold created (step 7 covers both).

Requirement Use Node 20+ so node --env-file=.env works when you start the BFF. Check with node -v.

2. Create server.mjs (the BFF)

This file does not exist yet — Angular CLI does not create it. In the project root (next to package.json), create server.mjs.

  1. Create the file: touch server.mjs / PowerShell New-Item server.mjs / New File in your editor.
  2. Paste the entire contents below and save (do not leave the file empty — empty → HTTP 405).
/**
 * Session-route BFF — mints embed sessions with your embed API key.
 * Run from this folder: npm run bff
 * (uses Node's --env-file=.env; requires Node 20+)
 */
import http from "node:http";

const API_URL = (process.env.EMBEDDED_CANVAS_API_URL || "").replace(/\/$/, "");
const EMBED_API_KEY = process.env.EMBED_API_KEY?.trim();
const EMBED_TOKEN = process.env.EMBED_TOKEN?.trim();
const PORT = Number(process.env.PORT || 3090);

function json(res, status, body) {
  res.writeHead(status, {
    "Content-Type": "application/json",
    "Access-Control-Allow-Origin": "*",
    "Access-Control-Allow-Methods": "POST, OPTIONS",
    "Access-Control-Allow-Headers": "Content-Type",
  });
  res.end(JSON.stringify(body));
}

const server = http.createServer(async (req, res) => {
  if (req.method === "OPTIONS") return json(res, 204, {});
  if (req.method === "POST" && req.url === "/api/analytics/session") {
    if (!API_URL || !EMBED_API_KEY || !EMBED_TOKEN) {
      return json(res, 500, { error: "Configure .env (EMBEDDED_CANVAS_API_URL, EMBED_API_KEY, EMBED_TOKEN)" });
    }
    let body = "";
    for await (const chunk of req) body += chunk;
    let customerId = process.env.TEST_CUSTOMER_ID || "CUST-1004";
    let filters;
    try {
      const parsed = body ? JSON.parse(body) : {};
      if (typeof parsed.customerId === "string" && parsed.customerId.trim()) {
        customerId = parsed.customerId.trim();
      }
      filters = parsed.filters;
    } catch {
      return json(res, 400, { error: "Invalid JSON" });
    }
    const payload = { embedToken: EMBED_TOKEN, customerId };
    if (filters) payload.filters = filters;
    const upstream = await fetch(`${API_URL}/public/embed/v1/sessions`, {
      method: "POST",
      headers: {
        Authorization: `Bearer ${EMBED_API_KEY}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify(payload),
    });
    return json(res, upstream.status, await upstream.json());
  }
  json(res, 404, { error: "Not found" });
});

server.listen(PORT, () => {
  console.log(`BFF listening on http://localhost:${PORT}`);
  console.log(`POST http://localhost:${PORT}/api/analytics/session`);
});

3. Create project-root .env (paste tokens)

Create .env next to server.mjs. This file is for the BFF only (Angular does not load it into the browser).

  1. Create .env.
  2. Paste the template; replace placeholders from Part I:
    • EMBED_API_KEY ← Step 4 ec_live_…
    • EMBED_TOKEN ← Step 3 embed token UUID
    • TEST_CUSTOMER_ID ← real warehouse tenant (example: co_alltrade)
  3. Add .env to .gitignore if it is not already listed.
# Server only — read by: npm run bff  (node --env-file=.env server.mjs)
EMBEDDED_CANVAS_API_URL=https://api.embeddedcanvas.com
EMBED_API_KEY=ec_live_PASTE_STEP4_KEY
EMBED_TOKEN=PASTE_STEP3_EMBED_TOKEN_UUID
PORT=3090
TEST_CUSTOMER_ID=CUST-1004
Do not put ec_live_… in Angular Browser-safe values go in environment.ts (step 6): embed token UUID + test tenant id only. The BFF keeps the API key.

4. Add the bff script to package.json

Open package.json and add a bff script alongside the Angular scripts:

File: package.json

{
  "scripts": {
    "start": "ng serve",
    "build": "ng build",
    "bff": "node --env-file=.env server.mjs"
  }
}

Keep your existing script names; only add bff.

5. Proxy /api/… with proxy.conf.json

The Angular app calls fetch("/api/analytics/session") on http://localhost:4200. The CLI must forward that path to the BFF on port 3090.

  1. Create proxy.conf.json in the project root (next to package.json):
{
  "/api/analytics/session": {
    "target": "http://localhost:3090",
    "secure": false,
    "changeOrigin": true,
    "logLevel": "info"
  }
}
  1. Wire the proxy into the serve target. Open angular.json, find projects → your app → architect → serve → options, and add:
"proxyConfig": "proxy.conf.json"

Example shape (must be under architect.serve.options, not test):

"serve": {
  "builder": "@angular-devkit/build-angular:dev-server",
  "options": {
    "proxyConfig": "proxy.conf.json"
  },
  "configurations": { … }
}
Wrong place = HTML instead of JSON If proxyConfig is under test (or missing), POST /api/analytics/session returns the Angular index.html and the browser throws Unexpected token '<' / is not valid JSON. Fix the path, then restart npm start.
  1. Load the Superset embed UMD bundle so Angular’s esbuild does not break CJS interop. In the same angular.json, under architect.build.options, set:
"scripts": [
  "node_modules/@superset-ui/embedded-sdk/bundle/index.js"
],
"allowedCommonJsDependencies": [
  "@superset-ui/embedded-sdk",
  "@superset-ui/switchboard"
]

Use @embeddedcanvas/embed-sdk@^1.8.2 or newer. After changing scripts, delete .angular and restart npm start.

6. Create environment.ts (browser-safe values)

  1. Create folder src/environments if missing: mkdir src/environments (PowerShell: New-Item -ItemType Directory -Force src/environments).
  2. Create src/environments/environment.ts and paste:
export const environment = {
  // Same Step 3 UUID as EMBED_TOKEN in the BFF .env (safe in the browser)
  embedToken: "PASTE_STEP3_EMBED_TOKEN_UUID",
  // Same tenant as TEST_CUSTOMER_ID — drives RLS
  testCustomerId: "CUST-1004",
};

Replace both placeholders. embedToken must equal BFF EMBED_TOKEN. testCustomerId must be a real warehouse tenant id (example: co_alltrade).

7. Replace the root app with the embed component

Replace your root component with the code below. If the scaffold created src/app/app.component.ts, keep the class name AppComponent (so main.ts keeps working). If it created src/app/app.ts, you may use App and match whatever main.ts imports. Delete or empty a separate app.component.html / app.html if the template is inline (as below).

  1. Ensure FormsModule is available (imported on the component as shown).
  2. Paste the full component (class name AppComponent for the default CLI layout):
import { Component, ElementRef, OnDestroy, OnInit, ViewChild } from "@angular/core";
import { FormsModule } from "@angular/forms";
import { mountAngularEmbed } from "@embeddedcanvas/embed-sdk/angular";
import type { AnalyticsEmbedController } from "@embeddedcanvas/embed-sdk";
import { environment } from "../environments/environment";

@Component({
  selector: "app-root",
  standalone: true,
  imports: [FormsModule],
  template: `
    <p style="padding: 12px 12px 0; color: #64748b; font-size: 14px">
      Tenant: <code>{{ customerId }}</code>
    </p>
    <div style="display: flex; gap: 8px; padding: 12px">
      <input [(ngModel)]="city" name="city" placeholder="city" />
      <input [(ngModel)]="serviceLine" name="sl" placeholder="service_line" />
      <button type="button" (click)="applyFilters()">Apply</button>
    </div>
    <div #mount style="min-height: 70vh"></div>
  `,
})
export class AppComponent implements OnInit, OnDestroy {
  @ViewChild("mount", { static: true }) mount!: ElementRef<HTMLDivElement>;

  embedToken = environment.embedToken;
  customerId = environment.testCustomerId;
  city = "";
  serviceLine = "";

  private controller: AnalyticsEmbedController | null = null;
  private filters = { city: "", service_line: "" };

  async ngOnInit() {
    this.controller = await mountAngularEmbed(this.mount, {
      embedToken: this.embedToken,
      customerId: this.customerId,
      layoutMode: "chromeless",
      fetchSession: () => this.mint(),
    });
  }

  ngOnDestroy() {
    this.controller?.destroy();
  }

  async applyFilters() {
    this.filters = { city: this.city, service_line: this.serviceLine };
    const extra: Record<string, string> = {};
    for (const [k, v] of Object.entries(this.filters)) {
      if (v.trim()) extra[k] = v.trim();
    }
    await this.controller?.setFilters(Object.keys(extra).length ? { extra } : {});
  }

  private mint() {
    const extra: Record<string, string> = {};
    for (const [k, v] of Object.entries(this.filters)) {
      if (v.trim()) extra[k] = v.trim();
    }
    return fetch("/api/analytics/session", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        customerId: this.customerId,
        ...(Object.keys(extra).length ? { filters: { extra } } : {}),
      }),
    }).then(async (res) => {
      const data = await res.json();
      if (!res.ok) throw new Error(data.error || "Session mint failed");
      return data;
    });
  }
}

Confirm main.ts still does import { AppComponent } from './app/app.component' and bootstrapApplication(AppComponent, appConfig). Fix the environment import path if needed (../environments/environment from src/app/).

RLS / empty charts environment.testCustomerId is what gets sent as customerId. Updating only BFF TEST_CUSTOMER_ID is not enough when the Angular body always sends environment.testCustomerId.

8. Run BFF + Angular and open the app

You need two terminals, both in my-analytics-angular:

# Terminal 1 — session-route BFF (must stay running)
npm run bff
# Expect: BFF listening on http://localhost:3090

# Terminal 2 — Angular app
npm start
# Expect: Local http://localhost:4200
  1. Open http://localhost:4200.
  2. Confirm the page shows your tenant id and the dashboard loads.
  3. In DevTools → Network, find POST /api/analytics/session and confirm the JSON body includes "customerId":"…" matching environment.testCustomerId, and the response is 200.
  4. Optional: type filter values that exist in your data → Apply (see host-owned filters below).
If the embed is blank or charts say “No results”
  • Configure .env / 500 from BFF → missing EMBED_API_KEY, EMBED_TOKEN, or API URL; or server.mjs left empty.
  • 405 on /api/analytics/session → BFF file empty or not running.
  • 404 / proxy fail → proxy.conf.json missing or not wired in angular.json; restart npm start.
  • EMBED_TOKEN (BFF) ≠ environment.embedToken → use the same Step 3 UUID.
  • Empty charts / RLS → wrong testCustomerId (builder Company filter ≠ embed claim).
  • Allowlist errors → Part I must list http://localhost:4200 in builder Allowed Domains and control-plane iframe parent origins.
  • embedDashboard is not a function → add @superset-ui/embedded-sdk/bundle/index.js to angular.json build.options.scripts, use @embeddedcanvas/embed-sdk@^1.8.2, delete .angular, hard-refresh the browser.

Final layout:

my-analytics-angular/
  .env                      ← step 3 (BFF secrets only)
  server.mjs                ← step 2 (must not be empty)
  proxy.conf.json           ← step 5
  package.json              ← "bff" script
  angular.json              ← proxyConfig
  src/environments/environment.ts
  src/app/app.ts            ← embed component (or app.component.ts)

Host-owned filters (non-native UI) — step by step

White-label filter UX

Native filters are the filter controls inside the analytics builder / iframe. Host-owned filters are inputs in your app (the city / service_line boxes in Part II). Your app collects values → sends them as filters.extra on session mint → Embedded Canvas applies them and syncs matching native filters. End users never need the native filter bar.

F-1. Create matching native filters in the builder (required)

  1. Open the dashboard in the analytics builder (Part I).
  2. Add a native filter for each column your host UI will drive (examples used in these tutorials: city, service_line).
  3. The filter must target the same dataset column you will send in filters.extra. Keys are matched by column name (case/spelling must match warehouse values too).
  4. Publish / save the dashboard.
  5. Optional white-label: hide the native filter bar in the builder embed settings / keep layoutMode: "chromeless" (Part II samples already do this).
If host filters do nothing Usually the native filter was never created, the column name does not match the filters.extra key, or the typed value does not exist in the data (wrong spelling/case).

F-2. Collect values in your app UI

Part II already adds simple inputs (e.g. city, service_line) and an Apply button. Those are host-owned filters — not Superset UI.

F-3. Send values on session mint as filters.extra

Your session-route BFF (server.mjs) must include non-empty host values under filters.extra when calling POST /public/embed/v1/sessions (directly or via your BFF):

{
  "embedToken": "…",
  "customerId": "co_alltrade",
  "filters": {
    "extra": {
      "city": "Denver",
      "service_line": "Electrical"
    }
  }
}

In Part II sample apps, the browser posts that shape to /api/analytics/session; the BFF forwards filters to Embedded Canvas. Empty strings are omitted — only filled inputs are sent.

F-4. On Apply, remint (do not only update local React/Vue state)

In the Angular sample, Apply copies city / serviceLine into this.filters, then calls await this.controller?.setFilters({ extra }). The SDK remints through your BFF with filters.extra. Changing the inputs alone does not remint — you must click Apply. Native filters for those columns must exist on the dashboard (F-1).

Changing an input alone does nothing until Apply remints the guest session with the new filters.extra. Charts then reload under RLS + the new filter values.

F-5. Verify

  1. DevTools → Network → POST …/session (or your BFF path) → confirm JSON includes filters.extra.
  2. Type a value you know exists for the current tenant → Apply → chart numbers should change.
  3. Clear inputs → Apply → broader (or unfiltered) result for that tenant.
Mental model Native filters = definition in the builder (which columns can be filtered). Host-owned filters = your UI + filters.extra at mint time. Both are required for white-label filter UX on the SDK + session route path.