diff --git a/.changeset/telemetry-error-tracking.md b/.changeset/telemetry-error-tracking.md
new file mode 100644
index 0000000..29e5259
--- /dev/null
+++ b/.changeset/telemetry-error-tracking.md
@@ -0,0 +1,5 @@
+---
+"@opencoredev/email-sdk": minor
+---
+
+Add redacted anonymous error reporting (PostHog error tracking) plus richer usage analytics: a `source` property distinguishing CLI runs from library usage, an `email batch sent` summary event, and CI provider detection. 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. All existing opt-outs (`EMAIL_SDK_TELEMETRY=0`, `DO_NOT_TRACK=1`, `telemetry: false`, `NODE_ENV=test`) apply unchanged.
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index b4171ad..0e12547 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 a831171..090df46 100644
--- a/README.md
+++ b/README.md
@@ -117,7 +117,7 @@ The skill is stored in `skills/email-sdk/SKILL.md`. It tells agents to refresh t
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), SDK version, OS, and Node.js version — 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.
+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), 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:
diff --git a/apps/fumadocs/package.json b/apps/fumadocs/package.json
index 5be8045..5e652d0 100644
--- a/apps/fumadocs/package.json
+++ b/apps/fumadocs/package.json
@@ -23,6 +23,7 @@
"fumadocs-mdx": "15.0.9",
"fumadocs-ui": "16.9.1",
"lucide-react": "^1.16.0",
+ "posthog-js": "^1.386.6",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"shiki": "4.1.0",
diff --git a/apps/fumadocs/src/lib/posthog.ts b/apps/fumadocs/src/lib/posthog.ts
new file mode 100644
index 0000000..55587a0
--- /dev/null
+++ b/apps/fumadocs/src/lib/posthog.ts
@@ -0,0 +1,28 @@
+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: true,
+ 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 fe5d9ff..345b572 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 16594f0..118a2ff 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 337db68..c7994a3 100644
--- a/bun.lock
+++ b/bun.lock
@@ -30,6 +30,7 @@
"fumadocs-mdx": "15.0.9",
"fumadocs-ui": "16.9.1",
"lucide-react": "^1.16.0",
+ "posthog-js": "^1.386.6",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"shiki": "4.1.0",
@@ -342,6 +343,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.32.3", "", { "dependencies": { "@posthog/types": "1.386.3" } }, "sha512-vwOEMfZvGv5XxNWV7p9I52NSmvFNMhyW2IHpIoUHW5jLkgUrknzJW1H/qxVGSIrNNVQkfsoaDFzDhJdg10pgrA=="],
+
+ "@posthog/types": ["@posthog/types@1.386.3", "", {}, "sha512-LqJoiQi2eyWn7rCUgnn+D+F3Efp6+04o72bjSX6kWHx0nFaYNC/nJuAIRliDTY/X7GPIUAaHAcSjbMI/9wfX1Q=="],
+
"@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=="],
@@ -568,6 +573,8 @@
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
+ "@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=="],
@@ -686,6 +693,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=="],
@@ -714,6 +723,8 @@
"dir-glob": ["dir-glob@3.0.1", "", { "dependencies": { "path-type": "^4.0.0" } }, "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA=="],
+ "dompurify": ["dompurify@3.4.10", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-0xzNv0e7oYC6yyuOGZIABPM4qtg3QxLFniDNPP4ZP90wR8Yq3zgwpRbrNiT4N3IKqDbbYFEJLV+JWEs19aZ//w=="],
+
"dotenv": ["dotenv@17.4.2", "", {}, "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw=="],
"eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="],
@@ -778,6 +789,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=="],
@@ -1140,12 +1153,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.386.6", "", { "dependencies": { "@posthog/core": "^1.32.3", "@posthog/types": "^1.386.3", "core-js": "^3.38.1", "dompurify": "^3.3.2", "fflate": "^0.4.8", "preact": "^10.28.2", "query-selector-shadow-dom": "^1.0.1", "web-vitals": "^5.1.0" } }, "sha512-xRcbToRtU07Ojk+VaCCos6I/zD60sNKfWxdeZih0xbncgegIqLZJmBzi4HdkSkXrf14b6HhwMI/s2GxWha5ADQ=="],
+
+ "preact": ["preact@10.29.2", "", {}, "sha512-7tNmwg/7mzzAoB/8kSg6Hl37JraAZw3Z3A0JSY7VXlZwo82Xn0G7wKbNNs2qoF4ZEEsQGTwDAroNdqKs1ofJxQ=="],
+
"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=="],
@@ -1350,6 +1369,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/packages/email-sdk/README.md b/packages/email-sdk/README.md
index cf6b9af..d94679f 100644
--- a/packages/email-sdk/README.md
+++ b/packages/email-sdk/README.md
@@ -335,7 +335,7 @@ The CLI can read provider credentials from environment variables or matching cre
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), SDK version, OS, and Node.js version — 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.
+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), 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:
diff --git a/packages/email-sdk/src/cli.test.ts b/packages/email-sdk/src/cli.test.ts
index 00dee12..65ca08c 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 91f7073..e6eeb1f 100644
--- a/packages/email-sdk/src/cli.ts
+++ b/packages/email-sdk/src/cli.ts
@@ -259,7 +259,7 @@ async function main(command: string | undefined, flags: CliFlags) {
}
const provider = createProvider(providerName, flags);
- const client = createEmailClient({ adapters: [provider] });
+ const client = createEmailClient({ adapters: [provider], telemetrySource: "cli" });
const response = await client.send(message);
console.log(JSON.stringify(response, null, 2));
@@ -707,6 +707,7 @@ async function captureCliRun(input: {
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:
@@ -730,6 +731,17 @@ try {
await main(cliCommand, cliFlags);
await captureCliRun({ command: cliCommand, flags: cliFlags, success: true, startedAt });
} catch (error) {
+ 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) {
diff --git a/packages/email-sdk/src/core.test.ts b/packages/email-sdk/src/core.test.ts
index af9ee7b..997a4f0 100644
--- a/packages/email-sdk/src/core.test.ts
+++ b/packages/email-sdk/src/core.test.ts
@@ -2,6 +2,14 @@ import { describe, expect, test } from "bun:test";
import { createEmailClient } from "./core.js";
import { EmailProviderError, EmailValidationError } from "./errors.js";
+import {
+ resetTelemetry,
+ setSharedTelemetry,
+ type CaptureExceptionContext,
+ type Telemetry,
+ type TelemetryEventName,
+ type TelemetryProperties,
+} from "./telemetry.js";
import { failingProvider, memoryProvider } from "./testing.js";
const message = {
@@ -281,3 +289,145 @@ describe("createEmailClient", () => {
});
});
});
+
+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);
+ await createEmailClient({
+ adapters: [memoryProvider()],
+ telemetrySource: "cli",
+ }).send(message);
+ });
+
+ 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 });
+ });
+
+ 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("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 });
+ });
+});
diff --git a/packages/email-sdk/src/core.ts b/packages/email-sdk/src/core.ts
index c912662..f9d14ee 100644
--- a/packages/email-sdk/src/core.ts
+++ b/packages/email-sdk/src/core.ts
@@ -22,7 +22,12 @@ import type {
SendBatchResult,
SendOptions,
} from "./types.js";
-import { getTelemetry, normalizeAdapterName } from "./telemetry.js";
+import {
+ getTelemetry,
+ isReportableSendError,
+ normalizeAdapterName,
+ type TelemetrySource,
+} from "./telemetry.js";
import { arrayify, assertMessage, toProviderError } from "./utils.js";
const defaultDelay = (attempt: number) => Math.min(100 * 2 ** (attempt - 1), 2_000);
@@ -85,12 +90,14 @@ export function createEmailClient<
}
const telemetry = options.telemetry === false ? undefined : getTelemetry();
+ const telemetrySource: TelemetrySource = options.telemetrySource === "cli" ? "cli" : "sdk";
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] : [])];
@@ -138,25 +145,41 @@ export function createEmailClient<
adapter: normalizeAdapterName(response.provider),
success: true,
duration_ms: Date.now() - startedAt,
+ source: telemetrySource,
});
return response;
} catch (error) {
+ const failedAdapter = normalizeAdapterName(
+ sendOptions?.adapter ?? sendOptions?.provider ?? defaultProvider,
+ );
+
void telemetry?.capture("email sent", {
...messageFacts,
- adapter: normalizeAdapterName(
- sendOptions?.adapter ?? sendOptions?.provider ?? defaultProvider,
- ),
+ adapter: failedAdapter,
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;
@@ -176,12 +199,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 =
+ usedAdapters.size === 0
+ ? 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) {
diff --git a/packages/email-sdk/src/telemetry.test.ts b/packages/email-sdk/src/telemetry.test.ts
index 8ea51c1..b32b478 100644
--- a/packages/email-sdk/src/telemetry.test.ts
+++ b/packages/email-sdk/src/telemetry.test.ts
@@ -1,9 +1,16 @@
import { describe, expect, test } from "bun:test";
import { mkdtempSync, readFileSync } from "node:fs";
-import { tmpdir } from "node:os";
+import { homedir, tmpdir } from "node:os";
import { join } from "node:path";
-import { TELEMETRY_NOTICE, createTelemetry, normalizeAdapterName } from "./telemetry.js";
+import { EmailProviderError, EmailProviderNotFoundError, EmailValidationError } from "./errors.js";
+import {
+ TELEMETRY_NOTICE,
+ createTelemetry,
+ detectCiVendor,
+ isReportableSendError,
+ normalizeAdapterName,
+} from "./telemetry.js";
type CapturedRequest = {
url: string;
@@ -185,6 +192,276 @@ describe("telemetry notice", () => {
});
});
+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"],
+ ["read /home/leo/app/.env first", "read ~/app/.env first"],
+ // Another user's Windows home path (backslashes) must redact too.
+ ["open C:\\Users\\bob\\secret.txt failed", "open C:~\\secret.txt failed"],
+ // A long alphanumeric username must collapse to "~" before TOKEN_PATTERN runs,
+ // never leak as "/home/".
+ ["spawn /home/abcdefghijklmnopqrstuvwx/bin", "spawn ~/bin"],
+ ])("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 ~/mail.json");
+ expect(value).not.toContain(homedir());
+ 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("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");
diff --git a/packages/email-sdk/src/telemetry.ts b/packages/email-sdk/src/telemetry.ts
index 1d28584..b9592e3 100644
--- a/packages/email-sdk/src/telemetry.ts
+++ b/packages/email-sdk/src/telemetry.ts
@@ -4,6 +4,7 @@ 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";
@@ -11,15 +12,34 @@ const POSTHOG_HOST = "https://us.i.posthog.com";
const POSTHOG_PROJECT_KEY = "phc_D62r4m5ivBr6LPCBqjKHg8GL6QTxT57LTzKrmkg5hNZS";
const CAPTURE_TIMEOUT_MS = 3_000;
-export const TELEMETRY_NOTICE = `@opencoredev/email-sdk collects anonymous usage analytics: adapter names, command names, and success/failure counts. 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`;
+const MAX_EXCEPTIONS_PER_PROCESS = 5;
+const MAX_CAUSE_CHAIN = 3;
+const MAX_STACK_FRAMES = 20;
+const MAX_MESSAGE_LENGTH = 300;
-export type TelemetryEventName = "client created" | "email sent" | "cli command run";
+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;
@@ -32,6 +52,8 @@ 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;
};
@@ -47,6 +69,28 @@ export function normalizeAdapterName(name: string | undefined) {
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;
@@ -56,6 +100,7 @@ export function createTelemetry(options: TelemetryOptions = {}): Telemetry {
return {
enabled: false,
capture: () => Promise.resolve(),
+ captureException: () => Promise.resolve(),
flush: () => Promise.resolve(),
};
}
@@ -69,17 +114,26 @@ export function createTelemetry(options: TelemetryOptions = {}): Telemetry {
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,
- ci: env.CI === "true" || env.CI === "1",
+ // 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>();
- async function deliver(event: TelemetryEventName, properties?: TelemetryProperties) {
+ // 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