Telemetry and privacy
Anonymous SDK and CLI analytics, redacted error reporting, storage, and opt-out behavior.
Core SDK and CLI telemetry are enabled by default. The first enabled run prints an opt-out notice to stderr and stores a random anonymous id plus the notice marker.
Opt out
export EMAIL_SDK_TELEMETRY=0
# or
export DO_NOT_TRACK=1const email = createEmailClient({
adapters: [adapter],
telemetry: false,
});NODE_ENV=test also disables capture. Repository tests use a Bun preload that sets EMAIL_SDK_TELEMETRY=0 for every in-process run.
When disabled, telemetry does not write config, print the notice, or make network requests.
Events
The current event names are client created, email sent, email batch sent, and cli command run.
Allowed facts include built-in adapter names, operation/command names, counts, success, durations, stable error codes, runtime and SDK versions, CI facts, and feature-presence booleans. Custom adapter names become custom.
Counting messages
email sent fires once per send() or sendPersonalized() call, not once per message, so counting events undercounts real volume. Two properties make the arithmetic exact:
| Property | Meaning |
|---|---|
message_count | Individual messages the call represents. 1 for send() no matter how many to/cc/bcc addresses share the message, one per recipient for sendPersonalized(). |
delivered_count | Messages the provider accepted. 0 on failure, and on a partially accepted personalized send it counts only the accepted recipients. |
Sum delivered_count for delivered volume and message_count for attempted volume. Each sendMany() item emits its own email sent, so email batch sent deliberately carries no delivered_count — it describes batch shape, while email sent is the volume series.
Flushing on serverless
Sends fire telemetry without awaiting it, so delivery never adds latency to an email. Serverless runtimes freeze the process the moment a response is returned, which drops those in-flight requests and undercounts exactly the platforms most transactional email ships from.
client.flush() waits for in-flight telemetry and never rejects. Await it before your handler returns, or hand it to the platform's background primitive:
export async function POST(request: Request) {
await email.send(message);
// Vercel/Cloudflare: keeps the response fast and still delivers the event.
waitUntil(email.flush());
return Response.json({ ok: true });
}Long-running servers do not need this — the process stays alive long enough for the request to finish on its own. When telemetry is disabled, flush() resolves immediately.
Error reports
Handled provider and unknown failures may produce a PostHog $exception event. Caller validation mistakes and unknown adapter names are excluded.
Error reporting limits cause depth, frames, message length, duplicate classes, and reports per process. It scrubs email addresses, URLs, quoted text, long tokens, home directories, and filesystem paths from messages and stacks.
Data that is not collected
Telemetry does not collect message bodies, subjects, addresses, headers, attachments, credentials, idempotency keys, provider response bodies, URLs, or filesystem paths.
Local state
The default state file is ~/.config/email-sdk/telemetry.json, or $XDG_CONFIG_HOME/email-sdk/telemetry.json when XDG_CONFIG_HOME is set.
Observability plugin
Configure your own application log, metric, and trace events.
