stackcanvas can send a small amount of anonymous, opt-in usage telemetry
to help prioritize development. This document is the complete, authoritative
description of what that is, when it happens, and how to turn it off. If
anything in the product disagrees with this file, treat it as a bug in the
product, not in this file — packages/server/src/telemetry.ts
has a test (envelope allowlist tripwire, in
telemetry.test.ts) that fails the
build the moment the code sends a field that isn't documented here.
- Off by default. Nothing is ever sent until you click Allow on the one-time consent banner in the canvas UI.
- Five counters, ever. Installs, canvases opened, edits sent (by kind), scans run, and drift-lens opens. No resource names, no infrastructure data, no file paths, no IPs.
- One vendor endpoint, source included.
https://t.stackcanvas.dev/e, a small AWS Lambda (behind API Gateway) whose source lives in this repo. It is the only network endpoint stackcanvas' own code ever calls out to. - Three ways to turn it off: click "No thanks", set
DO_NOT_TRACK=1, or setSTACKCANVAS_TELEMETRY=0.
Consent has four possible states:
| State | Meaning |
|---|---|
unset |
Default. Nothing has ever been sent. The consent banner shows once. |
granted |
You clicked Allow. Events are sent as described below. |
denied |
You clicked No thanks. Nothing is sent, ever, until you change your mind (there is currently no UI to re-open the banner after a denied decision short of editing the config file below). |
disabled_env |
An environment variable overrides everything else (see below). Nothing is sent, the banner never shows, and nothing is written to disk. |
The MCP server itself runs over stdio and has no way to prompt you, so the
canvas web UI is the only consent surface. The first time you open a
canvas with consent unset, a banner appears:
Anonymous usage counters (no resource names, no infra data) help prioritize development — see TELEMETRY.md. [Allow] [No thanks]
Clicking either button persists the decision (see "Config file" below) and the banner never reappears.
- "No thanks" in the banner — persists
consent: "denied"to the config file. DO_NOT_TRACK=1— the ambient convention some CLIs respect. Forcesdisabled_env, regardless of what's in the config file.STACKCANVAS_TELEMETRY=0— stackcanvas-specific equivalent. Also forcesdisabled_env, regardless of what's in the config file.
STACKCANVAS_TELEMETRY=1 is not an opt-out mechanism and does not
grant consent by itself — opt-in must be an explicit click. Setting it only
un-hides the banner if the stored decision was denied, letting you
re-decide.
~/.stackcanvas/config.json (path overridable with STACKCANVAS_CONFIG_DIR,
used by this repo's own test suite and e2e run to stay hermetic — see
vitest.config.ts / playwright.config.ts):
anonId is a coin-flip UUID — there is no MAC address, hostname, or user
identifier hashed into it, no fingerprinting of any kind. Denying consent
deletes anonId from the file. Config writes are atomic (.tmp + rename);
a crash mid-write just means the banner may show again next launch.
Four of the five events are emitted directly by server- or MCP-side code
that already holds a TelemetryClient — they never touch the browser:
| Event | Fires when | Code |
|---|---|---|
install |
The first time you ever click "Allow" (deduped forever via installReportedAt) |
TelemetryClient.setConsent(true), telemetry.ts |
canvas_opened |
Every successful CanvasServer.start() — covers both stackcanvas serve and the MCP open_canvas tool (which calls start() once per new canvas, never again for a reused one) |
canvas-server.ts |
intent_sent |
Every valid POST /api/intent (i.e. every Send to agent click on the canvas), counted by action kind after validation/normalization |
canvas-server.ts |
scan_run |
Reserved for the live-scan feature; not emitted by any code yet | — |
drift_opened is the one browser-originated event (the drift lens is
UI-only, it has no server-side trigger). It never goes straight from the
browser to the collector — it travels:
browser → POST /api/telemetry/event { name: "drift_opened" } → CanvasServer → TelemetryClient.emit() → collector
POST /api/telemetry/event re-checks consent === "granted" server-side
(it never trusts the browser's belief about its own state) and validates
name against a server-side allowlist narrower than the full event set
— currently just ["drift_opened"] — so a browser can never spoof
install/canvas_opened/intent_sent/scan_run. An unknown name gets a
400; a known name with consent not granted still returns 200 {ok: true} so the response never leaks your consent state. This route (like
every /api/* POST route: /api/intent, /api/telemetry,
/api/telemetry/event) is also behind a Host/Origin allowlist
(127.0.0.1|localhost, any port) that rejects anything that isn't a
same-machine request.
emit() itself is a hard no-op unless getConsent() === "granted". When it
does run: it builds the envelope below and does
fetch(endpoint, { method: "POST", body, signal: AbortSignal.timeout(3000) }).catch(() => {})
— fire-and-forget, 3-second timeout, no retries, no local queue (losing an
event is fine; storing unsent events on disk is not), and no logging to
stdout or stderr (stdout is the MCP protocol channel; a stray log line would
corrupt it).
Every event is wrapped in the same envelope. This is the complete list — nothing else is ever added to it without a documented schema-version bump.
interface TelemetryEnvelope {
schema: 1 // TELEMETRY_SCHEMA_VERSION, telemetry.ts
anon_id: string // random UUIDv4, see "Config file" above
day: string // "YYYY-MM-DD", UTC — the finest time precision that ever leaves the machine
app_version: string // the published `stackcanvas` npm package version
platform: "darwin" | "linux" | "win32" | "other"
node_major: number // e.g. 22
payload: TelemetryProps // one of the five shapes below
}The five payload shapes — every field of every event, verbatim:
{ event: "install" }
{ event: "canvas_opened", nodes_bucket: NodesBucket, tf_bin: "terraform" | "tofu" | "unknown" }
{ event: "intent_sent", add: number, modify: number, remove: number, adopt: number, investigate: number }
// each counter capped at 50; adopt/investigate are always 0 until the
// intent-v2 pipeline ships (reserved now so no schema bump is needed later)
{ event: "scan_run", provider: "aws" | "gcp" | "azure" | "other", nodes_bucket: NodesBucket }
// reserved — no code emits this yet; ships with the live-scan feature
{ event: "drift_opened", nodes_bucket: NodesBucket }
// reserved — no UI calls this yet; ships with the drift-lens feature.
// The forwarding route (POST /api/telemetry/event) already exists (above).type NodesBucket = "0" | "1-10" | "11-50" | "51-200" | "200+"nodes_bucket is a coarse bucket of your Terraform graph's node count
(computed by nodesBucket() in telemetry.ts) — never the actual count,
never any resource identity.
- Resource names, types, addresses, or attributes (beyond the coarse node-count bucket above)
- File paths, directory names, repo names
- IP addresses, hostnames, MAC addresses, or any device fingerprint
- Cloud account IDs, ARNs, or any other cloud-provider identifier
- Client-sent timestamps finer than a UTC day (the collector records its
own arrival time server-side — see
telemetry-collector/schema.md) - Anything not listed in the schema above — enforced by the "envelope allowlist tripwire" test (see "How to verify" below)
Endpoint: https://t.stackcanvas.dev/e (POST only). A small
self-hosted AWS Lambda behind API Gateway, source in this repo at
telemetry-collector/src/handler.ts
(no code path can ever reach it without an explicit "Allow" click). It
validates every envelope against the same hard allowlist described above
(rejects unknown keys, unknown event, a non-UUID anon_id, counts over
50, or a malformed day), ships one record to Kinesis Data Firehose —
which batches and delivers it to S3 as the system of record, see
telemetry-collector/schema.md — and
responds 200 {ok: true} on success / 400/413 otherwise. It stores no
cookies, the handler never reads the client IP (AWS edge/access logs may
record IPs at the infrastructure layer; we don't query them), and it has
CORS closed (only this repo's own server-side code calls it — never the
browser directly, see "When events fire" above).
PostHog / third-party analytics were evaluated and rejected: a
third-party processor breaks the "no cloud backend of ours, fully
auditable" story this project is built on, and its SDKs pull in
batching/retry/persistent-queue machinery this project deliberately doesn't
want. The client here is a single fetch call.
t.stackcanvas.dev is the only vendor-side endpoint anywhere in
stackcanvas. It receives only the allowlisted envelope documented above.
Every other feature of the product — reading your Terraform state, talking
to the Terraform/OpenTofu CLI, serving the canvas UI — runs entirely on
127.0.0.1. The product is fully functional with telemetry denied, or
with this endpoint unreachable entirely: emit() is fire-and-forget with
a 3-second timeout and every failure is silently swallowed, by design (see
"When events fire" above). No other stackcanvas code path ever calls out to
infrastructure we run.
- "install" really means "first consented open." Since consent only
exists in the canvas UI, someone who installs the package but never opens
a canvas — or opens one and never clicks "Allow" — is invisible to this
telemetry.
installundercounts raw installs; npm's own download stats are the better proxy for reach. This telemetry answers activation/retention questions, not "how many people installed this." anon_idis pseudonymous, not anonymous, under a strict reading of GDPR-style definitions — it's a stable per-machine identifier, even though it carries no directly identifying data and there is no user account to link it to.
Because the collector's only key is the random anon_id in your own
~/.stackcanvas/config.json, you can request deletion of any data
associated with it: open an issue at
github.com/pshenok/stackcanvas
with the anon_id value (never anything else from the config file) and
we'll delete the matching records from the S3 mirror.
- Read the source:
packages/server/src/telemetry.ts(client + consent state machine) andpackages/server/src/canvas-server.ts(the three/api/telemetry*routes and the emitter call sites). - Run the test suite:
npx pnpm@9 test. In particular,packages/server/src/telemetry.test.tshas an envelope allowlist tripwire test (test.eachover all five events) that parses the exact JSON bodyemit()would send and fails if a single key exists outside the schema on this page — a future field can't quietly ship without this file being updated in the same PR.packages/server/src/telemetry-routes.test.tsadditionally proves, with a realCanvasServerand a spiedfetch, that no event is ever sent before consent is granted, and thatDO_NOT_TRACK=1silences a config file that already saysgranted. - Watch it yourself: open dev tools' network tab (or a local proxy) while
running
stackcanvas servewith consent granted — the only external host it will ever contact ist.stackcanvas.dev.
Telemetry never becomes opt-out, and never gains a field outside the
allowlist above without: a bump to TELEMETRY_SCHEMA_VERSION in
telemetry.ts, an update to this file in the same PR, and a documented
minor version release.
{ "telemetry": { "consent": "granted", // "granted" | "denied" "anonId": "1c9f6a8e-…", // random UUIDv4, minted at the moment of grant, deleted on deny "decidedAt": "2026-07-12", // UTC date the consent decision was made "installReportedAt": "2026-07-12" // dedupe marker: the 'install' event fires at most once, ever } }