From 34698dab92d6007a2f6653fc8f335ac29ee0aca9 Mon Sep 17 00:00:00 2001 From: Leo Date: Fri, 12 Jun 2026 21:52:41 -0400 Subject: [PATCH 1/9] feat(telemetry): add redacted error reporting, source attribution, and CI context - captureException sends sanitized $exception events for PostHog error tracking: package-relative stack frames, scrubbed messages (emails, URLs, quoted text, tokens, home dirs), per-process dedupe and a 5-report cap - tag client created / email sent / cli command run with source (sdk|cli); the CLI reports unexpected crashes as unhandled exceptions - new email batch sent summary event alongside per-item events - detect ci_vendor (GitHub Actions, GitLab, CircleCI, Jenkins, Travis, Buildkite, Vercel, generic) and derive ci from it - disclose error reports in the notice and both READMEs; add changeset Generated-By: PostHog Code Task-Id: da0ebd32-eea6-4c6f-aa27-9eff2d00808f --- .changeset/telemetry-error-tracking.md | 5 + README.md | 2 +- packages/email-sdk/README.md | 2 +- packages/email-sdk/src/cli.test.ts | 3 + packages/email-sdk/src/cli.ts | 14 +- packages/email-sdk/src/core.test.ts | 115 ++++++++++ packages/email-sdk/src/core.ts | 52 ++++- packages/email-sdk/src/telemetry.test.ts | 224 +++++++++++++++++++- packages/email-sdk/src/telemetry.ts | 257 ++++++++++++++++++++++- packages/email-sdk/src/types.ts | 5 + 10 files changed, 662 insertions(+), 17 deletions(-) create mode 100644 .changeset/telemetry-error-tracking.md 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/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/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..e71adc4 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,110 @@ 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", + }); + // Per-item events still fire through send(). + expect(events.filter((item) => item.event === "email sent")).toHaveLength(2); + }); +}); diff --git a/packages/email-sdk/src/core.ts b/packages/email-sdk/src/core.ts index c912662..780ce08 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,40 @@ 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[] = []; + let failedCount = 0; + let firstFailureCode: string | undefined; for (const [index, item] of messages.entries()) { const { adapter, provider, fallbackAdapters, fallbackProviders, ...message } = item; @@ -178,10 +200,32 @@ export function createEmailClient< }); results.push({ ok: true, index, response }); } catch (error) { + failedCount += 1; + firstFailureCode ??= error instanceof EmailSdkError ? error.code : "unknown"; results.push({ ok: false, index, error }); } } + // 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: normalizeAdapterName( + sendOptions?.adapter ?? sendOptions?.provider ?? defaultProvider, + ), + 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..58f9fe3 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,219 @@ 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.each([ + ["sent to leo@example.com today", "sent to today"], + ["fetch https://api.resend.com/emails?x=1 failed", "fetch failed"], + ['Unknown adapter "acme-internal".', 'Unknown adapter "".'], + ["bad value 'super secret'", "bad value ''"], + ["template `welcome email` missing", "template `` missing"], + ["key re_AbCdEfGhIjKlMnOpQrStUvWx12 rejected", "key rejected"], + ["read /home/leo/app/.env first", "read ~/app/.env first"], + ])("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("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: "1" }, "vercel"], + [{ 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(); + + 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..6c7c2e8 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,26 @@ 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"; + if (env.VERCEL) return "vercel"; + 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 +98,7 @@ export function createTelemetry(options: TelemetryOptions = {}): Telemetry { return { enabled: false, capture: () => Promise.resolve(), + captureException: () => Promise.resolve(), flush: () => Promise.resolve(), }; } @@ -69,17 +112,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 +156,61 @@ 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 enqueue(deliver(event, properties)); + }, + captureException(error, context) { + if (typeof error === "object" && error !== null) { + if (seenErrorObjects.has(error)) { + return Promise.resolve(); + } + + seenErrorObjects.add(error); + } + + 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(); + } + + 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, + }; - return delivery; + 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)); }, async flush() { // Captures never reject, so waiting on the in-flight set is safe. @@ -136,6 +235,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 +258,143 @@ 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: string | undefined): ExceptionFrame[] { + if (!stack) { + 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); + } + + if (filename.startsWith("/") || /^[A-Za-z]:\//.test(filename)) { + return filename.split("/").at(-1) || filename; + } + + return filename; +} + +const EMAIL_PATTERN = /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g; +const URL_PATTERN = /https?:\/\/[^\s"'<>]+/gi; +const TOKEN_PATTERN = /\b[A-Za-z0-9+/_=-]{24,}\b/g; +const HOME_DIR_PATTERN = /\/(?:Users|home)\/[^\s/]+/g; + +/** + * Strips the values most likely to carry personal or secret data from error + * messages: addresses, URLs, quoted user input, long tokens, and home paths. + */ +function redactErrorMessage(message: string): string { + let redacted = message + .replace(EMAIL_PATTERN, "") + .replace(URL_PATTERN, "") + .replace(/"[^"]*"/g, '""') + .replace(/'[^']*'/g, "''") + .replace(/`[^`]*`/g, "``") + .replace(TOKEN_PATTERN, ""); + + const home = homedir(); + + if (home && home !== "/") { + redacted = redacted.split(home).join("~"); + } + + redacted = redacted.replace(HOME_DIR_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 Date: Fri, 12 Jun 2026 21:52:42 -0400 Subject: [PATCH 2/9] feat(docs): add PostHog web analytics and error monitoring to email-sdk.dev - posthog-js init on hydration with history-change pageviews, exception autocapture, and web vitals; session replay stays off (one-line flip) - keep Vercel Analytics alongside - allow https://*.posthog.com in the CSP script-src and connect-src - disclose PostHog and package telemetry in the privacy policy Generated-By: PostHog Code Task-Id: da0ebd32-eea6-4c6f-aa27-9eff2d00808f --- apps/fumadocs/package.json | 1 + apps/fumadocs/src/lib/posthog.ts | 25 +++++++++++++++++++++++++ apps/fumadocs/src/routes/__root.tsx | 5 +++++ apps/fumadocs/src/routes/privacy.tsx | 21 +++++++++++++++------ bun.lock | 21 +++++++++++++++++++++ vercel.json | 2 +- 6 files changed, 68 insertions(+), 7 deletions(-) create mode 100644 apps/fumadocs/src/lib/posthog.ts 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..7b70e4f --- /dev/null +++ b/apps/fumadocs/src/lib/posthog.ts @@ -0,0 +1,25 @@ +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 }, + // 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/vercel.json b/vercel.json index a2281ae..9e3ed1e 100644 --- a/vercel.json +++ b/vercel.json @@ -18,7 +18,7 @@ }, { "key": "Content-Security-Policy", - "value": "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://cdn.simpleicons.org https://www.google.com; font-src 'self' data:; connect-src 'self' https://vitals.vercel-insights.com https://*.vercel-insights.com; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; object-src 'none'" + "value": "default-src 'self'; script-src 'self' 'unsafe-inline' https://*.posthog.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://cdn.simpleicons.org https://www.google.com; font-src 'self' data:; connect-src 'self' https://vitals.vercel-insights.com https://*.vercel-insights.com https://*.posthog.com; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; object-src 'none'" } ] } From 21de3a28dc3d0598fa0663beb500fd05dc1d94de Mon Sep 17 00:00:00 2001 From: Leo Date: Fri, 12 Jun 2026 21:52:42 -0400 Subject: [PATCH 3/9] ci(release): annotate published versions in PostHog Posts a project annotation for @opencoredev/email-sdk releases so version cohorts are visible on dashboards. Skips gracefully when the POSTHOG_PERSONAL_API_KEY secret is absent and never fails the release. Generated-By: PostHog Code Task-Id: da0ebd32-eea6-4c6f-aa27-9eff2d00808f --- .github/workflows/release.yml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b4171ad..4b0fa53 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -60,6 +60,22 @@ 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)')" + 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 "{\"content\":\"@opencoredev/email-sdk v${VERSION} released\",\"date_marker\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\",\"scope\":\"project\"}" \ + || echo "::warning::PostHog release annotation failed (non-blocking)." + - name: Update Homebrew formula checksum if: steps.changesets.outputs.published == 'true' env: From 6eebed13622b9ca35ad9975dd9c18f2a464bdaec Mon Sep 17 00:00:00 2001 From: Leo Date: Fri, 12 Jun 2026 22:04:05 -0400 Subject: [PATCH 4/9] fix(telemetry): harden exception redaction and release annotation Addresses review findings on the error-tracking change: - redact home directories before token matching so a long alphanumeric username collapses to "~" instead of leaking as "/home/" - guard captureException against hostile error shapes: ignore non-string stack values and wrap the build path so a throwing stack getter can never make reporting throw - build the PostHog annotation body with jq so a crafted version string can't break out of the JSON or override the annotation scope Generated-By: PostHog Code Task-Id: da0ebd32-eea6-4c6f-aa27-9eff2d00808f --- .github/workflows/release.yml | 6 +- packages/email-sdk/src/telemetry.test.ts | 33 ++++++++ packages/email-sdk/src/telemetry.ts | 99 +++++++++++++----------- 3 files changed, 92 insertions(+), 46 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4b0fa53..0e12547 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -70,10 +70,14 @@ jobs: 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 "{\"content\":\"@opencoredev/email-sdk v${VERSION} released\",\"date_marker\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\",\"scope\":\"project\"}" \ + -d "$PAYLOAD" \ || echo "::warning::PostHog release annotation failed (non-blocking)." - name: Update Homebrew formula checksum diff --git a/packages/email-sdk/src/telemetry.test.ts b/packages/email-sdk/src/telemetry.test.ts index 58f9fe3..a37fc07 100644 --- a/packages/email-sdk/src/telemetry.test.ts +++ b/packages/email-sdk/src/telemetry.test.ts @@ -277,6 +277,9 @@ describe("telemetry exceptions", () => { ["template `welcome email` missing", "template `` missing"], ["key re_AbCdEfGhIjKlMnOpQrStUvWx12 rejected", "key rejected"], ["read /home/leo/app/.env first", "read ~/app/.env first"], + // 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); @@ -287,6 +290,36 @@ describe("telemetry exceptions", () => { 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)}`); diff --git a/packages/email-sdk/src/telemetry.ts b/packages/email-sdk/src/telemetry.ts index 6c7c2e8..47662ee 100644 --- a/packages/email-sdk/src/telemetry.ts +++ b/packages/email-sdk/src/telemetry.ts @@ -169,48 +169,54 @@ export function createTelemetry(options: TelemetryOptions = {}): Telemetry { return enqueue(deliver(event, properties)); }, captureException(error, context) { - if (typeof error === "object" && error !== null) { - if (seenErrorObjects.has(error)) { - return Promise.resolve(); + // Hostile error shapes (throwing getters, non-standard fields) must never + // turn error reporting into an error source itself. + try { + if (typeof error === "object" && error !== null) { + if (seenErrorObjects.has(error)) { + return Promise.resolve(); + } + + seenErrorObjects.add(error); } - seenErrorObjects.add(error); - } - - const exceptionList = buildExceptionList(error, context.handled); - const head = exceptionList[0]; + const exceptionList = buildExceptionList(error, context.handled); + const head = exceptionList[0]; - if (!head) { - return Promise.resolve(); - } + 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)}`; + 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(); - } + if (seenErrorClasses.has(classKey) || exceptionBudget <= 0) { + return Promise.resolve(); + } - 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, - }; + 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}`; + } - 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(); } - - return enqueue(deliver("$exception", properties)); }, async flush() { // Captures never reject, so waiting on the in-flight set is safe. @@ -309,8 +315,9 @@ function buildExceptionList(error: unknown, handled: boolean): ExceptionListItem const STACK_LINE_PATTERN = /^\s*at (?:(.*?) \()?((?:file:\/\/)?[^()]+?):(\d+):(\d+)\)?\s*$/; -function parseStackFrames(stack: string | undefined): ExceptionFrame[] { - if (!stack) { +function parseStackFrames(stack: unknown): ExceptionFrame[] { + // Error.prototype.stack is non-standard; subclasses can put anything here. + if (typeof stack !== "string") { return []; } @@ -374,21 +381,23 @@ const HOME_DIR_PATTERN = /\/(?:Users|home)\/[^\s/]+/g; * messages: addresses, URLs, quoted user input, long tokens, and home paths. */ function redactErrorMessage(message: string): string { - let redacted = message - .replace(EMAIL_PATTERN, "") - .replace(URL_PATTERN, "") - .replace(/"[^"]*"/g, '""') - .replace(/'[^']*'/g, "''") - .replace(/`[^`]*`/g, "``") - .replace(TOKEN_PATTERN, ""); - + // Home paths go first: a long alphanumeric username must collapse into "~" + // rather than being consumed by TOKEN_PATTERN and leaving "/home/". + let redacted = message; const home = homedir(); if (home && home !== "/") { redacted = redacted.split(home).join("~"); } - redacted = redacted.replace(HOME_DIR_PATTERN, "~"); + redacted = redacted + .replace(HOME_DIR_PATTERN, "~") + .replace(EMAIL_PATTERN, "") + .replace(URL_PATTERN, "") + .replace(/"[^"]*"/g, '""') + .replace(/'[^']*'/g, "''") + .replace(/`[^`]*`/g, "``") + .replace(TOKEN_PATTERN, ""); return redacted.length > MAX_MESSAGE_LENGTH ? `${redacted.slice(0, MAX_MESSAGE_LENGTH)}…` From ce53edc4a45282a7d53de246a1b6a417d0c8cfa2 Mon Sep 17 00:00:00 2001 From: Leo Date: Fri, 12 Jun 2026 22:19:17 -0400 Subject: [PATCH 5/9] fix(telemetry): anchor token redaction and tighten exception dedupe Greptile review follow-ups: - redact tokens with lookaround anchors instead of \b so base64 secrets ending in "=" padding are scrubbed whole rather than leaking the tail - mark an error object "seen" only after it passes the class and budget guards, so a bailed-out report never silently consumes a distinct error Generated-By: PostHog Code Task-Id: da0ebd32-eea6-4c6f-aa27-9eff2d00808f --- packages/email-sdk/src/telemetry.test.ts | 3 +++ packages/email-sdk/src/telemetry.ts | 18 ++++++++++++------ 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/packages/email-sdk/src/telemetry.test.ts b/packages/email-sdk/src/telemetry.test.ts index a37fc07..da8e3e0 100644 --- a/packages/email-sdk/src/telemetry.test.ts +++ b/packages/email-sdk/src/telemetry.test.ts @@ -276,6 +276,9 @@ describe("telemetry exceptions", () => { ["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"], // A long alphanumeric username must collapse to "~" before TOKEN_PATTERN runs, // never leak as "/home/". diff --git a/packages/email-sdk/src/telemetry.ts b/packages/email-sdk/src/telemetry.ts index 47662ee..4464921 100644 --- a/packages/email-sdk/src/telemetry.ts +++ b/packages/email-sdk/src/telemetry.ts @@ -172,12 +172,10 @@ export function createTelemetry(options: TelemetryOptions = {}): Telemetry { // Hostile error shapes (throwing getters, non-standard fields) must never // turn error reporting into an error source itself. try { - if (typeof error === "object" && error !== null) { - if (seenErrorObjects.has(error)) { - return Promise.resolve(); - } + const isErrorObject = typeof error === "object" && error !== null; - seenErrorObjects.add(error); + if (isErrorObject && seenErrorObjects.has(error)) { + return Promise.resolve(); } const exceptionList = buildExceptionList(error, context.handled); @@ -194,6 +192,12 @@ export function createTelemetry(options: TelemetryOptions = {}): Telemetry { 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; @@ -373,7 +377,9 @@ function sanitizeFrameFilename(raw: string): string { const EMAIL_PATTERN = /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g; const URL_PATTERN = /https?:\/\/[^\s"'<>]+/gi; -const TOKEN_PATTERN = /\b[A-Za-z0-9+/_=-]{24,}\b/g; +// 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 = /(? Date: Fri, 12 Jun 2026 22:32:53 -0400 Subject: [PATCH 6/9] fix(telemetry): redact non-http URLs, basename relative frames, mixed batch adapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Greptile review follow-ups (pass 2): - redact any scheme://… so SMTP/AMQP/DB connection strings with embedded credentials are scrubbed, and run URL redaction before email so a "scheme://user:pass@host" collapses whole - reduce project-relative stack frames (tsx/bun-from-source) to basenames so reports never leak the project directory layout; node: frames kept intact - report adapter "mixed" on email batch sent when items use different adapters instead of always showing the batch default Generated-By: PostHog Code Task-Id: da0ebd32-eea6-4c6f-aa27-9eff2d00808f --- packages/email-sdk/src/core.test.ts | 20 ++++++++++++++++++++ packages/email-sdk/src/core.ts | 16 +++++++++++++--- packages/email-sdk/src/telemetry.test.ts | 16 ++++++++++++++++ packages/email-sdk/src/telemetry.ts | 17 ++++++++++++----- 4 files changed, 61 insertions(+), 8 deletions(-) diff --git a/packages/email-sdk/src/core.test.ts b/packages/email-sdk/src/core.test.ts index e71adc4..03f537a 100644 --- a/packages/email-sdk/src/core.test.ts +++ b/packages/email-sdk/src/core.test.ts @@ -391,8 +391,28 @@ describe("createEmailClient telemetry", () => { 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 }); + }); }); diff --git a/packages/email-sdk/src/core.ts b/packages/email-sdk/src/core.ts index 780ce08..de642fa 100644 --- a/packages/email-sdk/src/core.ts +++ b/packages/email-sdk/src/core.ts @@ -177,6 +177,7 @@ export function createEmailClient< async sendBatch(messages, sendOptions) { const startedAt = Date.now(); const results: SendBatchResult[] = []; + const usedAdapters = new Set(); let failedCount = 0; let firstFailureCode: string | undefined; @@ -184,6 +185,7 @@ export function createEmailClient< const { adapter, provider, fallbackAdapters, fallbackProviders, ...message } = item; const resolvedAdapter = adapter ?? provider ?? sendOptions?.adapter ?? sendOptions?.provider; + usedAdapters.add(normalizeAdapterName(resolvedAdapter ?? defaultProvider)); const resolvedFallbackAdapters = fallbackAdapters ?? fallbackProviders ?? @@ -206,6 +208,16 @@ export function createEmailClient< } } + // 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", { @@ -217,9 +229,7 @@ export function createEmailClient< total + arrayify(item.to).length + arrayify(item.cc).length + arrayify(item.bcc).length, 0, ), - adapter: normalizeAdapterName( - sendOptions?.adapter ?? sendOptions?.provider ?? defaultProvider, - ), + adapter: batchAdapter, success: failedCount === 0, duration_ms: Date.now() - startedAt, error_code: firstFailureCode, diff --git a/packages/email-sdk/src/telemetry.test.ts b/packages/email-sdk/src/telemetry.test.ts index da8e3e0..db0cabc 100644 --- a/packages/email-sdk/src/telemetry.test.ts +++ b/packages/email-sdk/src/telemetry.test.ts @@ -269,9 +269,25 @@ describe("telemetry exceptions", () => { }); }); + 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"], diff --git a/packages/email-sdk/src/telemetry.ts b/packages/email-sdk/src/telemetry.ts index 4464921..f0795cc 100644 --- a/packages/email-sdk/src/telemetry.ts +++ b/packages/email-sdk/src/telemetry.ts @@ -368,15 +368,20 @@ function sanitizeFrameFilename(raw: string): string { return filename.slice(nodeModulesIndex); } - if (filename.startsWith("/") || /^[A-Za-z]:\//.test(filename)) { - return filename.split("/").at(-1) || filename; + // Node built-in frames (node:internal/...) carry no user data; keep them whole. + if (filename.startsWith("node:")) { + return filename; } - 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; -const URL_PATTERN = /https?:\/\/[^\s"'<>]+/gi; +// 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 = /(?") + // 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, "``") From 6f2f6bae627aaa768fb9dbd05b28556e408575df Mon Sep 17 00:00:00 2001 From: Leo Date: Fri, 12 Jun 2026 22:40:19 -0400 Subject: [PATCH 7/9] fix(telemetry): pin PostHog CSP hosts and record delivered batch adapter Greptile review follow-ups (pass 3): - pin the docs-site CSP to PostHog's actual hosts (us-assets.i.posthog.com for scripts, us.i.posthog.com + us-assets for connect) instead of the https://*.posthog.com wildcard, so only PostHog's known origins run/serve - record the adapter that actually delivered each batch item (response provider, which fallbacks change) rather than the intended primary, so the batch summary's adapter matches the per-item events Generated-By: PostHog Code Task-Id: da0ebd32-eea6-4c6f-aa27-9eff2d00808f --- packages/email-sdk/src/core.test.ts | 15 +++++++++++++++ packages/email-sdk/src/core.ts | 5 ++++- vercel.json | 2 +- 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/packages/email-sdk/src/core.test.ts b/packages/email-sdk/src/core.test.ts index 03f537a..997a4f0 100644 --- a/packages/email-sdk/src/core.test.ts +++ b/packages/email-sdk/src/core.test.ts @@ -415,4 +415,19 @@ describe("createEmailClient telemetry", () => { 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 de642fa..f9d14ee 100644 --- a/packages/email-sdk/src/core.ts +++ b/packages/email-sdk/src/core.ts @@ -185,7 +185,6 @@ export function createEmailClient< const { adapter, provider, fallbackAdapters, fallbackProviders, ...message } = item; const resolvedAdapter = adapter ?? provider ?? sendOptions?.adapter ?? sendOptions?.provider; - usedAdapters.add(normalizeAdapterName(resolvedAdapter ?? defaultProvider)); const resolvedFallbackAdapters = fallbackAdapters ?? fallbackProviders ?? @@ -200,8 +199,12 @@ 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 }); diff --git a/vercel.json b/vercel.json index 9e3ed1e..36de172 100644 --- a/vercel.json +++ b/vercel.json @@ -18,7 +18,7 @@ }, { "key": "Content-Security-Policy", - "value": "default-src 'self'; script-src 'self' 'unsafe-inline' https://*.posthog.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://cdn.simpleicons.org https://www.google.com; font-src 'self' data:; connect-src 'self' https://vitals.vercel-insights.com https://*.vercel-insights.com https://*.posthog.com; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; object-src 'none'" + "value": "default-src 'self'; script-src 'self' 'unsafe-inline' https://us-assets.i.posthog.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://cdn.simpleicons.org https://www.google.com; font-src 'self' data:; connect-src 'self' https://vitals.vercel-insights.com https://*.vercel-insights.com https://us.i.posthog.com https://us-assets.i.posthog.com; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; object-src 'none'" } ] } From 586011abceacd981515ae9785147f8d4b63850b6 Mon Sep 17 00:00:00 2001 From: Leo Date: Fri, 12 Jun 2026 22:50:11 -0400 Subject: [PATCH 8/9] fix(telemetry): keep docs visitors anonymous and redact Windows home paths Greptile review follow-ups (pass 4): - set posthog-js person_profiles: "identified_only" so anonymous docs traffic never creates person profiles, matching the SDK's server-side $process_person_profile: false and the privacy policy - extend the home-path redaction to match backslash separators so another user's Windows home path (\Users\name) is scrubbed, not just Unix paths Generated-By: PostHog Code Task-Id: da0ebd32-eea6-4c6f-aa27-9eff2d00808f --- apps/fumadocs/src/lib/posthog.ts | 3 +++ packages/email-sdk/src/telemetry.test.ts | 2 ++ packages/email-sdk/src/telemetry.ts | 5 ++++- 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/apps/fumadocs/src/lib/posthog.ts b/apps/fumadocs/src/lib/posthog.ts index 7b70e4f..55587a0 100644 --- a/apps/fumadocs/src/lib/posthog.ts +++ b/apps/fumadocs/src/lib/posthog.ts @@ -19,6 +19,9 @@ export function initPostHog() { 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/packages/email-sdk/src/telemetry.test.ts b/packages/email-sdk/src/telemetry.test.ts index db0cabc..5321847 100644 --- a/packages/email-sdk/src/telemetry.test.ts +++ b/packages/email-sdk/src/telemetry.test.ts @@ -296,6 +296,8 @@ describe("telemetry exceptions", () => { ["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"], diff --git a/packages/email-sdk/src/telemetry.ts b/packages/email-sdk/src/telemetry.ts index f0795cc..ea84256 100644 --- a/packages/email-sdk/src/telemetry.ts +++ b/packages/email-sdk/src/telemetry.ts @@ -385,7 +385,10 @@ 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 = /(? Date: Fri, 12 Jun 2026 22:59:34 -0400 Subject: [PATCH 9/9] fix(telemetry): stop tagging Vercel production runtime as CI Greptile review follow-up (pass 5): VERCEL=1 is injected into Vercel's production serverless runtime, not just CI builds, so an SDK deployed in a Vercel function would report every live send as ci=true. Drop the VERCEL heuristic; Vercel CI builds still set CI=1 and resolve to "generic", while production runtimes correctly report no CI. Generated-By: PostHog Code Task-Id: da0ebd32-eea6-4c6f-aa27-9eff2d00808f --- packages/email-sdk/src/telemetry.test.ts | 5 ++++- packages/email-sdk/src/telemetry.ts | 4 +++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/email-sdk/src/telemetry.test.ts b/packages/email-sdk/src/telemetry.test.ts index 5321847..b32b478 100644 --- a/packages/email-sdk/src/telemetry.test.ts +++ b/packages/email-sdk/src/telemetry.test.ts @@ -436,7 +436,8 @@ describe("detectCiVendor", () => { [{ JENKINS_URL: "https://ci.example.com" }, "jenkins"], [{ TRAVIS: "true" }, "travis"], [{ BUILDKITE: "true" }, "buildkite"], - [{ VERCEL: "1" }, "vercel"], + // 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) => { @@ -445,6 +446,8 @@ describe("detectCiVendor", () => { 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({ diff --git a/packages/email-sdk/src/telemetry.ts b/packages/email-sdk/src/telemetry.ts index ea84256..b9592e3 100644 --- a/packages/email-sdk/src/telemetry.ts +++ b/packages/email-sdk/src/telemetry.ts @@ -84,7 +84,9 @@ export function detectCiVendor(env: Record) { if (env.JENKINS_URL) return "jenkins"; if (env.TRAVIS) return "travis"; if (env.BUILDKITE) return "buildkite"; - if (env.VERCEL) return "vercel"; + // 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; }