Embed analytics in Angular — SDK + session route
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
- Sign in at
https://embeddedcanvas.com→ open Analytics.
EC-2. Step 1 — Start analytics runtime
- Provision runtime until Status: active → Launch analytics builder.
EC-3. Step 2 — Configure workspace
- Connect warehouse → set Tenant column → Save.
- Confirm two test tenant ids with different row counts.
EC-4. Build dashboard + native filters
- Create a dataset/chart that changes between tenants → publish the dashboard.
-
Add native filters in the builder for every column you will drive from your app UI later
(tutorial examples:
city,service_line, optionaltech_name). These native filters are required even if you hide the filter bar — host-owned filters sync to them viafilters.extra(see Host-owned filters). - 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
- Builder → Embed dashboard → Allowed Domains:
http://localhost:4200. - Analytics Step 3 → register → iframe parent origins:
http://localhost:4200. - Copy the control-plane embed token UUID — you will paste it into
src/environments/environment.tsin Part II.
EC-6. Step 4 — Create embed API key
- Create embed API key → copy
ec_live_…once and keep it for Part II (you will create the server env file there). - Never put this key in Vite/React/Next public env (
VITE_*/NEXT_PUBLIC_*), browser code, or git.
Values checklist
| Value | Where you get it | Where you put it |
|---|---|---|
EMBED_TOKEN | Analytics Step 3 — dashboard embed token UUID | Client (and server mint body on BFF) |
CUSTOMER_ID | Tenant id from your warehouse / auth | Server derives; client may demo via input |
EMBED_API_KEY | Analytics Step 4 — ec_live_… | Server .env only |
EMBEDDED_CANVAS_API_URL | https://api.embeddedcanvas.com | Server .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.
| Credential | Looks like | Put it here | Never 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 |
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
- Scaffold the Angular app + install SDKs
- Create
server.mjs(the BFF) - Create project-root
.env(paste tokens) - Add the
bffscript topackage.json - Proxy
/api/…withproxy.conf.json - Create
environment.ts(browser-safe values) - Replace the root app with the embed component
- Run BFF + Angular and open the app
- Host-owned filters (non-native UI)
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).
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.
- Create the file:
touch server.mjs/ PowerShellNew-Item server.mjs/ New File in your editor. - 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).
- Create
.env. - Paste the template; replace placeholders from Part I:
EMBED_API_KEY← Step 4ec_live_…EMBED_TOKEN← Step 3 embed token UUIDTEST_CUSTOMER_ID← real warehouse tenant (example:co_alltrade)
- Add
.envto.gitignoreif 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
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.
- Create
proxy.conf.jsonin the project root (next topackage.json):
{
"/api/analytics/session": {
"target": "http://localhost:3090",
"secure": false,
"changeOrigin": true,
"logLevel": "info"
}
}
-
Wire the proxy into the serve target. Open
angular.json, findprojects→ 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": { … }
}
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.
-
Load the Superset embed UMD bundle so Angular’s esbuild does not break CJS interop.
In the same
angular.json, underarchitect.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)
-
Create folder
src/environmentsif missing:mkdir src/environments(PowerShell:New-Item -ItemType Directory -Force src/environments). - Create
src/environments/environment.tsand 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).
- Ensure
FormsModuleis available (imported on the component as shown). - Paste the full component (class name
AppComponentfor 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/).
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
- Open
http://localhost:4200. - Confirm the page shows your tenant id and the dashboard loads.
-
In DevTools → Network, find
POST /api/analytics/sessionand confirm the JSON body includes"customerId":"…"matchingenvironment.testCustomerId, and the response is 200. - Optional: type filter values that exist in your data → Apply (see host-owned filters below).
Configure .env/ 500 from BFF → missingEMBED_API_KEY,EMBED_TOKEN, or API URL; orserver.mjsleft empty.405on/api/analytics/session→ BFF file empty or not running.404/ proxy fail →proxy.conf.jsonmissing or not wired inangular.json; restartnpm 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:4200in builder Allowed Domains and control-plane iframe parent origins. embedDashboard is not a function→ add@superset-ui/embedded-sdk/bundle/index.jstoangular.jsonbuild.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)
- Open the dashboard in the analytics builder (Part I).
- Add a native filter for each column your host UI will drive
(examples used in these tutorials:
city,service_line). -
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). - Publish / save the dashboard.
-
Optional white-label: hide the native filter bar in the builder embed settings / keep
layoutMode: "chromeless"(Part II samples already do this).
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
- DevTools → Network →
POST …/session(or your BFF path) → confirm JSON includesfilters.extra. - Type a value you know exists for the current tenant → Apply → chart numbers should change.
- Clear inputs → Apply → broader (or unfiltered) result for that tenant.
filters.extra at mint time.
Both are required for white-label filter UX on the SDK + session route path.