diff --git a/.changeset/telemetry-error-tracking.md b/.changeset/telemetry-error-tracking.md
new file mode 100644
index 00000000..cae5bcd0
--- /dev/null
+++ b/.changeset/telemetry-error-tracking.md
@@ -0,0 +1,5 @@
+---
+"@opencoredev/email-sdk": minor
+---
+
+Add anonymous usage telemetry and redacted error reporting to the SDK and CLI via PostHog. The client reports `client created` (configured adapter names), `email sent` (adapter, success/failure, error code, duration, recipient count, whether recipient variables or `sendAt` were used, and the delivery path: single, native bulk, or per-recipient expansion), an `email batch sent` summary for `sendBatch`, and the CLI reports `cli command run` (command, adapter, success). A `source` property distinguishes CLI runs from library usage, and CI providers are detected. Error reports carry only the error type, Email SDK error code, and stack frames with package-relative file names; messages are scrubbed of email addresses, URLs, quoted text, tokens, and home directories before upload. No email content, addresses, headers, or credentials are ever collected, and custom adapter names are masked as `custom`. A one-time notice with opt-out instructions is printed on first use. Opt out with `EMAIL_SDK_TELEMETRY=0`, `DO_NOT_TRACK=1`, or `createEmailClient({ telemetry: false })`; telemetry is disabled automatically when `NODE_ENV=test`.
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index b4171ad1..0e12547d 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -60,6 +60,26 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.CHANGESETS_TOKEN }}
+ - name: Annotate release in PostHog
+ if: steps.changesets.outputs.published == 'true'
+ env:
+ POSTHOG_PERSONAL_API_KEY: ${{ secrets.POSTHOG_PERSONAL_API_KEY }}
+ run: |
+ if [ -z "$POSTHOG_PERSONAL_API_KEY" ]; then
+ echo "::notice::POSTHOG_PERSONAL_API_KEY not set; skipping release annotation."
+ exit 0
+ fi
+ VERSION="$(bun -e 'const pkg = await Bun.file("packages/email-sdk/package.json").json(); console.log(pkg.version)')"
+ PAYLOAD="$(jq -n \
+ --arg content "@opencoredev/email-sdk v${VERSION} released" \
+ --arg date "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
+ '{content: $content, date_marker: $date, scope: "project"}')"
+ curl --fail-with-body -sS -X POST "https://us.posthog.com/api/projects/468042/annotations/" \
+ -H "Authorization: Bearer $POSTHOG_PERSONAL_API_KEY" \
+ -H "Content-Type: application/json" \
+ -d "$PAYLOAD" \
+ || echo "::warning::PostHog release annotation failed (non-blocking)."
+
- name: Update Homebrew formula checksum
if: steps.changesets.outputs.published == 'true'
env:
diff --git a/README.md b/README.md
index 54b18058..6fb980bb 100644
--- a/README.md
+++ b/README.md
@@ -63,6 +63,26 @@ Full docs live at **[email-sdk.dev/docs](https://email-sdk.dev/docs)**. Good pla
- [Fallbacks and retries](https://email-sdk.dev/docs/concepts/fallbacks-and-retries)
- [Field support](https://email-sdk.dev/docs/adapters/field-support)
+## Telemetry
+
+Email SDK collects anonymous usage analytics so we can see which adapters and CLI commands get used and how often sends succeed. The first run prints a notice with opt-out instructions.
+
+What is collected: built-in adapter names (custom adapters are reported as `custom`), CLI command names, success/failure and error codes, send duration, total recipient counts (`to` + `cc` + `bcc`), whether a message includes attachments (a boolean only, never the files themselves), whether a send used recipient variables or scheduling and which delivery path ran, SDK version, OS, Node.js version, whether the run happens in CI (and which CI provider), whether usage comes from the library or the bundled CLI, and redacted error reports — the error type, Email SDK error code, and stack traces with file paths reduced to package-relative names, with error messages scrubbed of email addresses, URLs, quoted text, long tokens, and home directories before upload — tied to a random anonymous ID stored in `~/.config/email-sdk/telemetry.json`. What is never collected: email content, subjects, addresses, headers, attachments, API keys, or any other message data.
+
+Opt out at any time with an environment variable:
+
+```bash
+export EMAIL_SDK_TELEMETRY=0 # or DO_NOT_TRACK=1
+```
+
+or per client in code:
+
+```ts
+const client = createEmailClient({ adapters: [resend({ apiKey })], telemetry: false });
+```
+
+Telemetry is also disabled automatically when `NODE_ENV=test`.
+
## Sponsors
Email SDK is supported by companies that help keep provider integrations practical and maintained. Want your logo here? **[Become a sponsor →](https://github.com/sponsors/opencoredev)**
diff --git a/apps/fumadocs/package.json b/apps/fumadocs/package.json
index 9da8a4cf..e50c92dc 100644
--- a/apps/fumadocs/package.json
+++ b/apps/fumadocs/package.json
@@ -26,6 +26,7 @@
"fumadocs-ui": "16.9.1",
"lucide-react": "^1.16.0",
"marked": "^18.0.5",
+ "posthog-js": "^1.386.6",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"sanitize-html": "^2.17.5",
diff --git a/apps/fumadocs/src/lib/posthog.ts b/apps/fumadocs/src/lib/posthog.ts
new file mode 100644
index 00000000..837c2221
--- /dev/null
+++ b/apps/fumadocs/src/lib/posthog.ts
@@ -0,0 +1,33 @@
+import posthog from "posthog-js";
+
+// Same PostHog project as the SDK/CLI telemetry (write-only public key).
+const POSTHOG_PROJECT_KEY = "phc_D62r4m5ivBr6LPCBqjKHg8GL6QTxT57LTzKrmkg5hNZS";
+
+let initialized = false;
+
+export function initPostHog() {
+ if (typeof window === "undefined" || initialized) {
+ return;
+ }
+
+ initialized = true;
+
+ posthog.init(POSTHOG_PROJECT_KEY, {
+ api_host: "https://us.i.posthog.com",
+ // 2026-01-30 defaults capture pageviews on history changes, covering
+ // TanStack Router client-side navigations without a router subscription.
+ defaults: "2026-01-30",
+ capture_exceptions: {
+ capture_unhandled_errors: true,
+ capture_unhandled_rejections: true,
+ // Explicitly off: console.error noise would drown real exceptions.
+ capture_console_errors: false,
+ },
+ capture_performance: { web_vitals: true },
+ // Docs traffic is anonymous (we never identify), so no person profiles are
+ // created — mirroring the SDK's server-side $process_person_profile: false.
+ person_profiles: "identified_only",
+ // Flip to false (plus a sampling rate in project settings) to enable replay.
+ disable_session_recording: true,
+ });
+}
diff --git a/apps/fumadocs/src/routes/__root.tsx b/apps/fumadocs/src/routes/__root.tsx
index 6812287c..023449a0 100644
--- a/apps/fumadocs/src/routes/__root.tsx
+++ b/apps/fumadocs/src/routes/__root.tsx
@@ -8,6 +8,7 @@ import { StaleBuildNotice } from "@/components/stale-build-notice";
import { chunkLoadGuardScript } from "@/lib/chunk-load-guard";
import { domMutationGuardScript } from "@/lib/dom-mutation-guard";
import { siteMeta } from "@/lib/metadata";
+import { initPostHog } from "@/lib/posthog";
import appCss from "@/styles/app.css?url";
@@ -38,6 +39,10 @@ export const Route = createRootRoute({
});
function RootComponent() {
+ React.useEffect(() => {
+ initPostHog();
+ }, []);
+
return (
diff --git a/apps/fumadocs/src/routes/privacy.tsx b/apps/fumadocs/src/routes/privacy.tsx
index 16594f01..118a2ff4 100644
--- a/apps/fumadocs/src/routes/privacy.tsx
+++ b/apps/fumadocs/src/routes/privacy.tsx
@@ -42,9 +42,18 @@ function Privacy() {
We may receive basic website analytics, such as page views, referrers, browser
- information, and coarse region data. If you contact the project, open an issue, or
- contribute to the repository, we receive the information you choose to provide in that
- message or contribution.
+ information, and coarse region data, along with client-side error reports that help us
+ fix broken docs pages. The npm package collects its own anonymous, opt-out usage
+ telemetry described in the{" "}
+
+ project README
+
+ . If you contact the project, open an issue, or contribute to the repository, we
+ receive the information you choose to provide in that message or contribution.
@@ -60,9 +69,9 @@ function Privacy() {
- The site is hosted on Vercel and may use Vercel Web Analytics. Package downloads,
- issues, pull requests, and repository activity are handled by npm and GitHub under
- their own policies.
+ The site is hosted on Vercel and may use Vercel Web Analytics and PostHog (US cloud)
+ for analytics and error monitoring. Package downloads, issues, pull requests, and
+ repository activity are handled by npm and GitHub under their own policies.
diff --git a/bun.lock b/bun.lock
index 0529592a..e43224bc 100644
--- a/bun.lock
+++ b/bun.lock
@@ -32,6 +32,7 @@
"fumadocs-ui": "16.9.1",
"lucide-react": "^1.16.0",
"marked": "^18.0.5",
+ "posthog-js": "^1.386.6",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"sanitize-html": "^2.17.5",
@@ -349,6 +350,10 @@
"@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.67.0", "", { "os": "win32", "cpu": "x64" }, "sha512-bw24y+/1MHS4QDkons3YyHkPT9uCMoLHHgQhb+mb8NOjTYwub1CZ+K9Ngr8aO5DMrDrkqHwTzlTwFP2vS8Y/ZQ=="],
+ "@posthog/core": ["@posthog/core@1.39.6", "", { "dependencies": { "@posthog/types": "^1.392.0" } }, "sha512-o6ajIwN5zXoNP0D4H/QPmOyibNTUkSyOR6ya7AG5U2ywXx4awo72L2KnCoiZPQM5x/bXv6jPBdimH8M18Ax0aw=="],
+
+ "@posthog/types": ["@posthog/types@1.392.1", "", {}, "sha512-Qg6Gl7/1vlr8+gPtBi5gwnLgAgiyFoKOVmTvTtDcvya9cpTwZfna7rQmkGQ4B63CunUYNNbOlqcwiUwUDyTK6w=="],
+
"@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="],
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="],
@@ -577,6 +582,8 @@
"@types/sanitize-html": ["@types/sanitize-html@2.16.1", "", { "dependencies": { "htmlparser2": "^10.1" } }, "sha512-n9wjs8bCOTyN/ynwD8s/nTcTreIHB1vf31vhLMGqUPNHaweKC4/fAl4Dj+hUlCTKYgm4P3k83fmiFfzkZ6sgMA=="],
+ "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
+
"@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
"@ungap/structured-clone": ["@ungap/structured-clone@1.3.1", "", {}, "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ=="],
@@ -697,6 +704,8 @@
"cookie-es": ["cookie-es@3.1.1", "", {}, "sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg=="],
+ "core-js": ["core-js@3.49.0", "", {}, "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg=="],
+
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
"crossws": ["crossws@0.4.5", "", { "peerDependencies": { "srvx": ">=0.11.5" }, "optionalPeers": ["srvx"] }, "sha512-wUR89x/Rw7/8t+vn0CmGDYM9TD6VtARGb0LD5jq2wjtMy1vCP4M+sm6N6TigWeTYvnA8MoW29NqqXD0ep0rfBA=="],
@@ -735,6 +744,8 @@
"domhandler": ["domhandler@5.0.3", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="],
+ "dompurify": ["dompurify@3.4.11", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw=="],
+
"domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="],
"dotenv": ["dotenv@17.4.2", "", {}, "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw=="],
@@ -801,6 +812,8 @@
"fetchdts": ["fetchdts@0.1.7", "", {}, "sha512-YoZjBdafyLIop9lSxXVI33oLD5kN31q4Td+CasofLLYeLXRFeOsuOw0Uo+XNRi9PZlbfdlN2GmRtm4tCEQ9/KA=="],
+ "fflate": ["fflate@0.4.8", "", {}, "sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA=="],
+
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
"find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="],
@@ -1173,12 +1186,18 @@
"postcss": ["postcss@8.5.15", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="],
+ "posthog-js": ["posthog-js@1.396.8", "", { "dependencies": { "@posthog/core": "^1.39.6", "@posthog/types": "^1.392.1", "core-js": "^3.38.1", "dompurify": "^3.3.2", "fflate": "^0.4.8", "preact": "^10.29.2", "query-selector-shadow-dom": "^1.0.1", "web-vitals": "^5.3.0" } }, "sha512-S5FGHn5nUZ//SVriGOAAMUAAcH5IB6mM2nyUb0ELeLvZgfOP3YYympHc3opn9nlB+JcRCCBISC1qgMspxqhCSA=="],
+
+ "preact": ["preact@10.29.4", "", {}, "sha512-GMpwh9+NJ8tSmqwIaVyFRQkiKfBEzQ+k7r7tle4W+kaJ+7wJiB9hFz9BixAomMtenPPSBfM4bZhXozGxhf0uFQ=="],
+
"prettier": ["prettier@3.8.3", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw=="],
"property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="],
"quansync": ["quansync@0.2.11", "", {}, "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA=="],
+ "query-selector-shadow-dom": ["query-selector-shadow-dom@1.0.1", "", {}, "sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw=="],
+
"queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
"range-parser": ["range-parser@1.2.0", "", {}, "sha512-kA5WQoNVo4t9lNx2kQNFCxKeBl5IbbSNBl1M/tLkw9WCn+hxNBAW5Qh8gdhs63CJnhjJ2zQWFoqPJP2sK1AV5A=="],
@@ -1385,6 +1404,8 @@
"web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="],
+ "web-vitals": ["web-vitals@5.3.0", "", {}, "sha512-q6LWsLatGYZp5VGBIOvbTj6JBV2nOmC8KvWztXBmwJcfFAzhwKwbOxhUH306XY3CcaZDUlSmSuNPBsCn0bFu+g=="],
+
"webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="],
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
diff --git a/bunfig.toml b/bunfig.toml
index 641e878f..9a45523b 100644
--- a/bunfig.toml
+++ b/bunfig.toml
@@ -1,2 +1,5 @@
[install]
linker = "isolated"
+
+[test]
+preload = ["./packages/email-sdk/test-preload.ts"]
diff --git a/packages/email-sdk/README.md b/packages/email-sdk/README.md
index 9b4d11e5..5e422fde 100644
--- a/packages/email-sdk/README.md
+++ b/packages/email-sdk/README.md
@@ -333,6 +333,26 @@ npx email-sdk send --dry-run --adapter resend --from hello@example.com --to user
The CLI can read provider credentials from environment variables or matching credential flags. Run `bunx --bun --package @opencoredev/email-sdk email-sdk adapters` for a one-off adapter list, or `npx email-sdk adapters` after installing the scoped package in a project. `--dry-run` validates the message and selected adapter field support without sending email.
+## Telemetry
+
+Email SDK collects anonymous usage analytics so we can see which adapters and CLI commands get used and how often sends succeed. The first run prints a notice with opt-out instructions.
+
+What is collected: built-in adapter names (custom adapters are reported as `custom`), CLI command names, success/failure and error codes, send duration, total recipient counts (`to` + `cc` + `bcc`), whether a message includes attachments (a boolean only, never the files themselves), whether a send used recipient variables or scheduling and which delivery path ran, SDK version, OS, Node.js version, whether the run happens in CI (and which CI provider), whether usage comes from the library or the bundled CLI, and redacted error reports — the error type, Email SDK error code, and stack traces with file paths reduced to package-relative names, with error messages scrubbed of email addresses, URLs, quoted text, long tokens, and home directories before upload — tied to a random anonymous ID stored in `~/.config/email-sdk/telemetry.json`. What is never collected: email content, subjects, addresses, headers, attachments, API keys, or any other message data.
+
+Opt out at any time with an environment variable:
+
+```bash
+export EMAIL_SDK_TELEMETRY=0 # or DO_NOT_TRACK=1
+```
+
+or per client in code:
+
+```ts
+const client = createEmailClient({ adapters: [resend({ apiKey })], telemetry: false });
+```
+
+Telemetry is also disabled automatically when `NODE_ENV=test`.
+
## Provider Reality
Email providers differ in domain verification, sandbox modes, rate limits, region settings, API scopes, and field support. Email SDK tests the normalized payloads and fail-fast validation locally, but the final live send still depends on provider account configuration.
diff --git a/packages/email-sdk/bunfig.toml b/packages/email-sdk/bunfig.toml
new file mode 100644
index 00000000..c1ecb335
--- /dev/null
+++ b/packages/email-sdk/bunfig.toml
@@ -0,0 +1,5 @@
+# Bun reads bunfig.toml from the cwd only, so this mirrors the root [test]
+# preload for `bun test` runs started inside this package. Install settings
+# live in the repo-root bunfig.toml.
+[test]
+preload = ["./test-preload.ts"]
diff --git a/packages/email-sdk/src/cli.test.ts b/packages/email-sdk/src/cli.test.ts
index 00dee12a..65ca08ce 100644
--- a/packages/email-sdk/src/cli.test.ts
+++ b/packages/email-sdk/src/cli.test.ts
@@ -176,6 +176,9 @@ async function runCli(args: string[]) {
const proc = Bun.spawn({
cmd: ["bun", "src/cli.ts", ...args],
cwd: packageRoot,
+ // NODE_ENV=test already disables telemetry; the explicit opt-out keeps these
+ // tests network-free even if env propagation changes.
+ env: { ...process.env, EMAIL_SDK_TELEMETRY: "0" },
stderr: "pipe",
stdout: "pipe",
});
diff --git a/packages/email-sdk/src/cli.ts b/packages/email-sdk/src/cli.ts
index 370dc0c6..cafb7cac 100644
--- a/packages/email-sdk/src/cli.ts
+++ b/packages/email-sdk/src/cli.ts
@@ -32,6 +32,7 @@ import type {
EmailProvider,
EmailTag,
} from "./types.js";
+import { getTelemetry, normalizeAdapterName, setTelemetrySource } from "./telemetry.js";
import {
SUPPORTED_MESSAGE_FIELDS,
arrayify,
@@ -238,10 +239,7 @@ const envFlagNames: Record = {
SMTP_HOST: "host",
};
-async function main() {
- const [command, ...args] = process.argv.slice(2);
- const flags = parseFlags(args);
-
+async function main(command: string | undefined, flags: CliFlags) {
if (!command || command === "help" || command === "--help" || command === "-h") {
printHelp();
return;
@@ -287,6 +285,7 @@ async function main() {
const provider = createProvider(providerName, flags);
const client = createEmailClient({ adapters: [provider] });
+ // setTelemetrySource("cli") at process start already tags this client's events.
const response = await client.send(message);
console.log(JSON.stringify(response, null, 2));
@@ -694,16 +693,90 @@ SMTP options:
`);
}
+class CliFailure extends Error {}
+
function fail(message: string): never {
- console.error(message);
- process.exit(1);
+ throw new CliFailure(message);
+}
+
+function normalizeCliCommand(command: string | undefined) {
+ if (!command || command === "help" || command === "--help" || command === "-h") {
+ return "help";
+ }
+
+ if (command === "version" || command === "--version" || command === "-v") {
+ return "version";
+ }
+
+ if (command === "adapters" || command === "providers") {
+ return "adapters";
+ }
+
+ if (command === "doctor" || command === "send") {
+ return command;
+ }
+
+ return "unknown";
+}
+
+async function captureCliRun(input: {
+ command: string | undefined;
+ flags: CliFlags;
+ success: boolean;
+ startedAt: number;
+ error?: unknown;
+}) {
+ const adapter = selectedAdapter(input.flags);
+ const telemetry = getTelemetry();
+
+ await telemetry.capture("cli command run", {
+ command: normalizeCliCommand(input.command),
+ adapter: adapter ? normalizeAdapterName(adapter) : undefined,
+ dry_run: truthyFlag(input.flags, "dry-run"),
+ source: "cli",
+ success: input.success,
+ duration_ms: Date.now() - input.startedAt,
+ error_code:
+ input.error instanceof EmailSdkError
+ ? input.error.code
+ : input.success
+ ? undefined
+ : "cli_error",
+ });
+
+ // Settle the fire-and-forget captures from core.ts before process.exit(1)
+ // can tear down the event loop and silently drop them.
+ await telemetry.flush();
}
+const startedAt = Date.now();
+const [cliCommand, ...cliArgs] = process.argv.slice(2);
+const cliFlags = parseFlags(cliArgs);
+
+// Tag every telemetry event from this process (client created, email sent,
+// exceptions) as CLI traffic before any client is constructed.
+setTelemetrySource("cli");
+
try {
- await main();
+ await main(cliCommand, cliFlags);
+ await captureCliRun({ command: cliCommand, flags: cliFlags, success: true, startedAt });
} catch (error) {
- if (error instanceof EmailSdkError) {
- fail(error.message);
+ if (!(error instanceof CliFailure) && !(error instanceof EmailSdkError)) {
+ // Unexpected crash, not a usage or provider failure. Reported before the run
+ // summary so captureCliRun's flush() settles it too. Errors rethrown out of
+ // client.send were already reported there; the per-object dedupe drops this one.
+ void getTelemetry().captureException(error, {
+ source: "cli",
+ handled: false,
+ command: normalizeCliCommand(cliCommand),
+ });
+ }
+
+ await captureCliRun({ command: cliCommand, flags: cliFlags, success: false, startedAt, error });
+
+ if (error instanceof CliFailure || error instanceof EmailSdkError) {
+ console.error(error.message);
+ process.exit(1);
}
throw error;
diff --git a/packages/email-sdk/src/core.test.ts b/packages/email-sdk/src/core.test.ts
index 8f2dc4a1..3000bd2c 100644
--- a/packages/email-sdk/src/core.test.ts
+++ b/packages/email-sdk/src/core.test.ts
@@ -3,6 +3,16 @@ import { describe, expect, test } from "bun:test";
import { createEmailClient } from "./core.js";
import { EmailProviderError, EmailSdkError, EmailValidationError } from "./errors.js";
import { postmark } from "./postmark.js";
+import {
+ resetTelemetry,
+ setSharedTelemetry,
+ resetTelemetrySource,
+ setTelemetrySource,
+ type CaptureExceptionContext,
+ type Telemetry,
+ type TelemetryEventName,
+ type TelemetryProperties,
+} from "./telemetry.js";
import { failingProvider, memoryProvider } from "./testing.js";
import type { EmailMessage, EmailProvider, EmailProviderContext } from "./types.js";
@@ -641,3 +651,237 @@ function recordingProvider(name: string, options: { bulk?: boolean } = {}) {
return { provider, calls };
}
+
+type CapturedEvent = { event: TelemetryEventName; properties?: TelemetryProperties };
+type CapturedException = { error: unknown; context: CaptureExceptionContext };
+
+function stubTelemetry() {
+ const events: CapturedEvent[] = [];
+ const exceptions: CapturedException[] = [];
+ const telemetry: Telemetry = {
+ enabled: true,
+ capture(event, properties) {
+ events.push({ event, properties });
+ return Promise.resolve();
+ },
+ captureException(error, context) {
+ exceptions.push({ error, context });
+ return Promise.resolve();
+ },
+ flush: () => Promise.resolve(),
+ };
+
+ return { events, exceptions, telemetry };
+}
+
+function withTelemetry(telemetry: Telemetry, run: () => Promise) {
+ setSharedTelemetry(telemetry);
+
+ return run().finally(() => resetTelemetry());
+}
+
+describe("createEmailClient telemetry", () => {
+ test("tags events with their source", async () => {
+ const { events, telemetry } = stubTelemetry();
+
+ await withTelemetry(telemetry, async () => {
+ await createEmailClient({ adapters: [memoryProvider()] }).send(message);
+ setTelemetrySource("cli");
+
+ try {
+ await createEmailClient({ adapters: [memoryProvider()] }).send(message);
+ } finally {
+ resetTelemetrySource();
+ }
+ });
+
+ const created = events.filter((item) => item.event === "client created");
+ const sent = events.filter((item) => item.event === "email sent");
+ expect(created.map((item) => item.properties?.source)).toEqual(["sdk", "cli"]);
+ expect(sent.map((item) => item.properties?.source)).toEqual(["sdk", "cli"]);
+ expect(sent[0]?.properties).toMatchObject({
+ success: true,
+ recipients: 1,
+ delivery_path: "single",
+ used_recipient_variables: false,
+ used_send_at: false,
+ });
+ });
+
+ test("reports failed sends as handled exceptions", async () => {
+ const { events, exceptions, telemetry } = stubTelemetry();
+
+ await withTelemetry(telemetry, async () => {
+ const client = createEmailClient({ adapters: [failingProvider()] });
+ await expect(client.send(message)).rejects.toBeInstanceOf(EmailProviderError);
+ });
+
+ const sent = events.filter((item) => item.event === "email sent");
+ expect(sent[0]?.properties).toMatchObject({ success: false, error_code: "provider_error" });
+ expect(exceptions).toHaveLength(1);
+ expect(exceptions[0]?.context).toMatchObject({
+ source: "sdk",
+ handled: true,
+ adapter: "custom",
+ });
+ expect(exceptions[0]?.error).toBeInstanceOf(EmailProviderError);
+ });
+
+ test("does not report usage errors as exceptions", async () => {
+ const { events, exceptions, telemetry } = stubTelemetry();
+
+ await withTelemetry(telemetry, async () => {
+ const client = createEmailClient({ adapters: [memoryProvider()] });
+ await expect(client.send(message, { adapter: "missing" })).rejects.toThrow(
+ 'Email provider "missing" is not registered.',
+ );
+ });
+
+ expect(events.filter((item) => item.event === "email sent")).toHaveLength(1);
+ expect(exceptions).toHaveLength(0);
+ });
+
+ test("marks scheduled sends", async () => {
+ const { events, telemetry } = stubTelemetry();
+
+ await withTelemetry(telemetry, async () => {
+ const client = createEmailClient({ adapters: [memoryProvider("resend")] });
+ await client.send({ ...message, sendAt: new Date("2026-07-10T12:30:00Z") });
+ });
+
+ const sent = events.filter((item) => item.event === "email sent");
+ expect(sent[0]?.properties).toMatchObject({ used_send_at: true, delivery_path: "single" });
+ });
+
+ test("counts one bulk_native send for recipientVariables on a native adapter", async () => {
+ const { events, telemetry } = stubTelemetry();
+ const { provider, calls } = recordingProvider("native", { bulk: true });
+
+ await withTelemetry(telemetry, async () => {
+ await createEmailClient({ adapters: [provider] }).send(batchMessage);
+ });
+
+ expect(calls.map((call) => call.kind)).toEqual(["sendBulk"]);
+ const sent = events.filter((item) => item.event === "email sent");
+ expect(sent).toHaveLength(1);
+ expect(sent[0]?.properties).toMatchObject({
+ success: true,
+ recipients: 2,
+ delivery_path: "bulk_native",
+ used_recipient_variables: true,
+ adapter: "custom",
+ });
+ });
+
+ test("counts one bulk_expanded send even when the client expands per recipient", async () => {
+ const { events, telemetry } = stubTelemetry();
+ const provider = memoryProvider();
+
+ await withTelemetry(telemetry, async () => {
+ await createEmailClient({ adapters: [provider] }).send(batchMessage);
+ });
+
+ // Two provider-level sends, one user-facing send() call, one event.
+ expect(provider.raw.sent).toHaveLength(2);
+ const sent = events.filter((item) => item.event === "email sent");
+ expect(sent).toHaveLength(1);
+ expect(sent[0]?.properties).toMatchObject({
+ success: true,
+ recipients: 2,
+ delivery_path: "bulk_expanded",
+ used_recipient_variables: true,
+ });
+ });
+
+ test("derives the failed delivery path from the primary adapter", async () => {
+ const { events, telemetry } = stubTelemetry();
+
+ await withTelemetry(telemetry, async () => {
+ const client = createEmailClient({ adapters: [failingProvider()] });
+ // Every expanded recipient fails, so the client aggregates the failures.
+ await expect(client.send(batchMessage)).rejects.toBeInstanceOf(EmailSdkError);
+ });
+
+ const sent = events.filter((item) => item.event === "email sent");
+ expect(sent[0]?.properties).toMatchObject({
+ success: false,
+ error_code: "all_recipients_failed",
+ delivery_path: "bulk_expanded",
+ used_recipient_variables: true,
+ });
+ });
+
+ test("summarizes sendBatch runs", async () => {
+ const { events, telemetry } = stubTelemetry();
+
+ await withTelemetry(telemetry, async () => {
+ const client = createEmailClient({ adapters: [memoryProvider()] });
+ await client.sendBatch([
+ { ...message, cc: "copy@example.com" },
+ { ...message, adapter: "missing" },
+ ]);
+ });
+
+ const batch = events.filter((item) => item.event === "email batch sent");
+ expect(batch).toHaveLength(1);
+ expect(batch[0]?.properties).toMatchObject({
+ message_count: 2,
+ succeeded: 1,
+ failed: 1,
+ recipients: 3,
+ success: false,
+ error_code: "provider_not_found",
+ source: "sdk",
+ // Both items normalize to the same adapter, so the summary is uniform.
+ adapter: "custom",
+ });
+ // Per-item events still fire through send().
+ expect(events.filter((item) => item.event === "email sent")).toHaveLength(2);
+ });
+
+ test("reports a mixed adapter when batch items differ", async () => {
+ const { events, telemetry } = stubTelemetry();
+
+ await withTelemetry(telemetry, async () => {
+ const client = createEmailClient({
+ adapters: [memoryProvider("resend"), memoryProvider("smtp")],
+ defaultAdapter: "resend",
+ });
+ await client.sendBatch([
+ { ...message, adapter: "resend" },
+ { ...message, adapter: "smtp" },
+ ]);
+ });
+
+ const batch = events.filter((item) => item.event === "email batch sent");
+ expect(batch[0]?.properties).toMatchObject({ adapter: "mixed", message_count: 2 });
+ });
+
+ test("batch adapter reflects the adapter that actually delivered", async () => {
+ const { events, telemetry } = stubTelemetry();
+
+ await withTelemetry(telemetry, async () => {
+ const client = createEmailClient({
+ adapters: [failingProvider("resend"), memoryProvider("smtp")],
+ });
+ await client.sendBatch([{ ...message, adapter: "resend", fallbackAdapters: ["smtp"] }]);
+ });
+
+ const batch = events.filter((item) => item.event === "email batch sent");
+ // Primary "resend" failed and "smtp" delivered, so the summary names smtp.
+ expect(batch[0]?.properties).toMatchObject({ adapter: "smtp", succeeded: 1, failed: 0 });
+ });
+
+ test("createEmailClient({ telemetry: false }) disables client events", async () => {
+ const { events, exceptions, telemetry } = stubTelemetry();
+
+ await withTelemetry(telemetry, async () => {
+ const client = createEmailClient({ adapters: [memoryProvider()], telemetry: false });
+ await client.send(message);
+ await client.sendBatch([message]);
+ });
+
+ expect(events).toHaveLength(0);
+ expect(exceptions).toHaveLength(0);
+ });
+});
diff --git a/packages/email-sdk/src/core.ts b/packages/email-sdk/src/core.ts
index 909da457..b8b4f901 100644
--- a/packages/email-sdk/src/core.ts
+++ b/packages/email-sdk/src/core.ts
@@ -24,6 +24,13 @@ import type {
SendOptions,
} from "./types.js";
import {
+ getTelemetry,
+ getTelemetrySource,
+ isReportableSendError,
+ normalizeAdapterName,
+} from "./telemetry.js";
+import {
+ arrayify,
assertMessage,
assertRecipientVariables,
hasRecipientVariables,
@@ -89,6 +96,17 @@ export function createEmailClient<
throw new EmailProviderNotFoundError(defaultProvider);
}
+ const telemetry = options.telemetry === false ? undefined : getTelemetry();
+ const telemetrySource = getTelemetrySource();
+
+ void telemetry?.capture("client created", {
+ adapters: [...adapters.keys()].map(normalizeAdapterName),
+ adapter_count: adapters.size,
+ plugin_count: options.plugins?.length ?? 0,
+ default_adapter: normalizeAdapterName(defaultProvider),
+ source: telemetrySource,
+ });
+
const hooks = [...pluginHooks, ...(options.hooks ? [options.hooks] : [])];
const client: EmailClient = {
adapters,
@@ -108,21 +126,78 @@ export function createEmailClient<
return client.adapter(name);
},
async send(message, sendOptions) {
- return sendWithAdapters({
- adapters,
- message,
- options: {
- hookList: hooks,
- middleware,
- retry: options.retry,
- defaultProvider,
- fallback: options.fallback,
- },
- sendOptions,
- });
+ const startedAt = Date.now();
+ // Facts are read from the caller's message, before middleware runs, so the
+ // event describes what the user asked for. Only one "email sent" event fires
+ // per send() call: expanded per-recipient fallback sends run through
+ // sendWithRetry internally and are never counted individually.
+ const usedRecipientVariables = hasRecipientVariables(message);
+ const messageFacts = {
+ recipients:
+ arrayify(message.to).length + arrayify(message.cc).length + arrayify(message.bcc).length,
+ has_attachments: (message.attachments?.length ?? 0) > 0,
+ used_recipient_variables: usedRecipientVariables,
+ used_send_at: message.sendAt !== undefined,
+ };
+
+ try {
+ const response = await sendWithAdapters({
+ adapters,
+ message,
+ options: {
+ hookList: hooks,
+ middleware,
+ retry: options.retry,
+ defaultProvider,
+ fallback: options.fallback,
+ },
+ sendOptions,
+ });
+
+ void telemetry?.capture("email sent", {
+ ...messageFacts,
+ adapter: normalizeAdapterName(response.provider),
+ delivery_path: deliveryPath(usedRecipientVariables, adapters.get(response.provider)),
+ success: true,
+ duration_ms: Date.now() - startedAt,
+ source: telemetrySource,
+ });
+
+ return response;
+ } catch (error) {
+ const failedAdapterName =
+ sendOptions?.adapter ?? sendOptions?.provider ?? defaultProvider;
+ const failedAdapter = normalizeAdapterName(failedAdapterName);
+
+ void telemetry?.capture("email sent", {
+ ...messageFacts,
+ adapter: failedAdapter,
+ // On failure the primary adapter decides the path — the one that would
+ // have delivered had the send succeeded.
+ delivery_path: deliveryPath(usedRecipientVariables, adapters.get(failedAdapterName)),
+ success: false,
+ duration_ms: Date.now() - startedAt,
+ error_code: error instanceof EmailSdkError ? error.code : "unknown",
+ source: telemetrySource,
+ });
+
+ if (telemetry && isReportableSendError(error)) {
+ void telemetry.captureException(error, {
+ source: telemetrySource,
+ handled: true,
+ adapter: failedAdapter,
+ });
+ }
+
+ throw error;
+ }
},
async sendBatch(messages, sendOptions) {
+ const startedAt = Date.now();
const results: SendBatchResult[] = [];
+ const usedAdapters = new Set();
+ let failedCount = 0;
+ let firstFailureCode: string | undefined;
for (const [index, item] of messages.entries()) {
const { adapter, provider, fallbackAdapters, fallbackProviders, ...message } = item;
@@ -142,12 +217,46 @@ export function createEmailClient<
fallbackAdapters: resolvedFallbackAdapters,
fallbackProviders: undefined,
});
+ // Record the adapter that actually delivered (fallbacks change it), so the
+ // summary matches the per-item "email sent" events.
+ usedAdapters.add(normalizeAdapterName(response.provider));
results.push({ ok: true, index, response });
} catch (error) {
+ usedAdapters.add(normalizeAdapterName(resolvedAdapter ?? defaultProvider));
+ failedCount += 1;
+ firstFailureCode ??= error instanceof EmailSdkError ? error.code : "unknown";
results.push({ ok: false, index, error });
}
}
+ // A batch may mix adapters across items; report the single one when uniform,
+ // else "mixed" (per-item adapters stay accurate on the "email sent" events).
+ const [firstAdapter, ...otherAdapters] = usedAdapters;
+ const batchAdapter =
+ firstAdapter === undefined
+ ? normalizeAdapterName(sendOptions?.adapter ?? sendOptions?.provider ?? defaultProvider)
+ : otherAdapters.length === 0
+ ? firstAdapter
+ : "mixed";
+
+ // Per-item telemetry (including failure exceptions) fires inside client.send;
+ // this summary event only describes the batch shape.
+ void telemetry?.capture("email batch sent", {
+ message_count: messages.length,
+ succeeded: results.length - failedCount,
+ failed: failedCount,
+ recipients: messages.reduce(
+ (total, item) =>
+ total + arrayify(item.to).length + arrayify(item.cc).length + arrayify(item.bcc).length,
+ 0,
+ ),
+ adapter: batchAdapter,
+ success: failedCount === 0,
+ duration_ms: Date.now() - startedAt,
+ error_code: firstFailureCode,
+ source: telemetrySource,
+ });
+
return results;
},
withAdapter(name) {
@@ -178,6 +287,27 @@ export function createEmailClient<
return client as EmailClient>;
}
+/**
+ * Telemetry label for how a send() call was (or would have been) delivered:
+ * "single" for plain messages, "bulk_native" when the adapter batches
+ * recipientVariables in one provider call, "bulk_expanded" when the client
+ * expands to one internal send per recipient.
+ */
+function deliveryPath(
+ usedRecipientVariables: boolean,
+ provider: EmailProvider | undefined,
+): "single" | "bulk_native" | "bulk_expanded" | undefined {
+ if (!usedRecipientVariables) {
+ return "single";
+ }
+
+ if (!provider) {
+ return undefined;
+ }
+
+ return provider.sendBulk ? "bulk_native" : "bulk_expanded";
+}
+
async function sendWithAdapters(input: {
adapters: Map;
message: EmailMessage;
diff --git a/packages/email-sdk/src/telemetry.test.ts b/packages/email-sdk/src/telemetry.test.ts
new file mode 100644
index 00000000..498dfae5
--- /dev/null
+++ b/packages/email-sdk/src/telemetry.test.ts
@@ -0,0 +1,529 @@
+import { describe, expect, test } from "bun:test";
+import { mkdtempSync, readFileSync } from "node:fs";
+import { homedir, tmpdir } from "node:os";
+import { join } from "node:path";
+
+import { EmailProviderError, EmailProviderNotFoundError, EmailValidationError } from "./errors.js";
+import {
+ TELEMETRY_NOTICE,
+ createTelemetry,
+ detectCiVendor,
+ isReportableSendError,
+ normalizeAdapterName,
+} from "./telemetry.js";
+
+type CapturedRequest = {
+ url: string;
+ body: {
+ api_key: string;
+ event: string;
+ distinct_id: string;
+ properties: Record;
+ };
+};
+
+function fetchCapture() {
+ const calls: CapturedRequest[] = [];
+ const fetchFn = (async (url: URL | RequestInfo, init?: RequestInit) => {
+ calls.push({ url: String(url), body: JSON.parse(String(init?.body)) });
+ return new Response("{}", { status: 200 });
+ }) as typeof fetch;
+
+ return { calls, fetchFn };
+}
+
+function tempConfigDir() {
+ return join(mkdtempSync(join(tmpdir(), "email-sdk-telemetry-")), "email-sdk");
+}
+
+describe("telemetry opt-out", () => {
+ test.each(["0", "false", "off", "OFF"])(
+ "EMAIL_SDK_TELEMETRY=%s disables capture",
+ async (value) => {
+ const { calls, fetchFn } = fetchCapture();
+ const notices: string[] = [];
+ const telemetry = createTelemetry({
+ env: { EMAIL_SDK_TELEMETRY: value },
+ fetch: fetchFn,
+ configDir: tempConfigDir(),
+ notify: (message) => notices.push(message),
+ });
+
+ expect(telemetry.enabled).toBe(false);
+ await telemetry.capture("cli command run", { command: "help" });
+ expect(calls).toHaveLength(0);
+ expect(notices).toHaveLength(0);
+ },
+ );
+
+ test.each(["1", "true"])("DO_NOT_TRACK=%s disables capture", (value) => {
+ const telemetry = createTelemetry({
+ env: { DO_NOT_TRACK: value },
+ configDir: tempConfigDir(),
+ notify: () => {},
+ });
+
+ expect(telemetry.enabled).toBe(false);
+ });
+
+ test("NODE_ENV=test disables capture", () => {
+ const telemetry = createTelemetry({
+ env: { NODE_ENV: "test" },
+ configDir: tempConfigDir(),
+ notify: () => {},
+ });
+
+ expect(telemetry.enabled).toBe(false);
+ });
+});
+
+describe("telemetry capture", () => {
+ test("posts anonymous events to PostHog with common properties", async () => {
+ const { calls, fetchFn } = fetchCapture();
+ const telemetry = createTelemetry({
+ env: {},
+ fetch: fetchFn,
+ configDir: tempConfigDir(),
+ notify: () => {},
+ sdkVersion: "1.2.3",
+ });
+
+ expect(telemetry.enabled).toBe(true);
+ await telemetry.capture("email sent", { adapter: "resend", success: true });
+
+ expect(calls).toHaveLength(1);
+ expect(calls[0]?.url).toBe("https://us.i.posthog.com/capture/");
+ expect(calls[0]?.body.api_key).toStartWith("phc_");
+ expect(calls[0]?.body.event).toBe("email sent");
+ expect(calls[0]?.body.distinct_id).toMatch(/^[0-9a-f-]{36}$/);
+ expect(calls[0]?.body.properties).toMatchObject({
+ adapter: "resend",
+ success: true,
+ sdk_version: "1.2.3",
+ platform: process.platform,
+ $process_person_profile: false,
+ });
+ });
+
+ test("keeps a stable anonymous id across instances", async () => {
+ const configDir = tempConfigDir();
+ const first = fetchCapture();
+ const second = fetchCapture();
+
+ await createTelemetry({
+ env: {},
+ fetch: first.fetchFn,
+ configDir,
+ notify: () => {},
+ }).capture("client created");
+ await createTelemetry({
+ env: {},
+ fetch: second.fetchFn,
+ configDir,
+ notify: () => {},
+ }).capture("client created");
+
+ expect(first.calls[0]?.body.distinct_id).toBe(second.calls[0]?.body.distinct_id as string);
+ });
+
+ test("flush waits for in-flight captures", async () => {
+ const calls: CapturedRequest[] = [];
+ let release: (() => void) | undefined;
+ const gate = new Promise((resolve) => {
+ release = resolve;
+ });
+ const fetchFn = (async (url: URL | RequestInfo, init?: RequestInit) => {
+ await gate;
+ calls.push({ url: String(url), body: JSON.parse(String(init?.body)) });
+ return new Response("{}", { status: 200 });
+ }) as typeof fetch;
+ const telemetry = createTelemetry({
+ env: {},
+ fetch: fetchFn,
+ configDir: tempConfigDir(),
+ notify: () => {},
+ });
+
+ void telemetry.capture("client created");
+ void telemetry.capture("email sent", { adapter: "resend", success: true });
+ expect(calls).toHaveLength(0);
+
+ release?.();
+ await telemetry.flush();
+
+ expect(calls).toHaveLength(2);
+ });
+
+ test("never throws when delivery fails", async () => {
+ const telemetry = createTelemetry({
+ env: {},
+ fetch: (() => Promise.reject(new Error("offline"))) as unknown as typeof fetch,
+ configDir: tempConfigDir(),
+ notify: () => {},
+ });
+
+ await expect(
+ telemetry.capture("cli command run", { command: "send" }),
+ ).resolves.toBeUndefined();
+ });
+});
+
+describe("telemetry notice", () => {
+ test("prints the opt-out notice once and persists the marker", () => {
+ const configDir = tempConfigDir();
+ const notices: string[] = [];
+ const options = {
+ env: {},
+ fetch: fetchCapture().fetchFn,
+ configDir,
+ notify: (message: string) => notices.push(message),
+ };
+
+ createTelemetry(options);
+ createTelemetry(options);
+
+ expect(notices).toEqual([TELEMETRY_NOTICE]);
+ expect(notices[0]).toContain("EMAIL_SDK_TELEMETRY=0");
+
+ const state = JSON.parse(readFileSync(join(configDir, "telemetry.json"), "utf8")) as {
+ noticeShown: boolean;
+ };
+ expect(state.noticeShown).toBe(true);
+ });
+});
+
+function exceptionTelemetry() {
+ const { calls, fetchFn } = fetchCapture();
+ const telemetry = createTelemetry({
+ env: {},
+ fetch: fetchFn,
+ configDir: tempConfigDir(),
+ notify: () => {},
+ });
+
+ return { calls, telemetry };
+}
+
+type ExceptionListItem = {
+ type: string;
+ value: string;
+ mechanism: { handled: boolean; type: string; synthetic: boolean };
+ stacktrace?: { type: string; frames: Array> };
+};
+
+function exceptionList(call: CapturedRequest | undefined) {
+ return (call?.body.properties.$exception_list ?? []) as ExceptionListItem[];
+}
+
+describe("telemetry exceptions", () => {
+ test("posts $exception events with sanitized raw stack frames", async () => {
+ const { calls, telemetry } = exceptionTelemetry();
+ const error = new EmailProviderError("request failed", { provider: "resend" });
+ error.stack = [
+ "EmailProviderError: request failed",
+ " at sendWithRetry (/Users/leo/projects/app/node_modules/@opencoredev/email-sdk/dist/core.js:280:13)",
+ " at processTicksAndRejections (node:internal/process/task_queues:95:5)",
+ " at async runMailer (/Users/leo/app/src/mailer.ts:42:9)",
+ ].join("\n");
+
+ await telemetry.captureException(error, {
+ source: "sdk",
+ handled: true,
+ adapter: "resend",
+ });
+
+ expect(calls).toHaveLength(1);
+ expect(calls[0]?.body.event).toBe("$exception");
+ expect(calls[0]?.body.properties).toMatchObject({
+ $exception_level: "error",
+ $exception_fingerprint: "EmailProviderError:provider_error",
+ error_name: "EmailProviderError",
+ error_code: "provider_error",
+ source: "sdk",
+ handled: true,
+ adapter: "resend",
+ $process_person_profile: false,
+ });
+
+ const [item] = exceptionList(calls[0]);
+ expect(item?.type).toBe("EmailProviderError");
+ expect(item?.mechanism).toEqual({ handled: true, type: "generic", synthetic: false });
+ expect(item?.stacktrace?.type).toBe("raw");
+
+ // Frames are Sentry-ordered: outermost call first, throw site last.
+ const frames = item?.stacktrace?.frames ?? [];
+ expect(frames).toHaveLength(3);
+ expect(frames[0]).toMatchObject({
+ platform: "node:javascript",
+ function: "async runMailer",
+ filename: "mailer.ts",
+ lineno: 42,
+ colno: 9,
+ in_app: false,
+ });
+ expect(frames[1]).toMatchObject({ filename: "node:internal/process/task_queues" });
+ expect(frames.at(-1)).toMatchObject({
+ filename: "node_modules/@opencoredev/email-sdk/dist/core.js",
+ function: "sendWithRetry",
+ in_app: true,
+ });
+ });
+
+ test("reduces project-relative frame paths to basenames", async () => {
+ const { calls, telemetry } = exceptionTelemetry();
+ const error = new Error("boom");
+ // tsx/bun running source directly emits project-relative frames.
+ error.stack = ["Error: boom", " at handler (src/emails/transactional/welcome.ts:12:3)"].join(
+ "\n",
+ );
+
+ await telemetry.captureException(error, { source: "sdk", handled: true });
+
+ const frames = exceptionList(calls[0])[0]?.stacktrace?.frames ?? [];
+ expect(frames[0]).toMatchObject({ filename: "welcome.ts", function: "handler", in_app: false });
+ });
+
+ test.each([
+ ["sent to leo@example.com today", "sent to today"],
+ ["fetch https://api.resend.com/emails?x=1 failed", "fetch failed"],
+ // Non-http connection strings (with embedded credentials) must redact whole.
+ ["connect smtp://user:s3cr3tpw@mail.example.com:587 refused", "connect refused"],
+ ['Unknown adapter "acme-internal".', 'Unknown adapter "".'],
+ ["bad value 'super secret'", "bad value ''"],
+ ["template `welcome email` missing", "template `` missing"],
+ ["key re_AbCdEfGhIjKlMnOpQrStUvWx12 rejected", "key rejected"],
+ // Base64 secrets ending in "=" padding must redact whole, not leak the tail.
+ ["auth dXNlcjpzdXBlcnNlY3JldA== bad", "auth bad"],
+ ["basic YWxhZGRpbjpvcGVuc2VzYW1l== denied", "basic denied"],
+ // Path tails after the home-dir collapse still name real files, so the
+ // remaining segments are consumed too (POSIX and Windows separators).
+ ["read /home/leo/app/.env first", "read ~ first"],
+ ["open /Users/jsmith/Documents/payroll.xlsx failed", "open ~ failed"],
+ // Another user's Windows home path (backslashes) must redact too.
+ ["open C:\\Users\\jsmith\\Documents\\payroll.xlsx failed", "open C:~ failed"],
+ // A long alphanumeric username must collapse to "~" before TOKEN_PATTERN runs,
+ // never leak as "/home/".
+ ["spawn /home/abcdefghijklmnopqrstuvwx/bin", "spawn ~"],
+ // Key=value secrets redact before TOKEN_PATTERN so short values are caught,
+ // preserving the key (and its casing) but never the value.
+ ["login failed: password=hunter2", "login failed: password="],
+ ["PWD=abc12 rejected", "PWD= rejected"],
+ ["api_key=sk_live_ab denied", "api_key= denied"],
+ ["pass=x secret=y token=z", "pass= secret= token="],
+ // Quoted secret values fall through to the quote passes instead.
+ ['password="hunter two" rejected', 'password="" rejected'],
+ ])("redacts %j", async (input, expected) => {
+ const { calls, telemetry } = exceptionTelemetry();
+ const error = new Error(input);
+ error.stack = undefined;
+
+ await telemetry.captureException(error, { source: "cli", handled: false });
+
+ expect(exceptionList(calls[0])[0]?.value).toBe(expected);
+ });
+
+ test("never throws on a hostile non-string stack", async () => {
+ const { calls, telemetry } = exceptionTelemetry();
+ const error = new Error("boom");
+ // Some Error subclasses overwrite stack with a non-string.
+ Object.defineProperty(error, "stack", { value: { frames: [] } });
+
+ await expect(
+ telemetry.captureException(error, { source: "sdk", handled: true }),
+ ).resolves.toBeUndefined();
+
+ const [item] = exceptionList(calls[0]);
+ expect(item?.value).toBe("boom");
+ expect(item?.stacktrace).toBeUndefined();
+ });
+
+ test("never throws when reading the error throws", async () => {
+ const { calls, telemetry } = exceptionTelemetry();
+ const error = new Error("trap");
+ Object.defineProperty(error, "stack", {
+ get() {
+ throw new Error("stack getter exploded");
+ },
+ });
+
+ await expect(
+ telemetry.captureException(error, { source: "sdk", handled: true }),
+ ).resolves.toBeUndefined();
+ expect(calls).toHaveLength(0);
+ });
+
+ test("replaces the current home directory and truncates long messages", async () => {
+ const { calls, telemetry } = exceptionTelemetry();
+ const error = new Error(`ENOENT ${homedir()}/mail.json ${"lorem ipsum ".repeat(40)}`);
+ error.stack = undefined;
+
+ await telemetry.captureException(error, { source: "sdk", handled: true });
+
+ const value = exceptionList(calls[0])[0]?.value ?? "";
+ expect(value).toContain("ENOENT ~");
+ expect(value).not.toContain(homedir());
+ expect(value).not.toContain("mail.json");
+ expect(value).toHaveLength(301);
+ expect(value.endsWith("…")).toBe(true);
+ });
+
+ test("walks cause chains and marks non-Error throws synthetic", async () => {
+ const { calls, telemetry } = exceptionTelemetry();
+ const root = new Error("root");
+ root.stack = undefined;
+ const middle = new Error("middle", { cause: root });
+ middle.stack = undefined;
+ const top = new Error("top", { cause: middle });
+ top.stack = undefined;
+
+ await telemetry.captureException(top, { source: "sdk", handled: true });
+ await telemetry.captureException("string failure", { source: "sdk", handled: false });
+
+ const chained = exceptionList(calls[0]);
+ expect(chained.map((item) => item.value)).toEqual(["top", "middle", "root"]);
+ expect(calls[0]?.body.properties.$exception_fingerprint).toBeUndefined();
+
+ const synthetic = exceptionList(calls[1]);
+ expect(synthetic[0]?.mechanism.synthetic).toBe(true);
+ expect(synthetic[0]?.type).toBe("Error");
+ });
+
+ test("redacts hostile error names in type, error_name, and fingerprint", async () => {
+ const { calls, telemetry } = exceptionTelemetry();
+ // Error.prototype.name is writable, so a hostile name must be redacted
+ // everywhere it surfaces: $exception_list type, error_name, fingerprint.
+ const error = new EmailProviderError("request failed", { provider: "resend" });
+ error.name = "Error for jsmith@corp.com in /Users/jsmith/app";
+ error.stack = undefined;
+
+ await telemetry.captureException(error, { source: "sdk", handled: true });
+
+ expect(calls).toHaveLength(1);
+ const payload = JSON.stringify(calls[0]?.body);
+ expect(payload).not.toContain("jsmith");
+ expect(payload).not.toContain("corp.com");
+ expect(payload).not.toContain("/Users");
+
+ const expectedName = "Error for in ~";
+ expect(exceptionList(calls[0])[0]?.type).toBe(expectedName);
+ expect(calls[0]?.body.properties.error_name).toBe(expectedName);
+ expect(calls[0]?.body.properties.$exception_fingerprint).toBe(
+ `${expectedName}:provider_error`,
+ );
+ });
+
+ test("keeps allowlisted error names verbatim and normalizes non-string names", async () => {
+ const { calls, telemetry } = exceptionTelemetry();
+
+ const builtin = new TypeError("boom");
+ builtin.stack = undefined;
+ await telemetry.captureException(builtin, { source: "sdk", handled: true });
+ expect(exceptionList(calls[0])[0]?.type).toBe("TypeError");
+
+ const hostile = new Error("boom two");
+ Object.defineProperty(hostile, "name", { value: 42 });
+ hostile.stack = undefined;
+ await telemetry.captureException(hostile, { source: "sdk", handled: true });
+ expect(exceptionList(calls[1])[0]?.type).toBe("Error");
+ });
+
+ test("dedupes by error object, error class, and process budget", async () => {
+ const { calls, telemetry } = exceptionTelemetry();
+ const error = new Error("same object");
+ error.stack = undefined;
+
+ await telemetry.captureException(error, { source: "sdk", handled: true });
+ await telemetry.captureException(error, { source: "cli", handled: false });
+ expect(calls).toHaveLength(1);
+
+ const sibling = new Error("same object");
+ sibling.stack = undefined;
+ await telemetry.captureException(sibling, { source: "sdk", handled: true });
+ expect(calls).toHaveLength(1);
+
+ for (let index = 0; index < 8; index += 1) {
+ const distinct = new Error(`distinct ${index}`);
+ distinct.stack = undefined;
+ distinct.name = `Error${index}`;
+ await telemetry.captureException(distinct, { source: "sdk", handled: true });
+ }
+
+ expect(calls).toHaveLength(5);
+ });
+
+ test("does nothing when telemetry is disabled", async () => {
+ const { calls, fetchFn } = fetchCapture();
+ const telemetry = createTelemetry({
+ env: { EMAIL_SDK_TELEMETRY: "0" },
+ fetch: fetchFn,
+ configDir: tempConfigDir(),
+ notify: () => {},
+ });
+
+ await expect(
+ telemetry.captureException(new Error("boom"), { source: "sdk", handled: true }),
+ ).resolves.toBeUndefined();
+ expect(calls).toHaveLength(0);
+ });
+});
+
+describe("isReportableSendError", () => {
+ test("excludes caller usage errors", () => {
+ expect(isReportableSendError(new EmailValidationError("bad message"))).toBe(false);
+ expect(isReportableSendError(new EmailProviderNotFoundError("acme"))).toBe(false);
+ });
+
+ test("includes provider failures and unknown throws", () => {
+ expect(isReportableSendError(new EmailProviderError("boom", {}))).toBe(true);
+ expect(isReportableSendError(new Error("boom"))).toBe(true);
+ });
+});
+
+describe("detectCiVendor", () => {
+ test.each([
+ [{ GITHUB_ACTIONS: "true" }, "github_actions"],
+ [{ GITLAB_CI: "true" }, "gitlab"],
+ [{ CIRCLECI: "true" }, "circleci"],
+ [{ JENKINS_URL: "https://ci.example.com" }, "jenkins"],
+ [{ TRAVIS: "true" }, "travis"],
+ [{ BUILDKITE: "true" }, "buildkite"],
+ // Vercel builds set CI=1, so a CI build resolves to generic...
+ [{ VERCEL: "1", CI: "1" }, "generic"],
+ [{ CI: "true" }, "generic"],
+ [{ CI: "1" }, "generic"],
+ ])("detects %o as %s", (env, vendor) => {
+ expect(detectCiVendor(env)).toBe(vendor);
+ });
+
+ test("returns undefined outside CI and stamps common properties", async () => {
+ expect(detectCiVendor({})).toBeUndefined();
+ // ...but a Vercel production serverless runtime (VERCEL=1, no CI) is not CI.
+ expect(detectCiVendor({ VERCEL: "1" })).toBeUndefined();
+
+ const { calls, fetchFn } = fetchCapture();
+ const telemetry = createTelemetry({
+ env: { GITHUB_ACTIONS: "true" },
+ fetch: fetchFn,
+ configDir: tempConfigDir(),
+ notify: () => {},
+ });
+
+ await telemetry.capture("cli command run", { command: "send" });
+ expect(calls[0]?.body.properties).toMatchObject({ ci: true, ci_vendor: "github_actions" });
+ });
+});
+
+describe("normalizeAdapterName", () => {
+ test("keeps built-in adapter names", () => {
+ expect(normalizeAdapterName("resend")).toBe("resend");
+ expect(normalizeAdapterName("smtp")).toBe("smtp");
+ });
+
+ test("masks custom adapter names", () => {
+ expect(normalizeAdapterName("acme-internal-mailer")).toBe("custom");
+ });
+
+ test("maps missing names to unknown", () => {
+ expect(normalizeAdapterName(undefined)).toBe("unknown");
+ });
+});
diff --git a/packages/email-sdk/src/telemetry.ts b/packages/email-sdk/src/telemetry.ts
new file mode 100644
index 00000000..a13a1aeb
--- /dev/null
+++ b/packages/email-sdk/src/telemetry.ts
@@ -0,0 +1,541 @@
+import { randomUUID } from "node:crypto";
+import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
+import { homedir } from "node:os";
+import { join } from "node:path";
+import { fileURLToPath } from "node:url";
+
+import { EmailProviderNotFoundError, EmailSdkError, EmailValidationError } from "./errors.js";
+import { SUPPORTED_MESSAGE_FIELDS } from "./utils.js";
+
+const POSTHOG_HOST = "https://us.i.posthog.com";
+// Public write-only project key. It can only ingest events, never read data.
+const POSTHOG_PROJECT_KEY = "phc_D62r4m5ivBr6LPCBqjKHg8GL6QTxT57LTzKrmkg5hNZS";
+const CAPTURE_TIMEOUT_MS = 3_000;
+
+const MAX_EXCEPTIONS_PER_PROCESS = 5;
+const MAX_CAUSE_CHAIN = 3;
+const MAX_STACK_FRAMES = 20;
+const MAX_MESSAGE_LENGTH = 300;
+
+export const TELEMETRY_NOTICE = `@opencoredev/email-sdk collects anonymous usage analytics: adapter names, command names, success/failure counts, and redacted error reports. Email content, addresses, and credentials are never collected. Opt out with EMAIL_SDK_TELEMETRY=0 or DO_NOT_TRACK=1. Details: https://github.com/opencoredev/email-sdk#telemetry`;
+
+export type TelemetryEventName =
+ | "client created"
+ | "email sent"
+ | "email batch sent"
+ | "cli command run";
+
+export type TelemetrySource = "sdk" | "cli";
+
+export type TelemetryProperties = Record<
+ string,
+ string | number | boolean | readonly string[] | undefined
+>;
+
+export type CaptureExceptionContext = {
+ source: TelemetrySource;
+ handled: boolean;
+ /** Pre-normalized via normalizeAdapterName. */
+ adapter?: string;
+ command?: string;
+};
+
+export type TelemetryOptions = {
+ env?: Record;
+ fetch?: typeof fetch;
+ configDir?: string;
+ notify?: (message: string) => void;
+ sdkVersion?: string;
+};
+
+export type Telemetry = {
+ readonly enabled: boolean;
+ /** Resolves once the event is delivered or dropped. Never rejects. */
+ capture(event: TelemetryEventName, properties?: TelemetryProperties): Promise;
+ /** Reports a redacted error to PostHog error tracking. Never rejects. */
+ captureException(error: unknown, context: CaptureExceptionContext): Promise;
+ /** Resolves once every in-flight capture has settled. Never rejects. */
+ flush(): Promise;
+};
+
+const KNOWN_ADAPTER_NAMES = new Set(Object.keys(SUPPORTED_MESSAGE_FIELDS));
+
+/** Maps custom adapter names to "custom" so telemetry never carries user-defined strings. */
+export function normalizeAdapterName(name: string | undefined) {
+ if (!name) {
+ return "unknown";
+ }
+
+ return KNOWN_ADAPTER_NAMES.has(name) ? name : "custom";
+}
+
+/**
+ * Usage mistakes (invalid message input, unregistered adapter names) are expected
+ * caller errors, not SDK defects, so they stay out of error reports.
+ */
+export function isReportableSendError(error: unknown) {
+ return !(error instanceof EmailValidationError) && !(error instanceof EmailProviderNotFoundError);
+}
+
+export function detectCiVendor(env: Record) {
+ if (env.GITHUB_ACTIONS) return "github_actions";
+ if (env.GITLAB_CI) return "gitlab";
+ if (env.CIRCLECI) return "circleci";
+ if (env.JENKINS_URL) return "jenkins";
+ if (env.TRAVIS) return "travis";
+ if (env.BUILDKITE) return "buildkite";
+ // Deliberately no VERCEL check: VERCEL=1 is set in production serverless
+ // runtimes too, so it would mislabel live sends as CI. Vercel builds still set
+ // CI=1 and fall through to "generic" below.
+ if (env.CI === "true" || env.CI === "1") return "generic";
+ return undefined;
+}
+
+export function createTelemetry(options: TelemetryOptions = {}): Telemetry {
+ const env = options.env ?? process.env;
+ const fetcher = options.fetch ?? fetch;
+ const notify = options.notify ?? ((message: string) => process.stderr.write(`${message}\n`));
+
+ if (isTelemetryDisabled(env)) {
+ return {
+ enabled: false,
+ capture: () => Promise.resolve(),
+ captureException: () => Promise.resolve(),
+ flush: () => Promise.resolve(),
+ };
+ }
+
+ const configDir =
+ options.configDir ?? join(env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "email-sdk");
+ const state = loadTelemetryState(configDir);
+
+ if (!state.noticeShown) {
+ notify(TELEMETRY_NOTICE);
+ persistTelemetryState(configDir, { ...state, noticeShown: true });
+ }
+
+ const ciVendor = detectCiVendor(env);
+ const commonProperties = {
+ sdk_version: options.sdkVersion ?? readSdkVersion(),
+ node_version: process.versions.node,
+ platform: process.platform,
+ arch: process.arch,
+ // Derived from ci_vendor so CI systems that don't set CI=true (Jenkins) still count.
+ ci: ciVendor !== undefined,
+ ci_vendor: ciVendor,
+ };
+
+ const pending = new Set>();
+
+ // Error reports are deduped per process: once per error object (the same error can
+ // surface in both core and CLI catch blocks), once per error class, capped overall.
+ const seenErrorObjects = new WeakSet