Skip to content

feat(emails): send transactional email with client.Emails - #365

Merged
Silthus merged 11 commits into
mainfrom
feat/emails-send
Nov 5, 2026
Merged

Silthus merged 11 commits into
mainfrom
feat/emails-send

Conversation

@Silthus

@Silthus Silthus commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

💡 Motivation and Context

.NET apps can't send transactional email through PostHog without hand-writing HTTP calls. They need the idempotency keys, retries and error mapping that the Node and Python SDKs already handle. This PR adds PostHogClient.Emails with the same contract as posthog-node and posthog-python (contract: PostHog/posthog#120921). There are two ways to send:

  • A template made in PostHog, with SendTemplateEmailOptions { TemplateId, TemplateData }.
  • Content your code rendered, with SendCodeEmailOptions { TemplateKey, Subject, Html and/or Text, From, ReplyTo, CaptureEngagement }. React Email users render to HTML themselves and pass Html. The SDK takes no React or rendering dependency.

GetAsync(messageId) reads the email's status.

var sent = await posthog.Emails.SendAsync(new SendTemplateEmailOptions
{
    To = "user@example.com",
    TemplateId = templateId,
    TemplateData = new Dictionary<string, object> { ["name"] = "Max" },
    DistinctId = "user_123",
    IdempotencyKey = "welcome:user_123",
});
var status = await posthog.Emails.GetAsync(sent.Id); // queued, sent, delivered, bounced, ...

Impact

This serves Grow Workflows. The metric is emails sent from code, which the workflows_emails usage line counts (API sends included) on the Workflows growth dashboard in the internal project. In scope: messaging (email). Expected move: small and upward, as one more server SDK that can send. Run cost: n/a, because these sends use the same server path as the other SDKs.

Merge risk

Warning

Medium risk, one-way door (public API). This adds PostHogClient.Emails, a new namespace PostHog.Emails, an IPostHogEmails registration in AddPostHog, and five new PostHogOptions properties. Once released, these names are API that customers compile against. The change is additive: IPostHogClient is unchanged, so custom implementers and test doubles keep compiling. Two existing paths change:

  • DisposeAsync also waits for in-flight email requests, alongside the event flush.
  • Dispose() now runs DisposeAsync on the thread pool, so a synchronous dispose on a single-threaded context (WPF, WinForms, classic ASP.NET) can no longer deadlock. SynchronousDisposeOnABlockedContextWaitsForASendWithoutDeadlocking starts a send and disposes on a context that never runs callbacks. It times out against the old Dispose and passes with the new one.

Release order. The code form (SendCodeEmailOptions) needs the server's code send, PostHog/posthog#127123 (Django) and #127122 (cdp-api), to be deployed. Until then a code send gets a 400. Don't merge or release this until code sends are live and usable for customers, which is later than the server deploy. The template form already works against production.

What it does

  • Send. POST {AppHost}/api/projects/@current/messaging/send/email/ with the flat body:

    • template form: template_id, to, message_data, distinct_id;
    • code form: template_key, to, subject, html, text, from, reply_to, capture_engagement, distinct_id.

    The request carries Authorization: Bearer <SecretKey> (a project secret API key) and an Idempotency-Key on every attempt. The SDK generates a UUID v4 key when you pass none, and reuses it on retries. With MigratedFrom set, it also sends X-PostHog-Migrated-From.

  • Status. GET {AppHost}/api/projects/@current/messaging/emails/{id}/ returns an EmailStatus with Status, CreatedAt, StatusAt and Reason.

  • App host. Cloud ingestion hosts map to their app host (us.i, us, app → us.posthog.com; eu.i, eu → eu.posthog.com). Any other HostUrl might be a reverse proxy for ingestion, which must never receive the secret key. So email requests need AppHost set there, and fail with missing_app_host before any request when it isn't. AppHost must use https, because every email request carries the secret key. An http AppHost fails with insecure_app_host before any request, unless AllowInsecureAppHost is set for local development.

  • Retries. network_error, 409 idempotency_key_in_flight, 429 (except quota_limited) and 503 are retried up to EmailRequestMaxRetries (default 2), or MaxRetries per send. The delay is 2^n s with ±20% jitter, capped at 30 s, or Retry-After when that is longer. A Retry-After over 30 s fails at once instead of blocking the caller. Each attempt times out after EmailRequestTimeout (default 15 s).

  • Errors. Invalid options throw ArgumentException before any request. Every request or SDK failure throws EmailException with:

    • Code: the server's or SDK's code, such as idempotency_key_reused or missing_app_host;
    • Name: the cross-SDK category: validation_error, invalid_permission, not_found, concurrent_idempotent_requests, invalid_idempotent_request, rate_limit_exceeded, monthly_quota_exceeded, service_unavailable, missing_api_key or application_error;
    • StatusCode, Why, Fix, DocsUrl, Field and RetryAfter.
  • Where it lives. Without DI, it's new PostHogClient(options).Emails. With DI, AddPostHog() registers IPostHogEmails, so a service injects it directly. IPostHogClient gets no new member.

  • Lifecycle. A disabled client throws client_disabled, and a disposed one throws client_closed. Dispose waits for in-flight sends, including ones in backoff, before the HTTP client goes away.

  • Warnings. The server's warnings come back on SendEmailResult.Warnings, and each one is logged at Warning level.

Divergence from sdk-specs. openspec/specs/emails/spec.md in PostHog/sdk-specs covers only the template form. The code form follows posthog-node (PostHog/posthog-js#5509) and posthog-python (PostHog/posthog-python#1171), which add it. Option names are .NET style (TemplateData, ReplyTo, EmailRequestMaxRetries). The wire names match the other SDKs.

💚 How did you test it?

Everything below ran at head 3fc597c. The live runs use a local http Django, so the probe sets AllowInsecureAppHost.

  • Live run against the real server code send. The run used feat(messaging): send an email with inline html from code posthog#127123 (Django, 51409d9b86b), #127122 (cdp-api and email workers, b5889c9eeca), a mail catcher, and a recording proxy in front of Django. A console app drove this branch's PostHogClient. Every case matched:

    Case Result
    Template send 202. The replay with the same key returns Replayed = true and the same ID, and one email lands.
    Code send (Html, Text, From, ReplyTo, CaptureEngagement = false) 202. The caught mail has the expected subject, from, reply-to, HTML and text.
    GetAsync on both IDs sent
    Same key, different body 422 idempotency_key_reused → invalid_idempotent_request
    Unknown template 404 not_found
    Unverified From 400 sender_not_verified → validation_error
    Bad secret key 401 → missing_api_key
    Unknown message ID 404 not_found
    Custom HostUrl without AppHost missing_app_host, and no request is sent
    http AppHost without AllowInsecureAppHost insecure_app_host on send and status read, and no request is sent

    Django received User-Agent: posthog-dotnet/2.18.1 (...) and the Idempotency-Key. The storage readback found the queued Cyclotron jobs, the email_status rows, and the idempotency keys in Valkey. A tenth case resolved IPostHogEmails from AddPostHog (with the PostHog config section bound), sent through it, and got the same instance as PostHogClient.Emails. A synchronous provider.Dispose() afterwards returned at once.

  • Cross-version: new SDK against today's master server (Django b8095bf610d, no code send). The template send, replay, status read and every error case behave as above. The code send is refused with 400 required on template_id, which maps to validation_error. No mail goes out and no job is queued. This is the accepted pre-deploy behavior, and the reason for the release order above.

  • Server-side observations, not SDK defects.

    • Right after a 202, the status route answers 404 for about 4–10 s, until the first status row lands. The first status the SDK sees is sent.
    • A bad phs_ key gets a 401 whose detail mentions a personal API key.
  • CI is green on 3fc597c: build, format, tests on netcoreapp3.1 and net8.0, packaging, the SDK compliance tests, CodeQL and semgrep. The first head, 0557879, had one failing test: a timing-dependent assertion in the shutdown test that review round 1 also flagged. It is removed.

  • Lane gate on 3fc597c, with the CI steps run locally: dotnet restore --locked-mode, bin/fmt --check, dotnet build -c Release, dotnet test -c Release. Exit 0.

  • Test seams. tests/UnitTests/Emails/EmailsTests.cs drives a real PostHogClient through its public Emails API. Only the edges are fake: a recording HttpMessageHandler, a TimeProvider that records and can hold backoff delays, and a fake logger. The tests cover:

    • exact request bodies and headers;
    • the idempotency key reused on retry;
    • backoff ranges and Retry-After;
    • the retry policy for each status code, and the Code/Name mapping;
    • timeouts, cancellation, and the app host table;
    • secret key handling;
    • disabled and closed clients, and dispose waiting for a send in backoff, both async and as a synchronous Dispose() on a blocked synchronization context;
    • AddPostHog resolving IPostHogEmails as the client's Emails;
    • status reads;
    • read-only TemplateData, including non-string keys.

    tests/UnitTests/Json/ReadOnlyDictionaryJsonConverterTests.cs covers the dictionary converter fix, the one shared-code change: writing a read-only dictionary used to recurse forever.

📝 Checklist

If releasing new changes

  • Ran pnpm changeset to generate a changeset file (.changeset/emails-send.md, minor)

🤖 Agent context

Autonomy: Human-driven (agent-assisted)

Built by Claude Opus 5.5 (claude-opus-5-5, high effort) in Claude Code, directed by Michael as one slice of the transactional-email-from-code work. Reviewed by a cross-model panel: GPT-6.1 Sol on correctness, risk, design and anti-overengineering, Claude Opus 5.5 on fresh eyes, then 7 fixes rounds on GPT-6.1 Sol. Every valid finding was fixed. The fixes were the read-only dictionary serialization (3 rounds of edge cases), keeping IPostHogClient unchanged (the proof judge showed that a default interface member still breaks netstandard2.0 implementers), running the email drain alongside the event flush, a deadlock-free synchronous Dispose(), ConfigureAwait(false), and https for AppHost (from a security review thread). Rejected: a client-side phs_ prefix check (neither reference SDK has one), and prod-shape and shadow-diff proofs (the SDK reads no stored data, and the change is additive).

Question Answer Who Where
Which send forms? A template made in PostHog, or rendered HTML/text by template_key. No local templates, content hash or upload 👤 Human contract PostHog/posthog#120921
React Email? The user renders it and passes Html. No React dependency in the SDK 👤 Human contract PostHog/posthog#120921
Merge or release now? No: hold until the server's code send deploys 👤 Human merge risk above
Error channel ArgumentException for invalid options, one EmailException with Code and Name for everything else 🤖 Agent comment
Template ID type Guid TemplateId. The message ID stays a string, because the status read takes what send returned 🤖 Agent comment
Result types Sealed classes instead of records, so the public API stays small 🤖 Agent comment
Personal API key as SecretKey Sent as given, with no phs_ check, matching posthog-node and posthog-python. The server checks the key's scopes 🤖 Agent comment
Static PostHogSdk facade Not extended. Emails go through the client instance only 🤖 Agent comment
Where Emails lives On PostHogClient, plus IPostHogEmails in DI. Not on IPostHogClient, because netstandard2.0 has no default interface members and a new member there breaks implementers. This replaced the EvaluateFlagsAsync-style default member from round 1 🤖 Agent comment
Dispose order The email drain runs alongside the event flush 🤖 Agent comment
Synchronous dispose Dispose() runs DisposeAsync on the thread pool, so a single-threaded context can't deadlock 🤖 Agent comment
http AppHost Refused with insecure_app_host. Plain http needs the explicit AllowInsecureAppHost opt-in for local development 👤 Human thread

Involvement: no grilling rounds, no prototypes, 8 review rounds (one 5-lens panel and 7 fixes reads), and live-run plus cross-version proof captured.

🤖 Generated with Claude Code

Add IPostHogClient.Emails, a port of posthog-node's and posthog-python's
emails API. SendAsync sends a template made in PostHog by its ID, or
content the app rendered, named by a template key, as the flat body of
POST /api/projects/@current/messaging/send/email/. GetAsync reads an
email's status. Every attempt of a send carries the same idempotency key.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Silthus Silthus self-assigned this Nov 2, 2026

@posthog posthog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PostHog Review · Standard · reviewed 0557879: 2 Must fix, 1 Should fix. This pull request is large, so the review ran in 4 parts with less depth than usual.

This is the only automatic review while this pull request is a draft. Comment @posthog review to review new changes sooner. Otherwise, a review of the new changes runs when you mark it ready for review.

See this review and its findings in PostHog Review. Comment @posthog review --deep for a Deep review. A Deep review takes longer and looks deeper than a Standard review.

Comment thread src/PostHog/Emails/EmailSender.cs Resolved
Comment thread src/PostHog/IPostHogClient.cs Resolved
Comment thread src/PostHog/Emails/EmailSender.cs Resolved
@github-actions

github-actions Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

posthog-dotnet Compliance Report

Date: 2026-11-05T11:03:43.230835+00:00
Duration: 124723ms

✅ All Tests Passed!

47/47 tests passed


Capture Tests

✅ 30/30 tests passed

View Details
Test Status Duration
Format Validation.Event Has Required Fields ✅ 208ms
Format Validation.Event Has Uuid ✅ 120ms
Format Validation.Event Has Lib Properties ✅ 109ms
Format Validation.Distinct Id Is String ✅ 114ms
Format Validation.Token Is Present ✅ 109ms
Format Validation.Custom Properties Preserved ✅ 114ms
Format Validation.Event Has Timestamp ✅ 109ms
Format Validation.Non Utc Event Timestamp Is Converted To Utc ✅ 120ms
Retry Behavior.Retries On 503 ✅ 8122ms
Retry Behavior.Does Not Retry On 400 ✅ 2117ms
Retry Behavior.Does Not Retry On 401 ✅ 2113ms
Retry Behavior.Respects Retry After Header ✅ 8119ms
Retry Behavior.Implements Backoff ✅ 22133ms
Retry Behavior.Retries On 500 ✅ 6118ms
Retry Behavior.Retries On 502 ✅ 6118ms
Retry Behavior.Retries On 504 ✅ 6117ms
Retry Behavior.Max Retries Respected ✅ 22136ms
Deduplication.Generates Unique Uuids ✅ 115ms
Deduplication.Preserves Uuid On Retry ✅ 6116ms
Deduplication.Preserves Uuid And Timestamp On Retry ✅ 13122ms
Deduplication.Preserves Uuid And Timestamp On Batch Retry ✅ 6121ms
Deduplication.No Duplicate Events In Batch ✅ 114ms
Deduplication.Different Events Have Different Uuids ✅ 110ms
Compression.Sends Gzip When Enabled ✅ 108ms
Batch Format.Uses Proper Batch Structure ✅ 108ms
Batch Format.Flush With No Events Sends Nothing ✅ 105ms
Batch Format.Multiple Events Batched Together ✅ 111ms
Error Handling.Does Not Retry On 403 ✅ 2109ms
Error Handling.Does Not Retry On 413 ✅ 2111ms
Error Handling.Retries On 408 ✅ 6116ms

Feature_Flags Tests

✅ 17/17 tests passed

View Details
Test Status Duration
Request Payload.Request With Person Properties Device Id ✅ 134ms
Request Payload.Flags Request Uses V2 Query Param ✅ 107ms
Request Payload.Flags Request Hits Flags Path Not Decide ✅ 107ms
Request Payload.Flags Request Omits Authorization Header ✅ 108ms
Request Payload.Token In Flags Body Matches Init ✅ 108ms
Request Payload.Groups Round Trip ✅ 108ms
Request Payload.Groups Default To Empty Object ✅ 107ms
Request Payload.Disable Geoip False Propagates As Geoip Disable False ✅ 107ms
Request Payload.Disable Geoip Omitted Defaults To False ✅ 107ms
Request Payload.Flag Keys To Evaluate Contains Only Requested Key ✅ 106ms
Request Lifecycle.No Flags Request On Init Alone ✅ 2ms
Request Lifecycle.No Flags Request On Normal Capture ✅ 106ms
Request Lifecycle.Two Flag Calls Produce Two Remote Requests ✅ 210ms
Request Lifecycle.Mock Response Value Is Returned To Caller ✅ 110ms
Retry Behavior.Retries Flags On 502 ✅ 1112ms
Retry Behavior.Retries Flags On 504 ✅ 1111ms
Side Effect Events.Get Feature Flag Captures Feature Flag Called Event ✅ 209ms

Silthus and others added 4 commits November 2, 2026 17:45
- Serialize read-only dictionaries without recursing into the same converter,
  so ReadOnlyDictionary template data no longer overflows the stack.
- Give IPostHogClient.Emails a default implementation where the target allows
  it, as EvaluateFlagsAsync does, so custom implementers keep compiling.
- Drain email requests while the event batch flushes, instead of before it.
- ConfigureAwait(false) on every await in EmailSender, so a synchronous Dispose
  can't deadlock on a captured context.
- Name an invalid AppHost in the missing_app_host message.
- Drop the unused jitter seam, the unreachable body-read fallback and the
  inline comments; use UUID message IDs in the send fixtures.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Copy the entries into a Dictionary and let System.Text.Json's own dictionary
converter write them, so non-string keys keep their JSON property-name format.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Write one property per entry the source enumerates, with the key type's own
JSON property-name converter, instead of copying into a Dictionary whose
default comparer could merge keys the source keeps apart.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
System.Text.Json's string converter already applies DictionaryKeyPolicy, so a
custom string key converter keeps working with a naming policy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Silthus

Silthus commented Nov 2, 2026

Copy link
Copy Markdown
Contributor Author

Note

🤖 Claude Opus 5.5 responding on behalf of Michael

Decisions made while building this PR:

  • 🤖 Error channel ArgumentException for invalid options, before any request. One EmailException with Code and the cross-SDK Name for every request or SDK failure.
  • 🤖 Template ID type Guid TemplateId. The message ID stays a string (a UUID), because the status read takes what send returned.
  • 🤖 Result types Sealed classes instead of records, so the public API stays small.
  • 🤖 Personal API key as SecretKey Sent as given, with no phs_ check, matching posthog-node and posthog-python. The server checks the key's scopes.
  • 🤖 Static PostHogSdk facade Not extended. Emails go through the client instance only.
  • 🤖 IPostHogClient.Emails compatibility A default implementation that throws NotSupportedException for custom implementers on every target except netstandard2.0, following EvaluateFlagsAsync. The changeset stays minor.
  • 🤖 Dispose order The email drain runs alongside the event batch flush, not before it. A send in retry backoff then doesn't delay the flush.

Adding Emails to IPostHogClient broke custom implementers on netstandard2.0,
which has no default interface members. Emails now lives on PostHogClient, and
AddPostHog registers the client's IPostHogEmails for injection.

Dispose() runs DisposeAsync off the caller's synchronization context, so a
synchronous dispose on a single-threaded context can't deadlock while an
email request or the event flush is still running.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Silthus

Silthus commented Nov 2, 2026

Copy link
Copy Markdown
Contributor Author

Note

🤖 Claude Opus 5.5 responding on behalf of Michael

This decision replaces the earlier IPostHogClient.Emails compatibility line:

  • 🤖 Where Emails lives It is a property of PostHogClient, and AddPostHog registers it as IPostHogEmails for injection. IPostHogClient gets no new member. netstandard2.0 has no default interface members, so a new member there breaks every custom implementer and test double, and a minor release must not do that.
  • 🤖 Synchronous dispose Dispose() runs DisposeAsync on the thread pool. A synchronous dispose on a single-threaded synchronization context then can't deadlock on the email drain or the event flush. A new test reproduced the deadlock before this fix.

…omments

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Silthus
Silthus marked this pull request as ready for review November 2, 2026 20:11
@Silthus
Silthus requested a review from a team as a code owner November 2, 2026 20:11
@Silthus

Silthus commented Nov 2, 2026

Copy link
Copy Markdown
Contributor Author

@PostHog review

@posthog

posthog Bot commented Nov 2, 2026

Copy link
Copy Markdown
Contributor

@Silthus PostHog Review is reviewing this pull request.

@posthog posthog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PostHog Review · Standard · reviewed 7e52001: no findings. This pull request is large, so the review ran in 4 parts with less depth than usual.

@posthog posthog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PostHog Review · Standard · reviewed 7e52001: no findings. This pull request is large, so the review ran in 4 parts with less depth than usual.

stamphog[bot]

This comment was marked as outdated.

Comment thread src/PostHog/Emails/EmailSender.cs Resolved
@veria-ai

veria-ai Bot commented Nov 2, 2026 •

Copy link
Copy Markdown

PR overview

All previously flagged issues have been addressed. No open security concerns remain on this pull request.

Security review

No open security issues remain on this pull request.

Fixed/addressed: 1 · PR risk: 0/10

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@stamphog
stamphog Bot dismissed their stale review November 3, 2026 11:06

New commits were pushed — dismissing the stamphog approval from an earlier head. This PR no longer qualifies for automatic review.

@Silthus

Silthus commented Nov 3, 2026

Copy link
Copy Markdown
Contributor Author

@PostHog review

@posthog

posthog Bot commented Nov 3, 2026

Copy link
Copy Markdown
Contributor

@Silthus PostHog Review is reviewing this pull request.

@posthog posthog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PostHog Review · Standard · reviewed 3fc597c: no findings. This pull request is large, so the review ran in 4 parts with less depth than usual.

stamphog[bot]

This comment was marked as outdated.

… reads

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@stamphog
stamphog Bot dismissed their stale review November 5, 2026 07:44

New commits were pushed — dismissing the stamphog approval from an earlier head. This PR no longer qualifies for automatic review.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Nov 5, 2026 •

Copy link
Copy Markdown
Contributor

SDK performance: posthog-dotnet

Benchmarks completed. No changes flagged for review.

Flags require at least a 5% change in the median and all six pairs agreeing on direction. This is a noise filter, not proof of a performance change or a CI gate.

View all results
Test Measurement Before PR With PR Change Assessment
Capture and deliver events (32-byte payload, burst) Allocations per operation — — — unavailable; base:​ unsupported; head:​ unsupported; base:​ CLR byte counters do not expose exact object allocation counts; native allocations are not included.​; head:​ CLR byte counters do not expose exact object allocation counts; native allocations are not included.​
Capture and deliver events (4,096-byte payload, burst) Allocations per operation — — — unavailable; base:​ unsupported; head:​ unsupported; base:​ CLR byte counters do not expose exact object allocation counts; native allocations are not included.​; head:​ CLR byte counters do not expose exact object allocation counts; native allocations are not included.​
Capture and deliver events (32-byte payload, steady) Allocations per operation — — — unavailable; base:​ unsupported; head:​ unsupported; base:​ CLR byte counters do not expose exact object allocation counts; native allocations are not included.​; head:​ CLR byte counters do not expose exact object allocation counts; native allocations are not included.​
Capture and deliver events (4,096-byte payload, steady) Allocations per operation — — — unavailable; base:​ unsupported; head:​ unsupported; base:​ CLR byte counters do not expose exact object allocation counts; native allocations are not included.​; head:​ CLR byte counters do not expose exact object allocation counts; native allocations are not included.​
Queue an event (32-byte payload) Allocations per operation — — — unavailable; base:​ unsupported; head:​ unsupported; base:​ CLR byte counters do not expose exact object allocation counts; native allocations are not included.​; head:​ CLR byte counters do not expose exact object allocation counts; native allocations are not included.​
Queue an event (4,096-byte payload) Allocations per operation — — — unavailable; base:​ unsupported; head:​ unsupported; base:​ CLR byte counters do not expose exact object allocation counts; native allocations are not included.​; head:​ CLR byte counters do not expose exact object allocation counts; native allocations are not included.​
Evaluate all 10 flags Allocations per operation — — — unavailable; base:​ unsupported; head:​ unsupported; base:​ CLR byte counters do not expose exact object allocation counts; native allocations are not included.​; head:​ CLR byte counters do not expose exact object allocation counts; native allocations are not included.​
Evaluate all 100 flags Allocations per operation — — — unavailable; base:​ unsupported; head:​ unsupported; base:​ CLR byte counters do not expose exact object allocation counts; native allocations are not included.​; head:​ CLR byte counters do not expose exact object allocation counts; native allocations are not included.​
Evaluate all 1,000 flags Allocations per operation — — — unavailable; base:​ unsupported; head:​ unsupported; base:​ CLR byte counters do not expose exact object allocation counts; native allocations are not included.​; head:​ CLR byte counters do not expose exact object allocation counts; native allocations are not included.​
Evaluate one boolean flag Allocations per operation — — — unavailable; base:​ unsupported; head:​ unsupported; base:​ CLR byte counters do not expose exact object allocation counts; native allocations are not included.​; head:​ CLR byte counters do not expose exact object allocation counts; native allocations are not included.​
Evaluate a cohort-filtered flag Allocations per operation — — — unavailable; base:​ unsupported; head:​ unsupported; base:​ CLR byte counters do not expose exact object allocation counts; native allocations are not included.​; head:​ CLR byte counters do not expose exact object allocation counts; native allocations are not included.​
Evaluate a flag (early match) Allocations per operation — — — unavailable; base:​ unsupported; head:​ unsupported; base:​ CLR byte counters do not expose exact object allocation counts; native allocations are not included.​; head:​ CLR byte counters do not expose exact object allocation counts; native allocations are not included.​
Evaluate a flag (late match) Allocations per operation — — — unavailable; base:​ unsupported; head:​ unsupported; base:​ CLR byte counters do not expose exact object allocation counts; native allocations are not included.​; head:​ CLR byte counters do not expose exact object allocation counts; native allocations are not included.​
Evaluate a flag (no match) Allocations per operation — — — unavailable; base:​ unsupported; head:​ unsupported; base:​ CLR byte counters do not expose exact object allocation counts; native allocations are not included.​; head:​ CLR byte counters do not expose exact object allocation counts; native allocations are not included.​
Evaluate a property-filtered flag Allocations per operation — — — unavailable; base:​ unsupported; head:​ unsupported; base:​ CLR byte counters do not expose exact object allocation counts; native allocations are not included.​; head:​ CLR byte counters do not expose exact object allocation counts; native allocations are not included.​
Evaluate a regex-filtered flag Allocations per operation — — — unavailable; base:​ unsupported; head:​ unsupported; base:​ CLR byte counters do not expose exact object allocation counts; native allocations are not included.​; head:​ CLR byte counters do not expose exact object allocation counts; native allocations are not included.​
Evaluate a rollout flag Allocations per operation — — — unavailable; base:​ unsupported; head:​ unsupported; base:​ CLR byte counters do not expose exact object allocation counts; native allocations are not included.​; head:​ CLR byte counters do not expose exact object allocation counts; native allocations are not included.​
Evaluate a multivariate flag Allocations per operation — — — unavailable; base:​ unsupported; head:​ unsupported; base:​ CLR byte counters do not expose exact object allocation counts; native allocations are not included.​; head:​ CLR byte counters do not expose exact object allocation counts; native allocations are not included.​
Capture and deliver events (32-byte payload, burst) Allocated memory per operation 5.37 KiB/op 5.37 KiB/op <0.1% more memory Not flagged
Capture and deliver events (32-byte payload, burst) Time per operation 6.48 µs/op 6.39 µs/op 1.4% less time Not flagged
Capture and deliver events (32-byte payload, burst) idle_managed_heap_bytes (estimated) 820.27 KiB 820.43 KiB <0.1% more memory Not flagged
Capture and deliver events (32-byte payload, burst) Idle process memory (RSS) 53.86 MiB 53.9 MiB <0.1% more memory Not flagged
Capture and deliver events (32-byte payload, burst) Observed peak process memory (RSS) 56.47 MiB 56.51 MiB <0.1% more memory Not flagged
Capture and deliver events (32-byte payload, burst) Managed heap after settling (estimated) 2.23 MiB 2.23 MiB <0.1% more memory Not flagged
Capture and deliver events (32-byte payload, burst) Process memory after settling (RSS) 56.57 MiB 56.6 MiB <0.1% more memory Not flagged
Capture and deliver events (32-byte payload, burst) Process CPU time per operation 6.51 µs/op 6.4 µs/op 1.6% less time Not flagged
Capture and deliver events (32-byte payload, burst) Lifetime peak process memory (RSS) 56.57 MiB 56.6 MiB <0.1% more memory Not flagged
Capture and deliver events (4,096-byte payload, burst) Allocated memory per operation 39.87 KiB/op 39.87 KiB/op <0.1% less memory Not flagged
Capture and deliver events (4,096-byte payload, burst) Time per operation 21.28 µs/op 21.21 µs/op 0.4% less time Not flagged
Capture and deliver events (4,096-byte payload, burst) idle_managed_heap_bytes (estimated) 2.13 MiB 2.13 MiB <0.1% less memory Not flagged
Capture and deliver events (4,096-byte payload, burst) Idle process memory (RSS) 55.78 MiB 55.84 MiB 0.1% more memory Not flagged
Capture and deliver events (4,096-byte payload, burst) Observed peak process memory (RSS) 80.89 MiB 80.96 MiB <0.1% more memory Not flagged
Capture and deliver events (4,096-byte payload, burst) Managed heap after settling (estimated) 25.63 MiB 25.63 MiB <0.1% less memory Not flagged
Capture and deliver events (4,096-byte payload, burst) Process memory after settling (RSS) 81.07 MiB 81.13 MiB <0.1% more memory Not flagged
Capture and deliver events (4,096-byte payload, burst) Process CPU time per operation 22.59 µs/op 22.54 µs/op 0.2% less time Not flagged
Capture and deliver events (4,096-byte payload, burst) Lifetime peak process memory (RSS) 81.07 MiB 81.13 MiB <0.1% more memory Not flagged
Capture and deliver events (32-byte payload, steady) Allocated memory per operation 18.15 KiB/op 18.15 KiB/op <0.1% more memory Not flagged
Capture and deliver events (32-byte payload, steady) Time per operation 9.96 µs/op 9.93 µs/op 0.3% less time Not flagged
Capture and deliver events (32-byte payload, steady) idle_managed_heap_bytes (estimated) 820.28 KiB 820.44 KiB <0.1% more memory Not flagged
Capture and deliver events (32-byte payload, steady) Idle process memory (RSS) 53.81 MiB 53.93 MiB 0.2% more memory Not flagged
Capture and deliver events (32-byte payload, steady) Observed peak process memory (RSS) 54.43 MiB 54.5 MiB 0.1% more memory Not flagged
Capture and deliver events (32-byte payload, steady) Managed heap after settling (estimated) 936.2 KiB 936.35 KiB <0.1% more memory Not flagged
Capture and deliver events (32-byte payload, steady) Process memory after settling (RSS) 54.42 MiB 54.49 MiB 0.1% more memory Not flagged
Capture and deliver events (32-byte payload, steady) Process CPU time per operation 10.06 µs/op 10.05 µs/op 0.1% less time Not flagged
Capture and deliver events (32-byte payload, steady) Lifetime peak process memory (RSS) 54.42 MiB 54.49 MiB 0.1% more memory Not flagged
Capture and deliver events (4,096-byte payload, steady) Allocated memory per operation 30.97 KiB/op 30.97 KiB/op <0.1% less memory Not flagged
Capture and deliver events (4,096-byte payload, steady) Time per operation 15.8 µs/op 15.88 µs/op 0.5% more time Not flagged
Capture and deliver events (4,096-byte payload, steady) idle_managed_heap_bytes (estimated) 2.13 MiB 2.13 MiB <0.1% more memory Not flagged
Capture and deliver events (4,096-byte payload, steady) Idle process memory (RSS) 55.75 MiB 55.75 MiB <0.1% less memory Not flagged
Capture and deliver events (4,096-byte payload, steady) Observed peak process memory (RSS) 62.92 MiB 62.87 MiB <0.1% less memory Not flagged
Capture and deliver events (4,096-byte payload, steady) Managed heap after settling (estimated) 3.12 MiB 3.12 MiB <0.1% more memory Not flagged
Capture and deliver events (4,096-byte payload, steady) Process memory after settling (RSS) 59.25 MiB 59.21 MiB <0.1% less memory Not flagged
Capture and deliver events (4,096-byte payload, steady) Process CPU time per operation 18.84 µs/op 18.77 µs/op 0.4% less time Not flagged
Capture and deliver events (4,096-byte payload, steady) Lifetime peak process memory (RSS) 62.59 MiB 62.68 MiB 0.2% more memory Not flagged
Queue an event (32-byte payload) Allocated memory per operation 1.67 KiB/op 1.67 KiB/op No observed change Not flagged
Queue an event (32-byte payload) Time per operation 2.16 µs/op 2.14 µs/op 0.7% less time Not flagged
Queue an event (32-byte payload) Process CPU time per operation 2.17 µs/op 2.15 µs/op 0.7% less time Not flagged
Queue an event (4,096-byte payload) Allocated memory per operation 1.67 KiB/op 1.67 KiB/op No observed change Not flagged
Queue an event (4,096-byte payload) Time per operation 2.02 µs/op 2.04 µs/op 0.9% more time Not flagged
Queue an event (4,096-byte payload) Process CPU time per operation 2.03 µs/op 2.05 µs/op 0.9% more time Not flagged
Evaluate all 10 flags Allocated memory per operation 23.91 KiB/op 23.91 KiB/op No observed change Not flagged
Evaluate all 10 flags Time per operation 38.49 µs/op 39.19 µs/op 1.8% more time Not flagged
Evaluate all 10 flags Process CPU time per operation 38.66 µs/op 39.37 µs/op 1.8% more time Not flagged
Evaluate all 100 flags Allocated memory per operation 237.31 KiB/op 237.31 KiB/op No observed change Not flagged
Evaluate all 100 flags Time per operation 330.77 µs/op 331.08 µs/op <0.1% more time Not flagged
Evaluate all 100 flags Process CPU time per operation 332.88 µs/op 332.07 µs/op 0.2% less time Not flagged
Evaluate all 1,000 flags Allocated memory per operation 2.32 MiB/op 2.32 MiB/op No observed change Not flagged
Evaluate all 1,000 flags Time per operation 3.12 ms/op 3.13 ms/op 0.3% more time Not flagged
Evaluate all 1,000 flags Process CPU time per operation 3.12 ms/op 3.13 ms/op 0.3% more time Not flagged
Evaluate 1,000 flags under concurrent load idle_managed_heap_bytes (estimated) 4.64 MiB 4.69 MiB 1% more memory Not flagged
Evaluate 1,000 flags under concurrent load Idle process memory (RSS) 83.28 MiB 83.23 MiB <0.1% less memory Not flagged
Evaluate 1,000 flags under concurrent load Observed peak process memory (RSS) 88.44 MiB 88.39 MiB <0.1% less memory Not flagged
Evaluate 1,000 flags under concurrent load Managed heap after settling (estimated) 4.68 MiB 4.73 MiB 1% more memory Not flagged
Evaluate 1,000 flags under concurrent load Process memory after settling (RSS) 88.49 MiB 88.45 MiB <0.1% less memory Not flagged
Evaluate 1,000 flags under concurrent load Lifetime peak process memory (RSS) 88.49 MiB 88.45 MiB <0.1% less memory Not flagged
Refresh 1,000 flag definitions idle_managed_heap_bytes (estimated) 19.43 MiB 19.43 MiB <0.1% more memory Not flagged
Refresh 1,000 flag definitions Idle process memory (RSS) 110.73 MiB 110.77 MiB <0.1% more memory Not flagged
Refresh 1,000 flag definitions Observed peak process memory (RSS) 123.39 MiB 123.5 MiB <0.1% more memory Not flagged
Refresh 1,000 flag definitions Managed heap after settling (estimated) 20.42 MiB 20.42 MiB <0.1% more memory Not flagged
Refresh 1,000 flag definitions Process memory after settling (RSS) 122.35 MiB 122.46 MiB <0.1% more memory Not flagged
Refresh 1,000 flag definitions Lifetime peak process memory (RSS) 122.77 MiB 122.83 MiB <0.1% more memory Not flagged
Evaluate one boolean flag Allocated memory per operation 1.1 KiB/op 1.1 KiB/op No observed change Not flagged
Evaluate one boolean flag Time per operation 2.49 µs/op 2.5 µs/op <0.1% more time Not flagged
Evaluate one boolean flag Process CPU time per operation 2.5 µs/op 2.5 µs/op <0.1% more time Not flagged
Evaluate a cohort-filtered flag Allocated memory per operation 1.35 KiB/op 1.35 KiB/op No observed change Not flagged
Evaluate a cohort-filtered flag Time per operation 2.41 µs/op 2.39 µs/op 0.6% less time Not flagged
Evaluate a cohort-filtered flag Process CPU time per operation 2.41 µs/op 2.4 µs/op 0.6% less time Not flagged
Evaluate a flag (early match) Allocated memory per operation 2.95 KiB/op 2.95 KiB/op No observed change Not flagged
Evaluate a flag (early match) Time per operation 3.73 µs/op 3.7 µs/op 0.6% less time Not flagged
Evaluate a flag (early match) Process CPU time per operation 3.75 µs/op 3.72 µs/op 0.7% less time Not flagged
Evaluate a flag (late match) Allocated memory per operation 4.57 KiB/op 4.57 KiB/op No observed change Not flagged
Evaluate a flag (late match) Time per operation 5.2 µs/op 5.13 µs/op 1.4% less time Not flagged
Evaluate a flag (late match) Process CPU time per operation 5.22 µs/op 5.14 µs/op 1.5% less time Not flagged
Evaluate a flag (no match) Allocated memory per operation 4.34 KiB/op 4.34 KiB/op No observed change Not flagged
Evaluate a flag (no match) Time per operation 4.65 µs/op 4.65 µs/op 0.2% less time Not flagged
Evaluate a flag (no match) Process CPU time per operation 4.68 µs/op 4.67 µs/op 0.3% less time Not flagged
Evaluate a property-filtered flag Allocated memory per operation 1.32 KiB/op 1.32 KiB/op No observed change Not flagged
Evaluate a property-filtered flag Time per operation 2.21 µs/op 2.23 µs/op 0.8% more time Not flagged
Evaluate a property-filtered flag Process CPU time per operation 2.21 µs/op 2.23 µs/op 0.8% more time Not flagged
Evaluate a regex-filtered flag Allocated memory per operation 5.75 KiB/op 5.75 KiB/op No observed change Not flagged
Evaluate a regex-filtered flag Time per operation 9.56 µs/op 9.54 µs/op 0.3% less time Not flagged
Evaluate a regex-filtered flag Process CPU time per operation 9.58 µs/op 9.55 µs/op 0.4% less time Not flagged
Evaluate a rollout flag Allocated memory per operation 1.2 KiB/op 1.2 KiB/op No observed change Not flagged
Evaluate a rollout flag Time per operation 3 µs/op 2.99 µs/op 0.2% less time Not flagged
Evaluate a rollout flag Process CPU time per operation 3 µs/op 2.99 µs/op 0.2% less time Not flagged
Evaluate a multivariate flag Allocated memory per operation 1.24 KiB/op 1.24 KiB/op No observed change Not flagged
Evaluate a multivariate flag Time per operation 2.65 µs/op 2.69 µs/op 1.6% more time Not flagged
Evaluate a multivariate flag Process CPU time per operation 2.65 µs/op 2.69 µs/op 1.5% more time Not flagged

Values are medians of paired measurements. Not flagged does not mean unchanged. Unavailable means a measurement could not be provided, not zero. Estimated allocation measurements retain their instrumentation uncertainty. RSS measures the whole process, not just SDK allocations; observed peaks are sampled checkpoints. Paired ranges in the diagnostics describe variability, not confidence intervals.

Compared 14f7e3cb6119207ec65ff9d56a8e5436d193526a → a0cb75da8383a668de999dd6cefa41bf93396d4b.

Full report and diagnostics

108 metric rows; 108 rows shown in all results. Omitted or shortened data remain in comparison.json, including per-runner environments, resource limits, measurement methods, samples and checkpoints.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Silthus
Silthus merged commit f2c19aa into main Nov 5, 2026
22 checks passed
@Silthus
Silthus deleted the feat/emails-send branch November 5, 2026 12:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants