Skip to content

feat(emails): send a message category with emails.send - #350

Draft
Silthus wants to merge 11 commits into
mainfrom
feat/emails-send-category
Draft

Silthus wants to merge 11 commits into
mainfrom
feat/emails-send-category

Conversation

@Silthus

@Silthus Silthus commented Nov 9, 2026 •

Copy link
Copy Markdown
Contributor

💡 Motivation and Context

Developers who send email from Ruby with posthog.emails.send can only send transactional mail. When a team moves a workflow's marketing email to a code send, the email loses its message category. Recipients' opt-outs and unsubscribe links stop applying.

This PR adds an optional category to code sends (the template_key form):

  • Left out: a transactional email with no category, as today, or, when template_key names a template in a transactional category, that template's category. A template made in PostHog is transactional: PostHog refuses a send whose template_key names a template in a marketing category with marketing_template.
  • PostHog::Emails::TRANSACTIONAL: a transactional email with no category. It is sent as "transactional", so PostHog refuses it with category_mismatch for a template in a category.
  • A PostHog::Emails::MessageCategory with key, type (marketing or transactional), name and an optional description. It defines the category in code: PostHog creates it the first time a send names its key, or restores it if it was deleted, and otherwise leaves it unchanged. Its name and description apply only when the send creates the category; change them later in PostHog. A marketing email skips recipients who opted out, and needs an {{ unsubscribe_url }} link.
posthog.emails.send(
  to: 'user@example.com',
  template_key: 'monthly-digest',
  subject: 'Your monthly digest',
  html: rendered_html, # links to {{ unsubscribe_url }}
  category: PostHog::Emails::MessageCategory.new(
    key: 'newsletter', type: 'marketing', name: 'Newsletter', description: 'Monthly product news'
  )
)

A template send (template_id) follows the template's own category, so it refuses category with an ArgumentError, the way it refuses subject.

Depends on PostHog/posthog#132118, which pins the contract (revision 2) and adds the server side. Release once customers can use it.

Impact: the backend PR adds category_type to the transactional email api send usage event, and its insight counts code sends by category_type. Marketing code sends from Ruby show up there.

Warning

🟡 Merge risk: medium · two-way door at merge, one-way once the gem releases: the public category keyword, MessageCategory and TRANSACTIONAL

  • Ruby callers that pass category to a code send get it on the wire, and the server's category refusals as EmailError; no caller does yet.
  • Every existing call is unchanged: the exact-body specs pass as before, so an unset category is not sent.
  • Uncovered: a real send against the live server (owed, below); a contract drift there fails the caller's own send visibly.
  • Templates made in PostHog are transactional only: a send whose template_key names a template in a marketing category gets marketing_template, and the docs say so. Template categories are parked server-side, so no marketing template send ships from this gem.
  • The backend dependency (feat(messaging): send code emails in a marketing message category posthog#132118 at ac055ee) is create-only, so a send never renames a category or changes its description, as these docs say.
  • Switch-off: none: an optional keyword, live once released; fix forward.

Rollout

  • Stages: the backend (feat(messaging): send code emails in a marketing message category posthog#132118) and CDP (#132149) deploy first; then merge and release this gem, after the real send through this branch.
  • Decided: "Create-only, no renames (Recommended)", 2026-11-12: a send never renames a category or changes its description.
  • Decided: "Ship code sends, park templates (Recommended)", 2026-11-12: code-send categories ship; a template made in PostHog stays transactional, and one in a marketing category is refused with marketing_template.
  • Watch: no alert pages on a wrong category; the real send checks the stored category before release.

Changes

  • PostHog::Emails::MessageCategory, a keyword Struct like the SDK's SendResult and Status. The wire carries it as an object. description is left out when nil and sent when "", as contract rev 2.2 asks; it only matters when the send creates the category.
  • PostHog::Emails::TRANSACTIONAL = 'transactional', the built-in value, sent as that string.
  • The client checks only what Ruby can't enforce: category is TRANSACTIONAL or a MessageCategory, key and name are strings, type is marketing or transactional, and description is a string when set. PostHog owns the key format and the lengths.
  • The server's new codes come through as EmailError, named validation_error, and none retries. They are category_mismatch, too_many_categories, unsubscribe_link_required and copies_not_allowed (400), and recipient_opted_out (422). The #send docs and the changeset also name marketing_template (400), which PostHog returns for a template_key that names a template in a marketing category.
  • YARD docs on Emails, MessageCategory and #send, a changeset (posthog-ruby: minor), and the public API snapshot.
  • sdk-specs: the canonical emails spec does not define category yet. This PR follows the contract in the backend PR.

💚 How did you test it?

  • CI on the head: RSpec on Ruby 3.2, 3.3 and 3.4 and on the latest Rails, RuboCop, the public API snapshot and the gem build.
  • Specs (spec/posthog/emails_spec.rb, 74 examples, green at 8f29409), driven through client.emails.send with WebMock:
    • code sends run against a fake of the send endpoint that holds categories by key. The spec asserts what it ends up holding: a first send creates newsletter with its name and description, and a second send with another name and description is accepted and leaves it unchanged. A transactional category object is created, and TRANSACTIONAL creates no category. Mutation-checked at cb57192: putting the old rename back in the fake fails the create-only spec;
    • the existing exact-body specs still pass, so an unset category is not sent;
    • the fake also models a template_key that names a template in a transactional category (receipts). A send that leaves category out is filed under the template's category, and a send with TRANSACTIONAL raises an EmailError with code category_mismatch and files nothing. Mutation-checked at 8c3d803: dropping an explicit TRANSACTIONAL fails the refusal spec, and sending "transactional" for a left-out category fails the filing spec;
    • a template_key that names a template in a marketing category raises an EmailError with code marketing_template and files nothing, as the server refuses it. Red first: the spec failed against the old fake, which sent the email (expected PostHog::EmailError but nothing was raised), before the fake learned the refusal;
    • a template send with category raises ArgumentError before any request;
    • a string key, a Hash, a non-string key or name, an unknown type and a non-string description raise ArgumentError before any request;
    • each new error code comes back as an EmailError with the right name. In the retry table each refusal is followed by a success, so recipient_opted_out raising its code shows it is not retried. Mutation-checked at 8c3d803: retrying every refusal fails the three no-retry rows, and retrying none fails the three retry rows.
  • Live run of the built and installed gem (Ruby 3.2.2) against a local recorder of the send endpoint, at 65a1bec. Later commits change only specs, doc comments and the changeset (bodies shortened here). Case 2 shows that an explicit TRANSACTIONAL reaches the wire, and case 1 that a left-out category does not:
Requests and results
1. No category
    body: {"subject":"Your monthly digest","html":"...","template_key":"monthly-digest","capture_engagement":true,"to":"user@example.com"}
<-- {:id=>"01a18356-c56c-0001-e494-7666bc915188", :replayed=>false, :warnings=>[]}

2. category: PostHog::Emails::TRANSACTIONAL
    body: {...,"category":"transactional","to":"user@example.com"}

3. category: MessageCategory(newsletter, marketing), no description
    body: {...,"category":{"key":"newsletter","type":"marketing","name":"Newsletter"},"to":"user@example.com"}

4. category: MessageCategory(newsletter, marketing, description), recipient opted out
    body: {...,"category":{"key":"newsletter","type":"marketing","name":"Newsletter","description":"Monthly product news"},"to":"user@example.com"}
<-- EmailError code=recipient_opted_out name=validation_error status=422 message=The recipient opted out of newsletter.

5. Template send with a category (no request expected)
<-- ArgumentError A send with template_id doesn't take category, so no email was sent. A template made in PostHog sets its own sender, subject, content and category, and follows the project's engagement setting. Remove category, or send with template_key.
  • Lane gate at 8f29409, in the ruby:3.2 Docker image: bundle exec rubocop (no offenses) and bundle exec rake public_api:check pass. bundle exec rspec: 24 failures, all in spec/posthog/rails/template_backtrace_spec.rb. The same 24 fail at the base commit 97102ef in the same image, and CI's Ruby 3.2 passes, so they don't come from this change.
  • Owed before this PR leaves draft: a live run against a real PostHog server. The server side (feat(messaging): send code emails in a marketing message category posthog#132118) is not deployed yet, so today's live run uses a recorder that answers like the contract says.

📝 Checklist

  • All commits are signed (see Commit signing)
  • I reviewed the submitted code.
  • I added tests to verify the changes.
  • I updated the docs if needed.
  • No breaking change or entry added to the changelog.

If releasing new changes

  • Ran pnpm changeset to generate a changeset file

🤖 Agent context

Autonomy: Human-driven (agent-assisted)

Written by Claude Code (Claude Opus 5.5), as one of the SDK workers for the code-send message categories contract.

Question Answer Who Where
What category carries The built-in "transactional", or a category object with key, type, name and optional description; replaced a plain category key 👤 Human Contract, revision 2
Which send forms take category Code sends only; a template send follows its template's category, in all 8 SDKs; replaced both forms 👤 Human Contract, revision 2.1
Explicit TRANSACTIONAL versus a left-out category Kept apart on the wire: left out follows a template_key's template category, TRANSACTIONAL is refused on a template in a category; docs name the exception 👤 Human Contract, explicit transactional
What a send does to an existing category "Create-only, no renames (Recommended)" (2026-11-12): a send creates a missing category or restores a deleted one, and otherwise leaves it unchanged; name and description apply only at creation; replaced the upsert by key 👍 Accepted default Contract, redesign 2026-11-12
Whether a template made in PostHog can be marketing "Ship code sends, park templates (Recommended)" (2026-11-12): no; templates are transactional only, and one in a marketing category is refused with marketing_template; template categories wait for their own decision 👤 Human Contract, 2026-11-12 ~14:30
How much the client validates Only what Ruby can't enforce: the value's class, string fields and the two types; the server owns key format and lengths 👍 Accepted default Contract, SDKs
How Ruby models the category PostHog::Emails::MessageCategory, a keyword Struct like the SDK's other value types; a Hash is refused, so there is one way to pass it 🤖 Agent comment
Name of the built-in value PostHog::Emails::TRANSACTIONAL 🤖 Agent comment

Involvement: no grilling rounds, no prototypes. GPT-6.1 Sol reviewed the first version: one finding, fixed. On the revision 2 rework it asked twice for the category test to run against a fake that behaves like the send endpoint instead of asserting a captured request. That is fixed, and the spec asserts the categories the fake holds. Its read of that fix found the fake accepted any category value that wasn't an object, which is fixed too. Its last read found nothing. After Michael's explicit-transactional decision, its fixes rounds on 0434df8 and on 8c3d803 found nothing. The proof judge asked that the retry table stop counting requests and assert only what the caller gets, which 8c3d803 does. PostHog Review on 65a1bec raised two findings, both declined because they follow the backend PR's earlier contract revision; the replies give the reasons. Its reviews of later heads, 0434df8 and 8c3d803 included, found nothing. After the create-only decision, GPT-6.1 Sol's fixes round on cb57192 raised one finding, that the backend PR's head still renames; declined, because the server half of the same decision lands in the backend stack and the real send gates this PR. After the decision to park template categories, its fixes round on 8f29409 found nothing. Live-run proof captured against a recorder; the real-server run is owed.

🤖 Generated with Claude Code

`category` takes `PostHog::Emails::TRANSACTIONAL` or the key of one of the
project's marketing message categories. The client sends it only when the
caller sets it, so PostHog decides the default.

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

Silthus commented Nov 9, 2026 •

Copy link
Copy Markdown
Contributor Author

Note

Decisions recorded by Claude Code (Claude Opus 5.5) while building this PR. Updated for contract revision 2.

  • 👤 Which send forms take category Code sends only (template_key). A template send follows its template's category and refuses category, in all 8 SDKs (contract revision 2.1). Replaces: both forms.
  • 👍 How much the client validates Only what Ruby can't enforce: the value is TRANSACTIONAL or a MessageCategory, key and name are strings, type is marketing or transactional, description is a string when set. The server owns key format and lengths. Replaces: a nonempty string.
  • 🤖 How Ruby models the category PostHog::Emails::MessageCategory, a keyword Struct like SendResult and Status. A Hash is refused, so there is one way to pass a category.
  • 🤖 Name of the built-in value PostHog::Emails::TRANSACTIONAL, a public constant on the class that owns send.

@github-actions

github-actions Bot commented Nov 9, 2026 •

Copy link
Copy Markdown

posthog-ruby-sync Compliance Report

Date: 2026-11-12T14:42:04.408060+00:00
Duration: 55727ms

⚠️ Some Tests Failed

39/47 tests passed, 8 failed


Capture Tests

⚠️ 23/30 tests passed, 7 failed

View Details
Test Status Duration
Format Validation.Event Has Required Fields ✅ 8ms
Format Validation.Event Has Uuid ✅ 5ms
Format Validation.Event Has Lib Properties ✅ 4ms
Format Validation.Distinct Id Is String ✅ 7ms
Format Validation.Token Is Present ✅ 6ms
Format Validation.Custom Properties Preserved ✅ 6ms
Format Validation.Event Has Timestamp ✅ 6ms
Format Validation.Non Utc Event Timestamp Is Converted To Utc ✅ 4ms
Retry Behavior.Retries On 503 ✅ 5320ms
Retry Behavior.Does Not Retry On 400 ❌ 10ms
Retry Behavior.Does Not Retry On 401 ❌ 11ms
Retry Behavior.Respects Retry After Header ✅ 8020ms
Retry Behavior.Implements Backoff ❌ 222ms
Retry Behavior.Retries On 500 ✅ 5148ms
Retry Behavior.Retries On 502 ✅ 5142ms
Retry Behavior.Retries On 504 ✅ 5115ms
Retry Behavior.Max Retries Respected ❌ 529ms
Deduplication.Generates Unique Uuids ✅ 13ms
Deduplication.Preserves Uuid On Retry ✅ 5112ms
Deduplication.Preserves Uuid And Timestamp On Retry ✅ 10251ms
Deduplication.Preserves Uuid And Timestamp On Batch Retry ✅ 5118ms
Deduplication.No Duplicate Events In Batch ✅ 13ms
Deduplication.Different Events Have Different Uuids ✅ 7ms
Compression.Sends Gzip When Enabled ✅ 4ms
Batch Format.Uses Proper Batch Structure ✅ 4ms
Batch Format.Flush With No Events Sends Nothing ✅ 2ms
Batch Format.Multiple Events Batched Together ❌ 11ms
Error Handling.Does Not Retry On 403 ❌ 4ms
Error Handling.Does Not Retry On 413 ❌ 3ms
Error Handling.Retries On 408 ✅ 5111ms

Failures

retry_behavior.does_not_retry_on_400

500, message='Internal Server Error', url='http://sdk-adapter:8080/capture'

retry_behavior.does_not_retry_on_401

500, message='Internal Server Error', url='http://sdk-adapter:8080/capture'

retry_behavior.implements_backoff

500, message='Internal Server Error', url='http://sdk-adapter:8080/capture'

retry_behavior.max_retries_respected

500, message='Internal Server Error', url='http://sdk-adapter:8080/capture'

batch_format.multiple_events_batched_together

Expected 1 requests, got 5

error_handling.does_not_retry_on_403

500, message='Internal Server Error', url='http://sdk-adapter:8080/capture'

error_handling.does_not_retry_on_413

500, message='Internal Server Error', url='http://sdk-adapter:8080/capture'

Feature_Flags Tests

⚠️ 16/17 tests passed, 1 failed

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

Failures

request_payload.disable_geoip_omitted_defaults_to_false

Field 'geoip_disable' not found in /flags request body at path 'geoip_disable'. Available keys: ['distinct_id', 'groups', 'person_properties', 'group_properties', 'flag_keys_to_evaluate', 'token']

@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 5401139: no findings.

@github-actions

github-actions Bot commented Nov 9, 2026 •

Copy link
Copy Markdown

posthog-ruby-async Compliance Report

Date: 2026-11-12T14:42:39.286848+00:00
Duration: 98441ms

⚠️ Some Tests Failed

46/47 tests passed, 1 failed


Capture Tests

✅ 30/30 tests passed

View Details
Test Status Duration
Format Validation.Event Has Required Fields ✅ 10ms
Format Validation.Event Has Uuid ✅ 106ms
Format Validation.Event Has Lib Properties ✅ 108ms
Format Validation.Distinct Id Is String ✅ 107ms
Format Validation.Token Is Present ✅ 107ms
Format Validation.Custom Properties Preserved ✅ 109ms
Format Validation.Event Has Timestamp ✅ 106ms
Format Validation.Non Utc Event Timestamp Is Converted To Utc ✅ 8ms
Retry Behavior.Retries On 503 ✅ 5310ms
Retry Behavior.Does Not Retry On 400 ✅ 2110ms
Retry Behavior.Does Not Retry On 401 ✅ 2109ms
Retry Behavior.Respects Retry After Header ✅ 8116ms
Retry Behavior.Implements Backoff ✅ 15521ms
Retry Behavior.Retries On 500 ✅ 5214ms
Retry Behavior.Retries On 502 ✅ 5211ms
Retry Behavior.Retries On 504 ✅ 5212ms
Retry Behavior.Max Retries Respected ✅ 15709ms
Deduplication.Generates Unique Uuids ✅ 111ms
Deduplication.Preserves Uuid On Retry ✅ 5212ms
Deduplication.Preserves Uuid And Timestamp On Retry ✅ 10418ms
Deduplication.Preserves Uuid And Timestamp On Batch Retry ✅ 5215ms
Deduplication.No Duplicate Events In Batch ✅ 112ms
Deduplication.Different Events Have Different Uuids ✅ 107ms
Compression.Sends Gzip When Enabled ✅ 106ms
Batch Format.Uses Proper Batch Structure ✅ 105ms
Batch Format.Flush With No Events Sends Nothing ✅ 5ms
Batch Format.Multiple Events Batched Together ✅ 113ms
Error Handling.Does Not Retry On 403 ✅ 2109ms
Error Handling.Does Not Retry On 413 ✅ 2109ms
Error Handling.Retries On 408 ✅ 5212ms

Feature_Flags Tests

⚠️ 16/17 tests passed, 1 failed

View Details
Test Status Duration
Request Payload.Request With Person Properties Device Id ✅ 107ms
Request Payload.Flags Request Uses V2 Query Param ✅ 107ms
Request Payload.Flags Request Hits Flags Path Not Decide ✅ 108ms
Request Payload.Flags Request Omits Authorization Header ✅ 107ms
Request Payload.Token In Flags Body Matches Init ✅ 107ms
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 ✅ 107ms
Request Lifecycle.No Flags Request On Init Alone ✅ 4ms
Request Lifecycle.No Flags Request On Normal Capture ✅ 105ms
Request Lifecycle.Two Flag Calls Produce Two Remote Requests ✅ 113ms
Request Lifecycle.Mock Response Value Is Returned To Caller ✅ 108ms
Retry Behavior.Retries Flags On 502 ✅ 248ms
Retry Behavior.Retries Flags On 504 ✅ 251ms
Side Effect Events.Get Feature Flag Captures Feature Flag Called Event ✅ 109ms

Failures

request_payload.disable_geoip_omitted_defaults_to_false

Field 'geoip_disable' not found in /flags request body at path 'geoip_disable'. Available keys: ['distinct_id', 'groups', 'person_properties', 'group_properties', 'flag_keys_to_evaluate', 'token']

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

@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 fad7d4f: no findings.

@github-actions

github-actions Bot commented Nov 9, 2026 •

Copy link
Copy Markdown

SDK performance: posthog-ruby

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)
Shard: capture
Allocated memory per operation — — — unavailable; base:​ unsupported; head:​ unsupported; base:​ Ruby has no cumulative allocated-byte counter here; retained ObjectSpace bytes are not allocation volume.​; head:​ Ruby has no cumulative allocated-byte counter here; retained ObjectSpace bytes are not allocation volume.​
Capture and deliver events (4,096-byte payload, burst)
Shard: capture
Allocated memory per operation — — — unavailable; base:​ unsupported; head:​ unsupported; base:​ Ruby has no cumulative allocated-byte counter here; retained ObjectSpace bytes are not allocation volume.​; head:​ Ruby has no cumulative allocated-byte counter here; retained ObjectSpace bytes are not allocation volume.​
Capture and deliver events (32-byte payload, steady)
Shard: capture
Allocated memory per operation — — — unavailable; base:​ unsupported; head:​ unsupported; base:​ Ruby has no cumulative allocated-byte counter here; retained ObjectSpace bytes are not allocation volume.​; head:​ Ruby has no cumulative allocated-byte counter here; retained ObjectSpace bytes are not allocation volume.​
Capture and deliver events (4,096-byte payload, steady)
Shard: capture
Allocated memory per operation — — — unavailable; base:​ unsupported; head:​ unsupported; base:​ Ruby has no cumulative allocated-byte counter here; retained ObjectSpace bytes are not allocation volume.​; head:​ Ruby has no cumulative allocated-byte counter here; retained ObjectSpace bytes are not allocation volume.​
Queue an event (32-byte payload)
Shard: capture
Allocated memory per operation — — — unavailable; base:​ unsupported; head:​ unsupported; base:​ Background serialization has no public enqueue completion boundary; use completed-delivery workloads for process-wide allocation counts.​; head:​ Background serialization has no public enqueue completion boundary; use completed-delivery workloads for process-wide allocation counts.​
Queue an event (32-byte payload)
Shard: capture
Allocations per operation — — — unavailable; base:​ unsupported; head:​ unsupported; base:​ Background serialization has no public enqueue completion boundary; use completed-delivery workloads for process-wide allocation counts.​; head:​ Background serialization has no public enqueue completion boundary; use completed-delivery workloads for process-wide allocation counts.​
Queue an event (4,096-byte payload)
Shard: capture
Allocated memory per operation — — — unavailable; base:​ unsupported; head:​ unsupported; base:​ Background serialization has no public enqueue completion boundary; use completed-delivery workloads for process-wide allocation counts.​; head:​ Background serialization has no public enqueue completion boundary; use completed-delivery workloads for process-wide allocation counts.​
Queue an event (4,096-byte payload)
Shard: capture
Allocations per operation — — — unavailable; base:​ unsupported; head:​ unsupported; base:​ Background serialization has no public enqueue completion boundary; use completed-delivery workloads for process-wide allocation counts.​; head:​ Background serialization has no public enqueue completion boundary; use completed-delivery workloads for process-wide allocation counts.​
Evaluate all 10 flags
Shard: flags-scaling
Allocated memory per operation — — — unavailable; base:​ unsupported; head:​ unsupported; base:​ Ruby has no cumulative allocated-byte counter here; retained ObjectSpace bytes are not allocation volume.​; head:​ Ruby has no cumulative allocated-byte counter here; retained ObjectSpace bytes are not allocation volume.​
Evaluate all 100 flags
Shard: flags-scaling
Allocated memory per operation — — — unavailable; base:​ unsupported; head:​ unsupported; base:​ Ruby has no cumulative allocated-byte counter here; retained ObjectSpace bytes are not allocation volume.​; head:​ Ruby has no cumulative allocated-byte counter here; retained ObjectSpace bytes are not allocation volume.​
Evaluate all 1,000 flags
Shard: flags-scaling
Allocated memory per operation — — — unavailable; base:​ unsupported; head:​ unsupported; base:​ Ruby has no cumulative allocated-byte counter here; retained ObjectSpace bytes are not allocation volume.​; head:​ Ruby has no cumulative allocated-byte counter here; retained ObjectSpace bytes are not allocation volume.​
Evaluate one boolean flag
Shard: flags-single
Allocated memory per operation — — — unavailable; base:​ unsupported; head:​ unsupported; base:​ Ruby has no cumulative allocated-byte counter here; retained ObjectSpace bytes are not allocation volume.​; head:​ Ruby has no cumulative allocated-byte counter here; retained ObjectSpace bytes are not allocation volume.​
Evaluate a cohort-filtered flag
Shard: flags-single
Allocated memory per operation — — — unavailable; base:​ unsupported; head:​ unsupported; base:​ Ruby has no cumulative allocated-byte counter here; retained ObjectSpace bytes are not allocation volume.​; head:​ Ruby has no cumulative allocated-byte counter here; retained ObjectSpace bytes are not allocation volume.​
Evaluate a flag (early match)
Shard: flags-single
Allocated memory per operation — — — unavailable; base:​ unsupported; head:​ unsupported; base:​ Ruby has no cumulative allocated-byte counter here; retained ObjectSpace bytes are not allocation volume.​; head:​ Ruby has no cumulative allocated-byte counter here; retained ObjectSpace bytes are not allocation volume.​
Evaluate a flag (late match)
Shard: flags-single
Allocated memory per operation — — — unavailable; base:​ unsupported; head:​ unsupported; base:​ Ruby has no cumulative allocated-byte counter here; retained ObjectSpace bytes are not allocation volume.​; head:​ Ruby has no cumulative allocated-byte counter here; retained ObjectSpace bytes are not allocation volume.​
Evaluate a flag (no match)
Shard: flags-single
Allocated memory per operation — — — unavailable; base:​ unsupported; head:​ unsupported; base:​ Ruby has no cumulative allocated-byte counter here; retained ObjectSpace bytes are not allocation volume.​; head:​ Ruby has no cumulative allocated-byte counter here; retained ObjectSpace bytes are not allocation volume.​
Evaluate a property-filtered flag
Shard: flags-single
Allocated memory per operation — — — unavailable; base:​ unsupported; head:​ unsupported; base:​ Ruby has no cumulative allocated-byte counter here; retained ObjectSpace bytes are not allocation volume.​; head:​ Ruby has no cumulative allocated-byte counter here; retained ObjectSpace bytes are not allocation volume.​
Evaluate a regex-filtered flag
Shard: flags-single
Allocated memory per operation — — — unavailable; base:​ unsupported; head:​ unsupported; base:​ Ruby has no cumulative allocated-byte counter here; retained ObjectSpace bytes are not allocation volume.​; head:​ Ruby has no cumulative allocated-byte counter here; retained ObjectSpace bytes are not allocation volume.​
Evaluate a rollout flag
Shard: flags-single
Allocated memory per operation — — — unavailable; base:​ unsupported; head:​ unsupported; base:​ Ruby has no cumulative allocated-byte counter here; retained ObjectSpace bytes are not allocation volume.​; head:​ Ruby has no cumulative allocated-byte counter here; retained ObjectSpace bytes are not allocation volume.​
Evaluate a multivariate flag
Shard: flags-single
Allocated memory per operation — — — unavailable; base:​ unsupported; head:​ unsupported; base:​ Ruby has no cumulative allocated-byte counter here; retained ObjectSpace bytes are not allocation volume.​; head:​ Ruby has no cumulative allocated-byte counter here; retained ObjectSpace bytes are not allocation volume.​
Capture and deliver events (32-byte payload, burst)
Shard: capture
Allocations per operation 56.27 count/op 56.27 count/op No observed change Not flagged
Capture and deliver events (32-byte payload, burst)
Shard: capture
Time per operation 110.37 µs/op 110.39 µs/op <0.1% more time Not flagged
Capture and deliver events (32-byte payload, burst)
Shard: capture
Idle process memory (RSS) 28.38 MiB 28.35 MiB 0.1% less memory Not flagged
Capture and deliver events (32-byte payload, burst)
Shard: capture
Observed peak process memory (RSS) 31.14 MiB 31.11 MiB 0.1% less memory Not flagged
Capture and deliver events (32-byte payload, burst)
Shard: capture
Managed heap after settling 5.28 MiB 5.28 MiB <0.1% more memory Not flagged
Capture and deliver events (32-byte payload, burst)
Shard: capture
Process memory after settling (RSS) 30.38 MiB 30.34 MiB 0.1% less memory Not flagged
Capture and deliver events (32-byte payload, burst)
Shard: capture
Process CPU time per operation 22.74 µs/op 22.86 µs/op 0.5% more time Not flagged
Capture and deliver events (32-byte payload, burst)
Shard: capture
Lifetime peak process memory (RSS) 30.94 MiB 30.95 MiB <0.1% more memory Not flagged
Capture and deliver events (4,096-byte payload, burst)
Shard: capture
Allocations per operation 58.65 count/op 58.65 count/op No observed change Not flagged
Capture and deliver events (4,096-byte payload, burst)
Shard: capture
Time per operation 110.16 µs/op 110.22 µs/op <0.1% more time Not flagged
Capture and deliver events (4,096-byte payload, burst)
Shard: capture
Idle process memory (RSS) 33.2 MiB 33.21 MiB <0.1% more memory Not flagged
Capture and deliver events (4,096-byte payload, burst)
Shard: capture
Observed peak process memory (RSS) 42.39 MiB 42.16 MiB 0.5% less memory Not flagged
Capture and deliver events (4,096-byte payload, burst)
Shard: capture
Managed heap after settling 9.15 MiB 9.15 MiB <0.1% more memory Not flagged
Capture and deliver events (4,096-byte payload, burst)
Shard: capture
Process memory after settling (RSS) 40.68 MiB 39.94 MiB 1.8% less memory Not flagged
Capture and deliver events (4,096-byte payload, burst)
Shard: capture
Process CPU time per operation 31.35 µs/op 31.47 µs/op 0.4% more time Not flagged
Capture and deliver events (4,096-byte payload, burst)
Shard: capture
Lifetime peak process memory (RSS) 42.19 MiB 41.96 MiB 0.5% less memory Not flagged
Capture and deliver events (32-byte payload, steady)
Shard: capture
Allocations per operation 58.68 count/op 58.68 count/op No observed change Not flagged
Capture and deliver events (32-byte payload, steady)
Shard: capture
Time per operation 1.01 ms/op 1.02 ms/op <0.1% more time Not flagged
Capture and deliver events (32-byte payload, steady)
Shard: capture
Idle process memory (RSS) 28.26 MiB 28.27 MiB <0.1% more memory Not flagged
Capture and deliver events (32-byte payload, steady)
Shard: capture
Observed peak process memory (RSS) 30.34 MiB 30.38 MiB 0.1% more memory Not flagged
Capture and deliver events (32-byte payload, steady)
Shard: capture
Managed heap after settling 5.28 MiB 5.28 MiB <0.1% more memory Not flagged
Capture and deliver events (32-byte payload, steady)
Shard: capture
Process memory after settling (RSS) 30.34 MiB 30.38 MiB 0.1% more memory Not flagged
Capture and deliver events (32-byte payload, steady)
Shard: capture
Process CPU time per operation 32.53 µs/op 32.33 µs/op 0.6% less time Not flagged
Capture and deliver events (32-byte payload, steady)
Shard: capture
Lifetime peak process memory (RSS) 30.34 MiB 30.38 MiB 0.1% more memory Not flagged
Capture and deliver events (4,096-byte payload, steady)
Shard: capture
Allocations per operation 58.68 count/op 58.68 count/op No observed change Not flagged
Capture and deliver events (4,096-byte payload, steady)
Shard: capture
Time per operation 1.01 ms/op 1.01 ms/op <0.1% less time Not flagged
Capture and deliver events (4,096-byte payload, steady)
Shard: capture
Idle process memory (RSS) 33.18 MiB 33.24 MiB 0.2% more memory Not flagged
Capture and deliver events (4,096-byte payload, steady)
Shard: capture
Observed peak process memory (RSS) 40.55 MiB 40.93 MiB 0.9% more memory Not flagged
Capture and deliver events (4,096-byte payload, steady)
Shard: capture
Managed heap after settling 9.15 MiB 9.15 MiB <0.1% more memory Not flagged
Capture and deliver events (4,096-byte payload, steady)
Shard: capture
Process memory after settling (RSS) 40.25 MiB 40.91 MiB 1.7% more memory Not flagged
Capture and deliver events (4,096-byte payload, steady)
Shard: capture
Process CPU time per operation 38.29 µs/op 38.08 µs/op 0.6% less time Not flagged
Capture and deliver events (4,096-byte payload, steady)
Shard: capture
Lifetime peak process memory (RSS) 40.72 MiB 40.93 MiB 0.5% more memory Not flagged
Queue an event (32-byte payload)
Shard: capture
Caller-thread CPU time per operation 10.13 µs/op 10.15 µs/op 0.1% more time Not flagged
Queue an event (32-byte payload)
Shard: capture
Time per operation 10.14 µs/op 10.15 µs/op 0.1% more time Not flagged
Queue an event (4,096-byte payload)
Shard: capture
Caller-thread CPU time per operation 10.16 µs/op 10.03 µs/op 1.3% less time Not flagged
Queue an event (4,096-byte payload)
Shard: capture
Time per operation 10.16 µs/op 10.03 µs/op 1.3% less time Not flagged
Evaluate all 10 flags
Shard: flags-scaling
Allocations per operation 343.02 count/op 343.02 count/op <0.1% fewer Not flagged
Evaluate all 10 flags
Shard: flags-scaling
Time per operation 104.65 µs/op 104.55 µs/op <0.1% less time Not flagged
Evaluate all 10 flags
Shard: flags-scaling
Process CPU time per operation 104.65 µs/op 104.55 µs/op 0.1% less time Not flagged
Evaluate all 100 flags
Shard: flags-scaling
Allocations per operation 3,324 count/op 3,324 count/op No observed change Not flagged
Evaluate all 100 flags
Shard: flags-scaling
Time per operation 995.9 µs/op 1 ms/op 0.4% more time Not flagged
Evaluate all 100 flags
Shard: flags-scaling
Process CPU time per operation 995.98 µs/op 1 ms/op 0.4% more time Not flagged
Evaluate all 1,000 flags
Shard: flags-scaling
Allocations per operation 33,113.62 count/op 33,113.59 count/op <0.1% fewer Not flagged
Evaluate all 1,000 flags
Shard: flags-scaling
Time per operation 10.12 ms/op 10.03 ms/op 0.9% less time Not flagged
Evaluate all 1,000 flags
Shard: flags-scaling
Process CPU time per operation 10.12 ms/op 10.03 ms/op 0.9% less time Not flagged
Evaluate 1,000 flags under concurrent load
Shard: flags-memory
Idle process memory (RSS) 38.91 MiB 38.9 MiB <0.1% less memory Not flagged
Evaluate 1,000 flags under concurrent load
Shard: flags-memory
Observed peak process memory (RSS) 40.63 MiB 40.32 MiB 0.7% less memory Not flagged
Evaluate 1,000 flags under concurrent load
Shard: flags-memory
Managed heap after settling 7.97 MiB 7.98 MiB <0.1% more memory Not flagged
Evaluate 1,000 flags under concurrent load
Shard: flags-memory
Process memory after settling (RSS) 39.35 MiB 39.27 MiB 0.2% less memory Not flagged
Evaluate 1,000 flags under concurrent load
Shard: flags-memory
Lifetime peak process memory (RSS) 40.27 MiB 40.03 MiB 0.6% less memory Not flagged
Refresh 1,000 flag definitions
Shard: flags-memory
Idle process memory (RSS) 37.31 MiB 38.17 MiB 2.3% more memory Not flagged
Refresh 1,000 flag definitions
Shard: flags-memory
Observed peak process memory (RSS) 41.48 MiB 41.46 MiB <0.1% less memory Not flagged
Refresh 1,000 flag definitions
Shard: flags-memory
Managed heap after settling 9.48 MiB 9.49 MiB <0.1% more memory Not flagged
Refresh 1,000 flag definitions
Shard: flags-memory
Process memory after settling (RSS) 41.46 MiB 41.22 MiB 0.6% less memory Not flagged
Refresh 1,000 flag definitions
Shard: flags-memory
Lifetime peak process memory (RSS) 41.48 MiB 41.46 MiB <0.1% less memory Not flagged
Evaluate one boolean flag
Shard: flags-single
Allocations per operation 38 count/op 38 count/op No observed change Not flagged
Evaluate one boolean flag
Shard: flags-single
Time per operation 11.97 µs/op 11.89 µs/op 0.7% less time Not flagged
Evaluate one boolean flag
Shard: flags-single
Process CPU time per operation 11.97 µs/op 11.89 µs/op 0.7% less time Not flagged
Evaluate a cohort-filtered flag
Shard: flags-single
Allocations per operation 38 count/op 38 count/op No observed change Not flagged
Evaluate a cohort-filtered flag
Shard: flags-single
Time per operation 18.96 µs/op 18.84 µs/op 0.6% less time Not flagged
Evaluate a cohort-filtered flag
Shard: flags-single
Process CPU time per operation 18.96 µs/op 18.84 µs/op 0.6% less time Not flagged
Evaluate a flag (early match)
Shard: flags-single
Allocations per operation 55 count/op 55 count/op No observed change Not flagged
Evaluate a flag (early match)
Shard: flags-single
Time per operation 22.76 µs/op 22.65 µs/op 0.5% less time Not flagged
Evaluate a flag (early match)
Shard: flags-single
Process CPU time per operation 22.76 µs/op 22.65 µs/op 0.5% less time Not flagged
Evaluate a flag (late match)
Shard: flags-single
Allocations per operation 76 count/op 76 count/op No observed change Not flagged
Evaluate a flag (late match)
Shard: flags-single
Time per operation 31.8 µs/op 31.73 µs/op 0.2% less time Not flagged
Evaluate a flag (late match)
Shard: flags-single
Process CPU time per operation 31.8 µs/op 31.73 µs/op 0.2% less time Not flagged
Evaluate a flag (no match)
Shard: flags-single
Allocations per operation 67 count/op 67 count/op No observed change Not flagged
Evaluate a flag (no match)
Shard: flags-single
Time per operation 27.09 µs/op 26.99 µs/op 0.4% less time Not flagged
Evaluate a flag (no match)
Shard: flags-single
Process CPU time per operation 27.09 µs/op 27 µs/op 0.3% less time Not flagged
Evaluate a property-filtered flag
Shard: flags-single
Allocations per operation 34 count/op 34 count/op No observed change Not flagged
Evaluate a property-filtered flag
Shard: flags-single
Time per operation 13.58 µs/op 13.56 µs/op 0.2% less time Not flagged
Evaluate a property-filtered flag
Shard: flags-single
Process CPU time per operation 13.58 µs/op 13.56 µs/op 0.1% less time Not flagged
Evaluate a regex-filtered flag
Shard: flags-single
Allocations per operation 99 count/op 99 count/op <0.1% fewer Not flagged
Evaluate a regex-filtered flag
Shard: flags-single
Time per operation 40.48 µs/op 40.05 µs/op 1.1% less time Not flagged
Evaluate a regex-filtered flag
Shard: flags-single
Process CPU time per operation 40.48 µs/op 40.05 µs/op 1.1% less time Not flagged
Evaluate a rollout flag
Shard: flags-single
Allocations per operation 31 count/op 31 count/op No observed change Not flagged
Evaluate a rollout flag
Shard: flags-single
Time per operation 10.98 µs/op 11.04 µs/op 0.6% more time Not flagged
Evaluate a rollout flag
Shard: flags-single
Process CPU time per operation 10.98 µs/op 11.04 µs/op 0.6% more time Not flagged
Evaluate a multivariate flag
Shard: flags-single
Allocations per operation 37 count/op 37 count/op No observed change Not flagged
Evaluate a multivariate flag
Shard: flags-single
Time per operation 13.53 µs/op 13.55 µs/op 0.1% more time Not flagged
Evaluate a multivariate flag
Shard: flags-single
Process CPU time per operation 13.53 µs/op 13.55 µs/op 0.1% 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.

Each shard uses its own runner; compare Before PR against With PR within a row, not across shards.

Compared 97102efc5ca6edc42e026e8bdddfb86c66fce3fa → 8f294090b89af30f5492c16fe7c32abbbd25bb02.

Full report and diagnostics

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

`category` is now `PostHog::Emails::TRANSACTIONAL` or a
`PostHog::Emails::MessageCategory` with a key, type, name and optional
description, which PostHog creates or updates by key. A template send
follows its template's category, so it no longer takes one.

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

@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 65a1bec: 1 Must fix, 1 Should fix.

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 lib/posthog/emails.rb Resolved
Comment thread lib/posthog/emails.rb Resolved
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@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 6bdd5bd: no findings.

The category spec now sends through a fake that holds categories by key,
keeps a stored description when the field is missing, clears it on "",
and refuses a marketing send without an unsubscribe link. It asserts the
categories PostHog ends up holding, and pins that "" goes out as "" while
an unset description is left out (contract rev 2.2).

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

@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 b262082: no findings.

The fake of the send endpoint now accepts only a missing category,
"transactional" or a category object, and the spec asserts the
TRANSACTIONAL send is accepted, so a wrong built-in value fails it.

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

@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 66d0a74: no findings.

The retry table now asserts the result: an EmailError with the refused
code when the SDK makes one attempt, and the queued email when it retries.
The send fake looks for the unsubscribe link the way PostHog does: in the
html when it has one, otherwise in the text.

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

@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 8d0930b: no findings.

A code send whose template_key names a template follows that template's
category when category is left out, and PostHog refuses an explicit
TRANSACTIONAL on a template in a category. The fake send endpoint now
models template-bound keys, and the docs no longer call the two the same.

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

@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 0434df8: no findings.

Each row's second response is a success, so a refusal the client wrongly
retries comes back as a sent email, and a retryable refusal it gives up
on comes back as an error. Counting WebMock requests added nothing.

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

@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 8c3d803: no findings.

Silthus and others added 2 commits November 12, 2026 05:22
PostHog now creates a category the first time a send names its key, or
restores a deleted one, and otherwise leaves it unchanged. The category's
name and description apply only at creation; renames happen in PostHog.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PostHog refuses a send whose template_key names a template in a marketing
category with marketing_template. The send fake now does the same, and the
follow-the-template tests seed a transactional category.

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

This branch has not been deployed

No deployments
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.

1 participant