Repository navigation
Conversation
`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>
|
Note Decisions recorded by Claude Code (Claude Opus 5.5) while building this PR. Updated for contract revision 2.
|
posthog-ruby-sync Compliance ReportDate: 2026-11-12T14:42:04.408060+00:00
|
| 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
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-ruby-async Compliance ReportDate: 2026-11-12T14:42:39.286848+00:00
|
| 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
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>
SDK performance: posthog-rubyBenchmarks 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
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 102 metric rows; 102 rows shown in all results. Omitted or shortened data remain in |
`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>
There was a problem hiding this comment.
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.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
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>
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>
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>
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 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>
💡 Motivation and Context
Developers who send email from Ruby with
posthog.emails.sendcan 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
categoryto code sends (thetemplate_keyform):template_keynames a template in a transactional category, that template's category. A template made in PostHog is transactional: PostHog refuses a send whosetemplate_keynames a template in a marketing category withmarketing_template.PostHog::Emails::TRANSACTIONAL: a transactional email with no category. It is sent as"transactional", so PostHog refuses it withcategory_mismatchfor a template in a category.PostHog::Emails::MessageCategorywithkey,type(marketingortransactional),nameand an optionaldescription. 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. Itsnameanddescriptionapply 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.A template send (
template_id) follows the template's own category, so it refusescategorywith anArgumentError, the way it refusessubject.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_typeto thetransactional email api sendusage event, and its insight counts code sends bycategory_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
categorykeyword,MessageCategoryandTRANSACTIONALcategoryto a code send get it on the wire, and the server's category refusals asEmailError; no caller does yet.categoryis not sent.template_keynames a template in a marketing category getsmarketing_template, and the docs say so. Template categories are parked server-side, so no marketing template send ships from this gem.ac055ee) is create-only, so a send never renames a category or changes its description, as these docs say.Rollout
marketing_template.Changes
PostHog::Emails::MessageCategory, a keywordStructlike the SDK'sSendResultandStatus. The wire carries it as an object.descriptionis left out whenniland 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.categoryisTRANSACTIONALor aMessageCategory,keyandnameare strings,typeismarketingortransactional, anddescriptionis a string when set. PostHog owns the key format and the lengths.EmailError, namedvalidation_error, and none retries. They arecategory_mismatch,too_many_categories,unsubscribe_link_requiredandcopies_not_allowed(400), andrecipient_opted_out(422). The#senddocs and the changeset also namemarketing_template(400), which PostHog returns for atemplate_keythat names a template in a marketing category.Emails,MessageCategoryand#send, a changeset (posthog-ruby: minor), and the public API snapshot.categoryyet. This PR follows the contract in the backend PR.💚 How did you test it?
spec/posthog/emails_spec.rb, 74 examples, green at8f29409), driven throughclient.emails.sendwith WebMock:newsletterwith 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, andTRANSACTIONALcreates no category. Mutation-checked atcb57192: putting the old rename back in the fake fails the create-only spec;categoryis not sent;template_keythat names a template in a transactional category (receipts). A send that leavescategoryout is filed under the template's category, and a send withTRANSACTIONALraises anEmailErrorwith codecategory_mismatchand files nothing. Mutation-checked at8c3d803: dropping an explicitTRANSACTIONALfails the refusal spec, and sending"transactional"for a left-out category fails the filing spec;template_keythat names a template in a marketing category raises anEmailErrorwith codemarketing_templateand 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;categoryraisesArgumentErrorbefore any request;typeand a non-stringdescriptionraiseArgumentErrorbefore any request;EmailErrorwith the rightname. In the retry table each refusal is followed by a success, sorecipient_opted_outraising its code shows it is not retried. Mutation-checked at8c3d803: retrying every refusal fails the three no-retry rows, and retrying none fails the three retry rows.65a1bec. Later commits change only specs, doc comments and the changeset (bodies shortened here). Case 2 shows that an explicitTRANSACTIONALreaches the wire, and case 1 that a left-out category does not:Requests and results
8f29409, in theruby:3.2Docker image:bundle exec rubocop(no offenses) andbundle exec rake public_api:checkpass.bundle exec rspec: 24 failures, all inspec/posthog/rails/template_backtrace_spec.rb. The same 24 fail at the base commit97102efin the same image, and CI's Ruby 3.2 passes, so they don't come from this change.📝 Checklist
If releasing new changes
pnpm changesetto 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.
categorycarries"transactional", or a category object withkey,type,nameand optionaldescription; replaced a plain category keycategoryTRANSACTIONALversus a left-outcategorytemplate_key's template category,TRANSACTIONALis refused on a template in a category; docs name the exceptionnameanddescriptionapply only at creation; replaced the upsert by keymarketing_template; template categories wait for their own decisionPostHog::Emails::MessageCategory, a keywordStructlike the SDK's other value types; a Hash is refused, so there is one way to pass itPostHog::Emails::TRANSACTIONALInvolvement: 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
0434df8and on8c3d803found nothing. The proof judge asked that the retry table stop counting requests and assert only what the caller gets, which8c3d803does. PostHog Review on65a1becraised two findings, both declined because they follow the backend PR's earlier contract revision; the replies give the reasons. Its reviews of later heads,0434df8and8c3d803included, found nothing. After the create-only decision, GPT-6.1 Sol's fixes round oncb57192raised 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 on8f29409found nothing. Live-run proof captured against a recorder; the real-server run is owed.🤖 Generated with Claude Code