From 7bbf9b9677806356957307052af4df730b783d53 Mon Sep 17 00:00:00 2001 From: "tembo[bot]" <208362400+tembo[bot]@users.noreply.github.com> Date: Fri, 12 Jun 2026 21:28:06 +0000 Subject: [PATCH 1/2] feat(telemetry): add anonymous usage analytics with opt-out and CLI notice Co-authored-by: null <> --- .changeset/anonymous-usage-telemetry.md | 5 + README.md | 20 +++ bun.lock | 2 +- packages/email-sdk/README.md | 20 +++ packages/email-sdk/src/cli.ts | 56 ++++++- packages/email-sdk/src/core.ts | 65 ++++++-- packages/email-sdk/src/telemetry.test.ts | 168 +++++++++++++++++++++ packages/email-sdk/src/telemetry.ts | 181 +++++++++++++++++++++++ packages/email-sdk/src/types.ts | 6 + 9 files changed, 505 insertions(+), 18 deletions(-) create mode 100644 .changeset/anonymous-usage-telemetry.md create mode 100644 packages/email-sdk/src/telemetry.test.ts create mode 100644 packages/email-sdk/src/telemetry.ts diff --git a/.changeset/anonymous-usage-telemetry.md b/.changeset/anonymous-usage-telemetry.md new file mode 100644 index 0000000..7185e72 --- /dev/null +++ b/.changeset/anonymous-usage-telemetry.md @@ -0,0 +1,5 @@ +--- +"@opencoredev/email-sdk": minor +--- + +Add anonymous usage telemetry to the SDK and CLI via PostHog. The client now reports `client created` (configured adapter names), `email sent` (adapter, success/failure, error code, duration, recipient count), and the CLI reports `cli command run` (command, adapter, success). No email content, addresses, headers, or credentials are ever collected, and custom adapter names are masked as `custom`. A one-time notice with opt-out instructions is printed on first use. Opt out with `EMAIL_SDK_TELEMETRY=0`, `DO_NOT_TRACK=1`, or `createEmailClient({ telemetry: false })`; telemetry is disabled automatically when `NODE_ENV=test`. diff --git a/README.md b/README.md index bbc023f..2b8a73d 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,26 @@ npx skills add opencoredev/email-sdk --skill email-sdk The skill is stored in `skills/email-sdk/SKILL.md`. It tells agents to refresh the current README, Fumadocs pages, package exports, and TypeScript declarations before implementing, so the guidance stays useful as the SDK evolves without needing every new adapter or option copied into the skill. +## Telemetry + +Email SDK collects anonymous usage analytics so we can see which adapters and CLI commands get used and how often sends succeed. The first run prints a notice with opt-out instructions. + +What is collected: built-in adapter names (custom adapters are reported as `custom`), CLI command names, success/failure and error codes, send duration, recipient counts, 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. + +Opt out at any time with an environment variable: + +```bash +export EMAIL_SDK_TELEMETRY=0 # or DO_NOT_TRACK=1 +``` + +or per client in code: + +```ts +const client = createEmailClient({ adapters: [resend({ apiKey })], telemetry: false }); +``` + +Telemetry is also disabled automatically when `NODE_ENV=test`. + ## Development ```bash diff --git a/bun.lock b/bun.lock index a0257c6..337db68 100644 --- a/bun.lock +++ b/bun.lock @@ -69,7 +69,7 @@ }, "packages/email-sdk": { "name": "@opencoredev/email-sdk", - "version": "0.6.0", + "version": "0.6.1", "bin": { "email-sdk": "dist/cli.js", }, diff --git a/packages/email-sdk/README.md b/packages/email-sdk/README.md index dbeb7f6..fc2e4e6 100644 --- a/packages/email-sdk/README.md +++ b/packages/email-sdk/README.md @@ -331,6 +331,26 @@ npx email-sdk send --dry-run --adapter resend --from hello@example.com --to user The CLI can read provider credentials from environment variables or matching credential flags. Run `bunx --bun --package @opencoredev/email-sdk email-sdk adapters` for a one-off adapter list, or `npx email-sdk adapters` after installing the scoped package in a project. `--dry-run` validates the message and selected adapter field support without sending email. +## Telemetry + +Email SDK collects anonymous usage analytics so we can see which adapters and CLI commands get used and how often sends succeed. The first run prints a notice with opt-out instructions. + +What is collected: built-in adapter names (custom adapters are reported as `custom`), CLI command names, success/failure and error codes, send duration, recipient counts, 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. + +Opt out at any time with an environment variable: + +```bash +export EMAIL_SDK_TELEMETRY=0 # or DO_NOT_TRACK=1 +``` + +or per client in code: + +```ts +const client = createEmailClient({ adapters: [resend({ apiKey })], telemetry: false }); +``` + +Telemetry is also disabled automatically when `NODE_ENV=test`. + ## Provider Reality Email providers differ in domain verification, sandbox modes, rate limits, region settings, API scopes, and field support. Email SDK tests the normalized payloads and fail-fast validation locally, but the final live send still depends on provider account configuration. diff --git a/packages/email-sdk/src/cli.ts b/packages/email-sdk/src/cli.ts index b9a4fcf..f3a7bd0 100644 --- a/packages/email-sdk/src/cli.ts +++ b/packages/email-sdk/src/cli.ts @@ -36,6 +36,7 @@ import { assertMessage, assertSupportedMessageFields, } from "./utils.js"; +import { getTelemetry, normalizeAdapterName } from "./telemetry.js"; import { assertUnosendMessage, unosend } from "./unosend.js"; import { zeptomail } from "./zeptomail.js"; @@ -669,16 +670,63 @@ SMTP options: `); } +class CliFailure extends Error {} + function fail(message: string): never { - console.error(message); - process.exit(1); + throw new CliFailure(message); } +function normalizeCliCommand(command: string | undefined) { + if (!command || command === "help" || command === "--help" || command === "-h") { + return "help"; + } + + if (command === "version" || command === "--version" || command === "-v") { + return "version"; + } + + if (command === "adapters" || command === "providers") { + return "adapters"; + } + + if (command === "doctor" || command === "send") { + return command; + } + + return "unknown"; +} + +async function captureCliRun(input: { success: boolean; startedAt: number; error?: unknown }) { + const [command, ...args] = process.argv.slice(2); + const flags = parseFlags(args); + const adapter = selectedAdapter(flags); + + await getTelemetry().capture("cli command run", { + command: normalizeCliCommand(command), + adapter: adapter ? normalizeAdapterName(adapter) : undefined, + dry_run: truthyFlag(flags, "dry-run"), + success: input.success, + duration_ms: Date.now() - input.startedAt, + error_code: + input.error instanceof EmailSdkError + ? input.error.code + : input.success + ? undefined + : "cli_error", + }); +} + +const startedAt = Date.now(); + try { await main(); + await captureCliRun({ success: true, startedAt }); } catch (error) { - if (error instanceof EmailSdkError) { - fail(error.message); + await captureCliRun({ success: false, startedAt, error }); + + if (error instanceof CliFailure || error instanceof EmailSdkError) { + console.error(error.message); + process.exit(1); } throw error; diff --git a/packages/email-sdk/src/core.ts b/packages/email-sdk/src/core.ts index 2e41f8c..deb2af9 100644 --- a/packages/email-sdk/src/core.ts +++ b/packages/email-sdk/src/core.ts @@ -22,7 +22,8 @@ import type { SendBatchResult, SendOptions, } from "./types.js"; -import { assertMessage, toProviderError } from "./utils.js"; +import { getTelemetry, normalizeAdapterName } from "./telemetry.js"; +import { arrayify, assertMessage, toProviderError } from "./utils.js"; const defaultDelay = (attempt: number) => Math.min(100 * 2 ** (attempt - 1), 2_000); @@ -83,6 +84,15 @@ export function createEmailClient< throw new EmailProviderNotFoundError(defaultProvider); } + const telemetry = options.telemetry === false ? undefined : getTelemetry(); + + void telemetry?.capture("client created", { + adapters: [...adapters.keys()].map(normalizeAdapterName), + adapter_count: adapters.size, + plugin_count: options.plugins?.length ?? 0, + default_adapter: normalizeAdapterName(defaultProvider), + }); + const hooks = [...pluginHooks, ...(options.hooks ? [options.hooks] : [])]; const client: EmailClient = { adapters, @@ -102,18 +112,47 @@ export function createEmailClient< return client.adapter(name); }, async send(message, sendOptions) { - return sendWithAdapters({ - adapters, - message, - options: { - hookList: hooks, - middleware, - retry: options.retry, - defaultProvider, - fallback: options.fallback, - }, - sendOptions, - }); + const startedAt = Date.now(); + const messageFacts = { + recipients: arrayify(message.to).length, + has_attachments: (message.attachments?.length ?? 0) > 0, + }; + + try { + const response = await sendWithAdapters({ + adapters, + message, + options: { + hookList: hooks, + middleware, + retry: options.retry, + defaultProvider, + fallback: options.fallback, + }, + sendOptions, + }); + + void telemetry?.capture("email sent", { + ...messageFacts, + adapter: normalizeAdapterName(response.provider), + success: true, + duration_ms: Date.now() - startedAt, + }); + + return response; + } catch (error) { + void telemetry?.capture("email sent", { + ...messageFacts, + adapter: normalizeAdapterName( + sendOptions?.adapter ?? sendOptions?.provider ?? defaultProvider, + ), + success: false, + duration_ms: Date.now() - startedAt, + error_code: error instanceof EmailSdkError ? error.code : "unknown", + }); + + throw error; + } }, async sendBatch(messages, sendOptions) { const results: SendBatchResult[] = []; diff --git a/packages/email-sdk/src/telemetry.test.ts b/packages/email-sdk/src/telemetry.test.ts new file mode 100644 index 0000000..0fbf613 --- /dev/null +++ b/packages/email-sdk/src/telemetry.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, test } from "bun:test"; +import { mkdtempSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { TELEMETRY_NOTICE, createTelemetry, normalizeAdapterName } from "./telemetry.js"; + +type CapturedRequest = { + url: string; + body: { + api_key: string; + event: string; + distinct_id: string; + properties: Record; + }; +}; + +function fetchCapture() { + const calls: CapturedRequest[] = []; + const fetchFn = (async (url: URL | RequestInfo, init?: RequestInit) => { + calls.push({ url: String(url), body: JSON.parse(String(init?.body)) }); + return new Response("{}", { status: 200 }); + }) as typeof fetch; + + return { calls, fetchFn }; +} + +function tempConfigDir() { + return join(mkdtempSync(join(tmpdir(), "email-sdk-telemetry-")), "email-sdk"); +} + +describe("telemetry opt-out", () => { + test.each(["0", "false", "off", "OFF"])("EMAIL_SDK_TELEMETRY=%s disables capture", async (value) => { + const { calls, fetchFn } = fetchCapture(); + const notices: string[] = []; + const telemetry = createTelemetry({ + env: { EMAIL_SDK_TELEMETRY: value }, + fetch: fetchFn, + configDir: tempConfigDir(), + notify: (message) => notices.push(message), + }); + + expect(telemetry.enabled).toBe(false); + await telemetry.capture("cli command run", { command: "help" }); + expect(calls).toHaveLength(0); + expect(notices).toHaveLength(0); + }); + + test.each(["1", "true"])("DO_NOT_TRACK=%s disables capture", (value) => { + const telemetry = createTelemetry({ + env: { DO_NOT_TRACK: value }, + configDir: tempConfigDir(), + notify: () => {}, + }); + + expect(telemetry.enabled).toBe(false); + }); + + test("NODE_ENV=test disables capture", () => { + const telemetry = createTelemetry({ + env: { NODE_ENV: "test" }, + configDir: tempConfigDir(), + notify: () => {}, + }); + + expect(telemetry.enabled).toBe(false); + }); +}); + +describe("telemetry capture", () => { + test("posts anonymous events to PostHog with common properties", async () => { + const { calls, fetchFn } = fetchCapture(); + const telemetry = createTelemetry({ + env: {}, + fetch: fetchFn, + configDir: tempConfigDir(), + notify: () => {}, + sdkVersion: "1.2.3", + }); + + expect(telemetry.enabled).toBe(true); + await telemetry.capture("email sent", { adapter: "resend", success: true }); + + expect(calls).toHaveLength(1); + expect(calls[0]?.url).toBe("https://us.i.posthog.com/capture/"); + expect(calls[0]?.body.api_key).toStartWith("phc_"); + expect(calls[0]?.body.event).toBe("email sent"); + expect(calls[0]?.body.distinct_id).toMatch(/^[0-9a-f-]{36}$/); + expect(calls[0]?.body.properties).toMatchObject({ + adapter: "resend", + success: true, + sdk_version: "1.2.3", + platform: process.platform, + $process_person_profile: false, + }); + }); + + test("keeps a stable anonymous id across instances", async () => { + const configDir = tempConfigDir(); + const first = fetchCapture(); + const second = fetchCapture(); + + await createTelemetry({ + env: {}, + fetch: first.fetchFn, + configDir, + notify: () => {}, + }).capture("client created"); + await createTelemetry({ + env: {}, + fetch: second.fetchFn, + configDir, + notify: () => {}, + }).capture("client created"); + + expect(first.calls[0]?.body.distinct_id).toBe(second.calls[0]?.body.distinct_id as string); + }); + + test("never throws when delivery fails", async () => { + const telemetry = createTelemetry({ + env: {}, + fetch: (() => Promise.reject(new Error("offline"))) as unknown as typeof fetch, + configDir: tempConfigDir(), + notify: () => {}, + }); + + await expect(telemetry.capture("cli command run", { command: "send" })).resolves.toBeUndefined(); + }); +}); + +describe("telemetry notice", () => { + test("prints the opt-out notice once and persists the marker", () => { + const configDir = tempConfigDir(); + const notices: string[] = []; + const options = { + env: {}, + fetch: fetchCapture().fetchFn, + configDir, + notify: (message: string) => notices.push(message), + }; + + createTelemetry(options); + createTelemetry(options); + + expect(notices).toEqual([TELEMETRY_NOTICE]); + expect(notices[0]).toContain("EMAIL_SDK_TELEMETRY=0"); + + const state = JSON.parse(readFileSync(join(configDir, "telemetry.json"), "utf8")) as { + noticeShown: boolean; + }; + expect(state.noticeShown).toBe(true); + }); +}); + +describe("normalizeAdapterName", () => { + test("keeps built-in adapter names", () => { + expect(normalizeAdapterName("resend")).toBe("resend"); + expect(normalizeAdapterName("smtp")).toBe("smtp"); + }); + + test("masks custom adapter names", () => { + expect(normalizeAdapterName("acme-internal-mailer")).toBe("custom"); + }); + + test("maps missing names to unknown", () => { + expect(normalizeAdapterName(undefined)).toBe("unknown"); + }); +}); diff --git a/packages/email-sdk/src/telemetry.ts b/packages/email-sdk/src/telemetry.ts new file mode 100644 index 0000000..414d74e --- /dev/null +++ b/packages/email-sdk/src/telemetry.ts @@ -0,0 +1,181 @@ +import { randomUUID } from "node:crypto"; +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { SUPPORTED_MESSAGE_FIELDS } from "./utils.js"; + +const POSTHOG_HOST = "https://us.i.posthog.com"; +// Public write-only project key. It can only ingest events, never read data. +const POSTHOG_PROJECT_KEY = "phc_D62r4m5ivBr6LPCBqjKHg8GL6QTxT57LTzKrmkg5hNZS"; +const CAPTURE_TIMEOUT_MS = 3_000; + +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`; + +export type TelemetryEventName = "client created" | "email sent" | "cli command run"; + +export type TelemetryProperties = Record< + string, + string | number | boolean | readonly string[] | undefined +>; + +export type TelemetryOptions = { + env?: Record; + fetch?: typeof fetch; + configDir?: string; + notify?: (message: string) => void; + sdkVersion?: string; +}; + +export type Telemetry = { + readonly enabled: boolean; + /** Resolves once the event is delivered or dropped. Never rejects. */ + capture(event: TelemetryEventName, properties?: TelemetryProperties): Promise; +}; + +const KNOWN_ADAPTER_NAMES = new Set(Object.keys(SUPPORTED_MESSAGE_FIELDS)); + +/** Maps custom adapter names to "custom" so telemetry never carries user-defined strings. */ +export function normalizeAdapterName(name: string | undefined) { + if (!name) { + return "unknown"; + } + + return KNOWN_ADAPTER_NAMES.has(name) ? name : "custom"; +} + +export function createTelemetry(options: TelemetryOptions = {}): Telemetry { + const env = options.env ?? process.env; + const fetcher = options.fetch ?? fetch; + const notify = options.notify ?? ((message: string) => process.stderr.write(`${message}\n`)); + + if (isTelemetryDisabled(env)) { + return { + enabled: false, + capture: () => Promise.resolve(), + }; + } + + const configDir = + options.configDir ?? join(env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "email-sdk"); + const state = loadTelemetryState(configDir); + + if (!state.noticeShown) { + notify(TELEMETRY_NOTICE); + persistTelemetryState(configDir, { ...state, noticeShown: true }); + } + + const commonProperties = { + sdk_version: options.sdkVersion ?? readSdkVersion(), + node_version: process.versions.node, + platform: process.platform, + arch: process.arch, + ci: env.CI === "true" || env.CI === "1", + }; + + return { + enabled: true, + async capture(event, properties) { + try { + const response = await fetcher(`${POSTHOG_HOST}/capture/`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + signal: captureTimeoutSignal(), + body: JSON.stringify({ + api_key: POSTHOG_PROJECT_KEY, + event, + distinct_id: state.anonymousId, + timestamp: new Date().toISOString(), + properties: { + ...commonProperties, + ...properties, + $process_person_profile: false, + }, + }), + }); + + await response.body?.cancel(); + } catch { + // Telemetry must never break sending email. + } + }, + }; +} + +let sharedTelemetry: Telemetry | undefined; + +export function getTelemetry(): Telemetry { + sharedTelemetry ??= createTelemetry(); + return sharedTelemetry; +} + +function isTelemetryDisabled(env: Record) { + const optOut = env.EMAIL_SDK_TELEMETRY?.toLowerCase(); + + if (optOut === "0" || optOut === "false" || optOut === "off") { + return true; + } + + const doNotTrack = env.DO_NOT_TRACK?.toLowerCase(); + + if (doNotTrack === "1" || doNotTrack === "true") { + return true; + } + + return env.NODE_ENV === "test"; +} + +type TelemetryState = { + anonymousId: string; + noticeShown: boolean; +}; + +function loadTelemetryState(configDir: string): TelemetryState { + try { + const parsed = JSON.parse(readFileSync(join(configDir, "telemetry.json"), "utf8")) as Partial< + TelemetryState + >; + + if (typeof parsed.anonymousId === "string" && parsed.anonymousId) { + return { + anonymousId: parsed.anonymousId, + noticeShown: parsed.noticeShown === true, + }; + } + } catch { + // Missing or unreadable state falls through to a fresh identity. + } + + const state = { anonymousId: randomUUID(), noticeShown: false }; + persistTelemetryState(configDir, state); + + return state; +} + +function persistTelemetryState(configDir: string, state: TelemetryState) { + try { + mkdirSync(configDir, { recursive: true }); + writeFileSync(join(configDir, "telemetry.json"), `${JSON.stringify(state, null, 2)}\n`); + } catch { + // Read-only environments still get telemetry with a per-process identity. + } +} + +function captureTimeoutSignal() { + return typeof AbortSignal.timeout === "function" + ? AbortSignal.timeout(CAPTURE_TIMEOUT_MS) + : undefined; +} + +function readSdkVersion() { + try { + const packageJson = JSON.parse( + readFileSync(fileURLToPath(new URL("../package.json", import.meta.url)), "utf8"), + ) as { version?: string }; + + return packageJson.version ?? "unknown"; + } catch { + return "unknown"; + } +} diff --git a/packages/email-sdk/src/types.ts b/packages/email-sdk/src/types.ts index 79dfd6b..b30a3ad 100644 --- a/packages/email-sdk/src/types.ts +++ b/packages/email-sdk/src/types.ts @@ -163,6 +163,12 @@ export type EmailClientOptions Date: Sat, 13 Jun 2026 01:18:54 +0000 Subject: [PATCH 2/2] fix(telemetry): address review feedback on disclosure, flushing, and CLI parsing - count cc and bcc recipients in the email sent event and disclose has_attachments plus the full recipient definition in both READMEs - track in-flight captures and add Telemetry.flush() so the CLI settles fire-and-forget events from core.ts before process.exit(1) - add resetTelemetry() to clear the cached shared instance - document the deliberate DO_NOT_TRACK value handling - parse process.argv once in the CLI entrypoint and pass command/flags to main() and captureCliRun() Co-authored-by: null <> --- README.md | 2 +- packages/email-sdk/README.md | 2 +- packages/email-sdk/src/cli.ts | 36 ++++++----- packages/email-sdk/src/core.ts | 3 +- packages/email-sdk/src/telemetry.test.ts | 65 ++++++++++++++----- packages/email-sdk/src/telemetry.ts | 80 ++++++++++++++++-------- 6 files changed, 129 insertions(+), 59 deletions(-) diff --git a/README.md b/README.md index 2b8a73d..a831171 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, recipient counts, 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, 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. Opt out at any time with an environment variable: diff --git a/packages/email-sdk/README.md b/packages/email-sdk/README.md index fc2e4e6..cf6b9af 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, recipient counts, 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, 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. Opt out at any time with an environment variable: diff --git a/packages/email-sdk/src/cli.ts b/packages/email-sdk/src/cli.ts index f3a7bd0..91f7073 100644 --- a/packages/email-sdk/src/cli.ts +++ b/packages/email-sdk/src/cli.ts @@ -214,10 +214,7 @@ const envFlagNames: Record = { SMTP_HOST: "host", }; -async function main() { - const [command, ...args] = process.argv.slice(2); - const flags = parseFlags(args); - +async function main(command: string | undefined, flags: CliFlags) { if (!command || command === "help" || command === "--help" || command === "-h") { printHelp(); return; @@ -696,15 +693,20 @@ function normalizeCliCommand(command: string | undefined) { return "unknown"; } -async function captureCliRun(input: { success: boolean; startedAt: number; error?: unknown }) { - const [command, ...args] = process.argv.slice(2); - const flags = parseFlags(args); - const adapter = selectedAdapter(flags); +async function captureCliRun(input: { + command: string | undefined; + flags: CliFlags; + success: boolean; + startedAt: number; + error?: unknown; +}) { + const adapter = selectedAdapter(input.flags); + const telemetry = getTelemetry(); - await getTelemetry().capture("cli command run", { - command: normalizeCliCommand(command), + await telemetry.capture("cli command run", { + command: normalizeCliCommand(input.command), adapter: adapter ? normalizeAdapterName(adapter) : undefined, - dry_run: truthyFlag(flags, "dry-run"), + dry_run: truthyFlag(input.flags, "dry-run"), success: input.success, duration_ms: Date.now() - input.startedAt, error_code: @@ -714,15 +716,21 @@ async function captureCliRun(input: { success: boolean; startedAt: number; error ? undefined : "cli_error", }); + + // Settle the fire-and-forget captures from core.ts before process.exit(1) + // can tear down the event loop and silently drop them. + await telemetry.flush(); } const startedAt = Date.now(); +const [cliCommand, ...cliArgs] = process.argv.slice(2); +const cliFlags = parseFlags(cliArgs); try { - await main(); - await captureCliRun({ success: true, startedAt }); + await main(cliCommand, cliFlags); + await captureCliRun({ command: cliCommand, flags: cliFlags, success: true, startedAt }); } catch (error) { - await captureCliRun({ success: false, startedAt, error }); + await captureCliRun({ command: cliCommand, flags: cliFlags, success: false, startedAt, error }); if (error instanceof CliFailure || error instanceof EmailSdkError) { console.error(error.message); diff --git a/packages/email-sdk/src/core.ts b/packages/email-sdk/src/core.ts index deb2af9..c912662 100644 --- a/packages/email-sdk/src/core.ts +++ b/packages/email-sdk/src/core.ts @@ -114,7 +114,8 @@ export function createEmailClient< async send(message, sendOptions) { const startedAt = Date.now(); const messageFacts = { - recipients: arrayify(message.to).length, + recipients: + arrayify(message.to).length + arrayify(message.cc).length + arrayify(message.bcc).length, has_attachments: (message.attachments?.length ?? 0) > 0, }; diff --git a/packages/email-sdk/src/telemetry.test.ts b/packages/email-sdk/src/telemetry.test.ts index 0fbf613..8ea51c1 100644 --- a/packages/email-sdk/src/telemetry.test.ts +++ b/packages/email-sdk/src/telemetry.test.ts @@ -30,21 +30,24 @@ function tempConfigDir() { } describe("telemetry opt-out", () => { - test.each(["0", "false", "off", "OFF"])("EMAIL_SDK_TELEMETRY=%s disables capture", async (value) => { - const { calls, fetchFn } = fetchCapture(); - const notices: string[] = []; - const telemetry = createTelemetry({ - env: { EMAIL_SDK_TELEMETRY: value }, - fetch: fetchFn, - configDir: tempConfigDir(), - notify: (message) => notices.push(message), - }); - - expect(telemetry.enabled).toBe(false); - await telemetry.capture("cli command run", { command: "help" }); - expect(calls).toHaveLength(0); - expect(notices).toHaveLength(0); - }); + test.each(["0", "false", "off", "OFF"])( + "EMAIL_SDK_TELEMETRY=%s disables capture", + async (value) => { + const { calls, fetchFn } = fetchCapture(); + const notices: string[] = []; + const telemetry = createTelemetry({ + env: { EMAIL_SDK_TELEMETRY: value }, + fetch: fetchFn, + configDir: tempConfigDir(), + notify: (message) => notices.push(message), + }); + + expect(telemetry.enabled).toBe(false); + await telemetry.capture("cli command run", { command: "help" }); + expect(calls).toHaveLength(0); + expect(notices).toHaveLength(0); + }, + ); test.each(["1", "true"])("DO_NOT_TRACK=%s disables capture", (value) => { const telemetry = createTelemetry({ @@ -116,6 +119,34 @@ describe("telemetry capture", () => { expect(first.calls[0]?.body.distinct_id).toBe(second.calls[0]?.body.distinct_id as string); }); + test("flush waits for in-flight captures", async () => { + const calls: CapturedRequest[] = []; + let release: (() => void) | undefined; + const gate = new Promise((resolve) => { + release = resolve; + }); + const fetchFn = (async (url: URL | RequestInfo, init?: RequestInit) => { + await gate; + calls.push({ url: String(url), body: JSON.parse(String(init?.body)) }); + return new Response("{}", { status: 200 }); + }) as typeof fetch; + const telemetry = createTelemetry({ + env: {}, + fetch: fetchFn, + configDir: tempConfigDir(), + notify: () => {}, + }); + + void telemetry.capture("client created"); + void telemetry.capture("email sent", { adapter: "resend", success: true }); + expect(calls).toHaveLength(0); + + release?.(); + await telemetry.flush(); + + expect(calls).toHaveLength(2); + }); + test("never throws when delivery fails", async () => { const telemetry = createTelemetry({ env: {}, @@ -124,7 +155,9 @@ describe("telemetry capture", () => { notify: () => {}, }); - await expect(telemetry.capture("cli command run", { command: "send" })).resolves.toBeUndefined(); + await expect( + telemetry.capture("cli command run", { command: "send" }), + ).resolves.toBeUndefined(); }); }); diff --git a/packages/email-sdk/src/telemetry.ts b/packages/email-sdk/src/telemetry.ts index 414d74e..1d28584 100644 --- a/packages/email-sdk/src/telemetry.ts +++ b/packages/email-sdk/src/telemetry.ts @@ -32,6 +32,8 @@ export type Telemetry = { readonly enabled: boolean; /** Resolves once the event is delivered or dropped. Never rejects. */ capture(event: TelemetryEventName, properties?: TelemetryProperties): Promise; + /** Resolves once every in-flight capture has settled. Never rejects. */ + flush(): Promise; }; const KNOWN_ADAPTER_NAMES = new Set(Object.keys(SUPPORTED_MESSAGE_FIELDS)); @@ -54,6 +56,7 @@ export function createTelemetry(options: TelemetryOptions = {}): Telemetry { return { enabled: false, capture: () => Promise.resolve(), + flush: () => Promise.resolve(), }; } @@ -74,31 +77,45 @@ export function createTelemetry(options: TelemetryOptions = {}): Telemetry { ci: env.CI === "true" || env.CI === "1", }; + const pending = new Set>(); + + async function deliver(event: TelemetryEventName, properties?: TelemetryProperties) { + try { + const response = await fetcher(`${POSTHOG_HOST}/capture/`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + signal: captureTimeoutSignal(), + body: JSON.stringify({ + api_key: POSTHOG_PROJECT_KEY, + event, + distinct_id: state.anonymousId, + timestamp: new Date().toISOString(), + properties: { + ...commonProperties, + ...properties, + $process_person_profile: false, + }, + }), + }); + + await response.body?.cancel(); + } catch { + // Telemetry must never break sending email. + } + } + return { enabled: true, - async capture(event, properties) { - try { - const response = await fetcher(`${POSTHOG_HOST}/capture/`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - signal: captureTimeoutSignal(), - body: JSON.stringify({ - api_key: POSTHOG_PROJECT_KEY, - event, - distinct_id: state.anonymousId, - timestamp: new Date().toISOString(), - properties: { - ...commonProperties, - ...properties, - $process_person_profile: false, - }, - }), - }); - - await response.body?.cancel(); - } catch { - // Telemetry must never break sending email. - } + capture(event, properties) { + const delivery = deliver(event, properties); + pending.add(delivery); + void delivery.finally(() => pending.delete(delivery)); + + return delivery; + }, + async flush() { + // Captures never reject, so waiting on the in-flight set is safe. + await Promise.all(pending); }, }; } @@ -110,6 +127,15 @@ export function getTelemetry(): Telemetry { return sharedTelemetry; } +/** + * Drops the cached shared instance so the next getTelemetry() call re-reads the + * environment. Lets long-running processes and tests pick up env changes made + * after the singleton was first created. + */ +export function resetTelemetry() { + sharedTelemetry = undefined; +} + function isTelemetryDisabled(env: Record) { const optOut = env.EMAIL_SDK_TELEMETRY?.toLowerCase(); @@ -117,6 +143,8 @@ function isTelemetryDisabled(env: Record) { return true; } + // Honour the standard DNT value "1" as well as the common "true" alias. + // Any other value (including "0") is treated as not opting out. const doNotTrack = env.DO_NOT_TRACK?.toLowerCase(); if (doNotTrack === "1" || doNotTrack === "true") { @@ -133,9 +161,9 @@ type TelemetryState = { function loadTelemetryState(configDir: string): TelemetryState { try { - const parsed = JSON.parse(readFileSync(join(configDir, "telemetry.json"), "utf8")) as Partial< - TelemetryState - >; + const parsed = JSON.parse( + readFileSync(join(configDir, "telemetry.json"), "utf8"), + ) as Partial; if (typeof parsed.anonymousId === "string" && parsed.anonymousId) { return {