Repository navigation
feat(emails): let a code send choose its tracking and sensitivity - #348
Conversation
A send with template_key takes track_opens_and_clicks. false sends the email without PostHog's open pixel or rewritten links. The SDK sends the field only when the caller sets it, so a default send keeps today's body and still works against an API that predates the field. A send with template_id refuses it, like capture_engagement. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
posthog-ruby-sync Compliance ReportDate: 2026-11-11T04:14:04.772020+00:00
|
| Test | Status | Duration |
|---|---|---|
| Format Validation.Event Has Required Fields | ✅ | 8ms |
| Format Validation.Event Has Uuid | ✅ | 5ms |
| Format Validation.Event Has Lib Properties | ✅ | 5ms |
| Format Validation.Distinct Id Is String | ✅ | 8ms |
| Format Validation.Token Is Present | ✅ | 6ms |
| Format Validation.Custom Properties Preserved | ✅ | 5ms |
| Format Validation.Event Has Timestamp | ✅ | 6ms |
| Format Validation.Non Utc Event Timestamp Is Converted To Utc | ✅ | 5ms |
| Retry Behavior.Retries On 503 | ✅ | 5256ms |
| Retry Behavior.Does Not Retry On 400 | ❌ | 6ms |
| Retry Behavior.Does Not Retry On 401 | ❌ | 5ms |
| Retry Behavior.Respects Retry After Header | ✅ | 8013ms |
| Retry Behavior.Implements Backoff | ❌ | 289ms |
| Retry Behavior.Retries On 500 | ✅ | 5158ms |
| Retry Behavior.Retries On 502 | ✅ | 5145ms |
| Retry Behavior.Retries On 504 | ✅ | 5114ms |
| Retry Behavior.Max Retries Respected | ❌ | 632ms |
| Deduplication.Generates Unique Uuids | ✅ | 14ms |
| Deduplication.Preserves Uuid On Retry | ✅ | 5111ms |
| Deduplication.Preserves Uuid And Timestamp On Retry | ✅ | 10246ms |
| Deduplication.Preserves Uuid And Timestamp On Batch Retry | ✅ | 5119ms |
| Deduplication.No Duplicate Events In Batch | ✅ | 15ms |
| Deduplication.Different Events Have Different Uuids | ✅ | 8ms |
| Compression.Sends Gzip When Enabled | ✅ | 5ms |
| Batch Format.Uses Proper Batch Structure | ✅ | 4ms |
| Batch Format.Flush With No Events Sends Nothing | ✅ | 3ms |
| Batch Format.Multiple Events Batched Together | ❌ | 13ms |
| Error Handling.Does Not Retry On 403 | ❌ | 4ms |
| Error Handling.Does Not Retry On 413 | ❌ | 4ms |
| Error Handling.Retries On 408 | ✅ | 5112ms |
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 | ✅ | 9ms |
| Request Payload.Flags Request Uses V2 Query Param | ✅ | 8ms |
| Request Payload.Flags Request Hits Flags Path Not Decide | ✅ | 7ms |
| 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 | ✅ | 5ms |
| Request Payload.Disable Geoip False Propagates As Geoip Disable False | ✅ | 5ms |
| Request Payload.Disable Geoip Omitted Defaults To False | ❌ | 6ms |
| 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 | ✅ | 5ms |
| 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 | ✅ | 138ms |
| Retry Behavior.Retries Flags On 504 | ✅ | 156ms |
| Side Effect Events.Get Feature Flag Captures Feature Flag Called Event | ✅ | 7ms |
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-11T04:14:44.733563+00:00
|
| Test | Status | Duration |
|---|---|---|
| Format Validation.Event Has Required Fields | ✅ | 111ms |
| Format Validation.Event Has Uuid | ✅ | 107ms |
| Format Validation.Event Has Lib Properties | ✅ | 110ms |
| Format Validation.Distinct Id Is String | ✅ | 107ms |
| Format Validation.Token Is Present | ✅ | 108ms |
| Format Validation.Custom Properties Preserved | ✅ | 108ms |
| Format Validation.Event Has Timestamp | ✅ | 107ms |
| Format Validation.Non Utc Event Timestamp Is Converted To Utc | ✅ | 109ms |
| Retry Behavior.Retries On 503 | ✅ | 5413ms |
| Retry Behavior.Does Not Retry On 400 | ✅ | 2109ms |
| Retry Behavior.Does Not Retry On 401 | ✅ | 2110ms |
| Retry Behavior.Respects Retry After Header | ✅ | 8116ms |
| Retry Behavior.Implements Backoff | ✅ | 15723ms |
| Retry Behavior.Retries On 500 | ✅ | 5213ms |
| Retry Behavior.Retries On 502 | ✅ | 5213ms |
| Retry Behavior.Retries On 504 | ✅ | 5212ms |
| Retry Behavior.Max Retries Respected | ✅ | 15523ms |
| Deduplication.Generates Unique Uuids | ✅ | 111ms |
| Deduplication.Preserves Uuid On Retry | ✅ | 5212ms |
| Deduplication.Preserves Uuid And Timestamp On Retry | ✅ | 10419ms |
| Deduplication.Preserves Uuid And Timestamp On Batch Retry | ✅ | 5211ms |
| Deduplication.No Duplicate Events In Batch | ✅ | 111ms |
| Deduplication.Different Events Have Different Uuids | ✅ | 108ms |
| Compression.Sends Gzip When Enabled | ✅ | 107ms |
| Batch Format.Uses Proper Batch Structure | ✅ | 108ms |
| Batch Format.Flush With No Events Sends Nothing | ✅ | 6ms |
| Batch Format.Multiple Events Batched Together | ✅ | 112ms |
| Error Handling.Does Not Retry On 403 | ✅ | 2108ms |
| Error Handling.Does Not Retry On 413 | ✅ | 2110ms |
| Error Handling.Retries On 408 | ✅ | 5212ms |
Feature_Flags Tests
View Details
| Test | Status | Duration |
|---|---|---|
| Request Payload.Request With Person Properties Device Id | ✅ | 108ms |
| Request Payload.Flags Request Uses V2 Query Param | ✅ | 108ms |
| Request Payload.Flags Request Hits Flags Path Not Decide | ✅ | 107ms |
| Request Payload.Flags Request Omits Authorization Header | ✅ | 107ms |
| 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 | ✅ | 108ms |
| 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 | ✅ | 114ms |
| Request Lifecycle.Mock Response Value Is Returned To Caller | ✅ | 106ms |
| Retry Behavior.Retries Flags On 502 | ✅ | 246ms |
| Retry Behavior.Retries Flags On 504 | ✅ | 210ms |
| Side Effect Events.Get Feature Flag Captures Feature Flag Called Event | ✅ | 110ms |
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']
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 |
PostHog never rewrites the links of an email sent from code, so track_opens_and_clicks: false removes only the open pixel. The docstring and changeset no longer say it removes rewritten links. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Replace track_opens_and_clicks with tracking: true, false, or opens, clicks and engagement one by one. It is left out of the request when unset. capture_engagement is deprecated in favor of tracking's engagement and keeps working; the client leaves it out when tracking is set, unless the caller sets it too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A code send that sets tracking must not also carry capture_engagement, or PostHog refuses it. PostHog reads a missing capture_engagement as true, so a default send still captures engagement. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
PostHog Review · Standard · reviewed afb4289: 1 Must 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.
A send with template_key now takes an optional sensitive flag. The client sends it only when the caller sets it. Left out, PostHog keeps today's behavior: the matching template's setting, else sensitive. Click tracking follows it unless tracking sets clicks. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PR overviewNo security issues were found in this pull request. Reviews
|
There was a problem hiding this comment.
Not approved — this change needs a human reviewer.
Re-adding the stamphog label gives the same result unless the changed files change.
stamphog won't auto-review this pull request because two gates refused it. The deny-list gate flagged it for touching public_api: public_api_snapshot.txt and the PostHog::Emails#send signature in lib/posthog/emails.rb gain the new sensitive: and tracking: parameters. The tier gate classified it as T2-never (98 lines across 4 files, cross-cutting, a feature), and that tier is never handled automatically. The size gate passed, so splitting is not what's needed here. Ask a human reviewer, such as a maintainer who owns the SDK's public API, to review it.
- posthog[bot] reviewed the current head.
Gate mechanics and policy version
| Gate | Result | |
|---|---|---|
| prerequisites | ✓ | all clear |
| deny-list | ✗ | matches: public_api |
| size | ✓ | 44L, 1F substantive, 98L/4F incl. docs/generated/snapshots — within ceiling |
| tier | ✗ | classified as T2-never: T2-never (98L, 4F, cross-cutting, feat) |
| stamphog 2.7.0 | .stamphog/policy.yml @ unknown · reviewed head f17a02e |
marandaneto
left a comment
There was a problem hiding this comment.
The code looks good; no qualifying findings. Verdict: correct.
Note
👀 Reviewed together with #131780. The team question for this change is there. Once you're happy with #131780, please approve this one too.
💡 Motivation and Context
Problem: A code send (
template_keywith your own content) can't say what PostHog does with it. It always carries PostHog's open pixel. PostHog treats it as sensitive, with no click tracking, unless its key matches a template made in PostHog that isn't sensitive. Callers can't send an email fully untracked, such as a welcome email whose spec forbids open pixels. They can't turn on click tracking for links that are safe to rewrite, or mark an email as sensitive or not.Changes:
tracking:. Left out, PostHog counts opens and captures engagement events, and tracks clicks only when the email isn't sensitive.trueturns on opens, clicks and engagement events.falseturns them all off.{ clicks: true }setsopens(default on),clicks(default: on unless the email is sensitive) andengagement(default on) one by one.sensitive:too.truemarks an email that carries login links, reset tokens or codes: its links stay unchanged unless tracking turns clicks on, its subject stays off engagement events, and its content stays out of logs.sensitiveandtrackingonly when you set them.capture_engagementonly when you set it too. PostHog refuses a send that sets both, and it reads a missing value as on, so a default send captures engagement as before.capture_engagement:is marked deprecated in its YARD doc, in favor oftracking: { engagement: false }.tracking:andsensitive:are refused on atemplate_idsend before any request. Asensitive:that isn't a boolean raisesArgumentError. A value that isn't a bool or a Hash of bools raisesArgumentError.sendYARD docs andpublic_api_snapshot.txt. Change intent:.changeset/emails-tracking.md(minor).This replaces
track_opens_and_clicksfrom the first version of this PR, which never shipped. The emails sdk-spec doesn't cover the code-content form, so no spec covers these fields.Release once PostHog's API takes
trackingandsensitivein both regions: #131780, #131756.💚 How did you test it?
In a
ruby:3.4container, CI's commands:bundle exec rubocop: no offenses.bundle exec rake public_api:check: up to date.bundle exec rspec spec/posthog/emails_spec.rb: 71 examples, 0 failures.rspecrun fails only inspec/posthog/rails/template_backtrace_spec.rb, which fails the same way onmainin this container (24 of 32).spec/posthog/emails_spec.rbchecks the sent body fortrue,false,{ clicks: true }, nil kinds dropped and{}, thatcapture_engagementandsensitiveare left out unless set, both engagement fields together, andsensitive: trueandfalsenext totracking. It also checks that a template send refusessensitive:and that a non-boolean is refused.With the line that sends
sensitiveremoved, the new tests fail, so they catch a client that drops the field.Not run against a live PostHog: the API takes
trackingandsensitiveonly after the two PRs above deploy. A real send through this branch is part of the SDK release gate.📝 Checklist
If releasing new changes
pnpm changesetto generate a changeset file🤖 Agent context
Autonomy: Human-driven (agent-assisted)
claude-opus-5-5) in Claude Code, as one of several SDK lanes adding the same field.🤖 Generated with Claude Code