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(); + const seenErrorClasses = new Set(); + let exceptionBudget = MAX_EXCEPTIONS_PER_PROCESS; + + async function deliver(event: string, properties?: Record) { try { const response = await fetcher(`${POSTHOG_HOST}/capture/`, { method: "POST", @@ -104,14 +158,71 @@ export function createTelemetry(options: TelemetryOptions = {}): Telemetry { } } + function enqueue(delivery: Promise) { + pending.add(delivery); + void delivery.finally(() => pending.delete(delivery)); + + return delivery; + } + return { enabled: true, capture(event, properties) { - const delivery = deliver(event, properties); - pending.add(delivery); - void delivery.finally(() => pending.delete(delivery)); - - return delivery; + return enqueue(deliver(event, properties)); + }, + captureException(error, context) { + // Hostile error shapes (throwing getters, non-standard fields) must never + // turn error reporting into an error source itself. + try { + const isErrorObject = typeof error === "object" && error !== null; + + if (isErrorObject && seenErrorObjects.has(error)) { + return Promise.resolve(); + } + + const exceptionList = buildExceptionList(error, context.handled); + const head = exceptionList[0]; + + if (!head) { + return Promise.resolve(); + } + + const errorCode = error instanceof EmailSdkError ? error.code : "unknown"; + const classKey = `${head.type}:${error instanceof EmailSdkError ? error.code : head.value.slice(0, 60)}`; + + if (seenErrorClasses.has(classKey) || exceptionBudget <= 0) { + return Promise.resolve(); + } + + // Mark the object seen only once it is actually reported, so a budget or + // class-duplicate bail-out never silently consumes a distinct error. + if (isErrorObject) { + seenErrorObjects.add(error); + } + + seenErrorClasses.add(classKey); + exceptionBudget -= 1; + + const properties: Record = { + $exception_list: exceptionList, + $exception_level: "error", + error_name: head.type, + error_code: errorCode, + source: context.source, + handled: context.handled, + adapter: context.adapter, + command: context.command, + }; + + if (error instanceof EmailSdkError) { + // Redacted messages vary; the stable name:code pair keeps issue grouping useful. + properties.$exception_fingerprint = `${head.type}:${error.code}`; + } + + return enqueue(deliver("$exception", properties)); + } catch { + return Promise.resolve(); + } }, async flush() { // Captures never reject, so waiting on the in-flight set is safe. @@ -136,6 +247,11 @@ export function resetTelemetry() { sharedTelemetry = undefined; } +/** @internal Test seam: swaps the shared singleton. Pair with resetTelemetry() to restore. */ +export function setSharedTelemetry(telemetry: Telemetry | undefined) { + sharedTelemetry = telemetry; +} + function isTelemetryDisabled(env: Record) { const optOut = env.EMAIL_SDK_TELEMETRY?.toLowerCase(); @@ -154,6 +270,158 @@ function isTelemetryDisabled(env: Record) { return env.NODE_ENV === "test"; } +type ExceptionFrame = { + platform: "node:javascript"; + function: string; + filename: string; + lineno?: number; + colno?: number; + in_app: boolean; +}; + +type ExceptionListItem = { + type: string; + value: string; + mechanism: { handled: boolean; type: "generic"; synthetic: boolean }; + stacktrace?: { type: "raw"; frames: ExceptionFrame[] }; +}; + +function buildExceptionList(error: unknown, handled: boolean): ExceptionListItem[] { + const items: ExceptionListItem[] = []; + const visited = new Set(); + let current: unknown = error; + + while ( + current !== undefined && + current !== null && + items.length < MAX_CAUSE_CHAIN && + !visited.has(current) + ) { + visited.add(current); + + const currentError = current instanceof Error ? current : undefined; + const item: ExceptionListItem = { + type: currentError ? currentError.name || "Error" : "Error", + value: redactErrorMessage(currentError ? currentError.message : String(current)), + mechanism: { handled, type: "generic", synthetic: !currentError }, + }; + + const frames = currentError ? parseStackFrames(currentError.stack) : []; + + if (frames.length > 0) { + item.stacktrace = { type: "raw", frames }; + } + + items.push(item); + current = currentError?.cause; + } + + return items; +} + +const STACK_LINE_PATTERN = /^\s*at (?:(.*?) \()?((?:file:\/\/)?[^()]+?):(\d+):(\d+)\)?\s*$/; + +function parseStackFrames(stack: unknown): ExceptionFrame[] { + // Error.prototype.stack is non-standard; subclasses can put anything here. + if (typeof stack !== "string") { + return []; + } + + const frames: ExceptionFrame[] = []; + + for (const line of stack.split("\n")) { + const match = STACK_LINE_PATTERN.exec(line); + + if (!match) { + continue; + } + + const filename = sanitizeFrameFilename(match[2] ?? ""); + + frames.push({ + platform: "node:javascript", + function: match[1]?.trim() || "", + filename, + lineno: Number(match[3]), + colno: Number(match[4]), + in_app: filename.includes("@opencoredev/email-sdk") || filename.includes("email-sdk/dist"), + }); + + if (frames.length >= MAX_STACK_FRAMES) { + break; + } + } + + // PostHog renders Sentry-style stacktraces: outermost call first, throw site last. + return frames.reverse(); +} + +/** + * Reduces stack frame paths to package-relative names (or bare basenames) so + * reports never carry usernames, home directories, or project layouts. + */ +function sanitizeFrameFilename(raw: string): string { + let filename = raw.startsWith("file://") ? raw.slice("file://".length) : raw; + filename = filename.replaceAll("\\", "/"); + + const nodeModulesIndex = filename.lastIndexOf("node_modules/"); + + if (nodeModulesIndex >= 0) { + return filename.slice(nodeModulesIndex); + } + + // Node built-in frames (node:internal/...) carry no user data; keep them whole. + if (filename.startsWith("node:")) { + return filename; + } + + // Everything else — absolute or project-relative source — collapses to its + // basename so reports never carry usernames, home dirs, or project layouts. + return filename.split("/").at(-1) || filename; +} + +const EMAIL_PATTERN = /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g; +// Any scheme://… so SMTP/AMQP/DB connection strings with embedded credentials are +// scrubbed too, not just http(s). +const URL_PATTERN = /(?]+/gi; +// Lookarounds (not \b) anchor the full token alphabet: \b sits between word and +// non-word chars, so it would skip trailing base64 padding like "==" and leak it. +const TOKEN_PATTERN = /(?". + let redacted = message; + const home = homedir(); + + if (home && home !== "/") { + redacted = redacted.split(home).join("~"); + } + + redacted = redacted + .replace(HOME_DIR_PATTERN, "~") + // URLs before emails so a "scheme://user:pass@host" is redacted whole rather + // than the email pass catching only the "pass@host" portion. + .replace(URL_PATTERN, "") + .replace(EMAIL_PATTERN, "") + .replace(/"[^"]*"/g, '""') + .replace(/'[^']*'/g, "''") + .replace(/`[^`]*`/g, "``") + .replace(TOKEN_PATTERN, ""); + + return redacted.length > MAX_MESSAGE_LENGTH + ? `${redacted.slice(0, MAX_MESSAGE_LENGTH)}…` + : redacted; +} + type TelemetryState = { anonymousId: string; noticeShown: boolean; diff --git a/packages/email-sdk/src/types.ts b/packages/email-sdk/src/types.ts index b30a3ad..2f71423 100644 --- a/packages/email-sdk/src/types.ts +++ b/packages/email-sdk/src/types.ts @@ -169,6 +169,11 @@ export type EmailClientOptions