Skip to content

feat(emails): send cc, bcc and attachments - #325

Open
Silthus wants to merge 14 commits into
mainfrom
feat/emails-attachments
Open

Silthus wants to merge 14 commits into
mainfrom
feat/emails-attachments

Conversation

@Silthus

@Silthus Silthus commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor

💡 Motivation and Context

Ruby apps that send transactional email through PostHog can't copy anyone in or attach a file: client.emails.send from #324 takes one recipient and no files. An invoice, a receipt or a ticket export has to go out through another provider. posthog-node and posthog-python now take cc, bcc and attachments (PostHog/posthog-js#5514, PostHog/posthog-python#1172), and the send route accepts them (PostHog/posthog#127988). This PR adds the same three fields to posthog-ruby.

posthog.emails.send(
  to: 'user@example.com', template_key: 'invoice', subject: 'Your invoice', html: rendered_html,
  cc: 'billing@example.com', bcc: ['audit@example.com'],
  attachments: [
    { filename: 'invoice.pdf', content: File.binread('invoice.pdf') },
    { filename: 'logo.png', content: File.binread('logo.png'), content_type: 'image/png', content_id: 'logo' }
  ]
)

Stacked on #324; review that one first.

Important

Release this only after PostHog/posthog#127988 is deployed. Until then the send route refuses a send that has any of the three fields, and names the field.

What it does

  • cc and bcc: one address or a list, like reply_to. An empty list is left out of the request.
  • attachments: a list of Hashes with filename, content, and optionally content_type and content_id. A content_id shows the file inline, referenced from the HTML as cid:<id>. Keys can be Symbols or Strings. An empty list is left out.
  • Content: Ruby has one String type for text and bytes, so the client decides by encoding:
    • a binary String, as File.binread returns, is the file's bytes, and the client encodes it as Base64;
    • a list of byte values, as Resend's Ruby SDK takes (File.binread(path).bytes), is encoded the same way;
    • any other String must already be Base64. Base64 with line breaks is taken only in the exact layout Base64.encode64 writes, and sent without them; other text with line breaks is refused, so a text file read with File.read never goes out garbled. Text that isn't ASCII, such as a PDF read with File.read, can't be Base64 and is refused with a hint to use File.binread.
      Encoding happens once, before the first attempt, so retries send the same body.
  • Refused before any request, with PostHog::EmailError code invalid_attachment, name validation_error, and a field such as attachments__0__content, as in Node and Python:
    • an attachment given by path (the client reads no files);
    • an unknown key, a missing filename or content, or text that isn't Base64 (including text that isn't ASCII).
      The message never holds the file's content.
  • Refused by PostHog, as PostHog::EmailError invalid_request with the field and the server's message, why and fix: blocked file types, unsafe filenames or content IDs, more than 10 addresses per field or 10 files, and more than 512 KB decoded in total.
  • The template form (template_id) refuses all three fields, as it does subject and html.

Impact

Pays into the Workflows team's growth goal for Messaging (email is in scope) through transactional email sends from server SDKs. It removes a reason a Ruby app keeps a second email provider: invoices, receipts and exports need attachments. Expected move: small, up, once released. Run cost: n/a, same send route. Measure: sends with attachments or copies by SDK, not measured yet; it needs the same User-Agent attribution as #324.

Merge Danger

Low, two-way door until release. The change is additive: the three keywords are new and optional, and a send without them builds the same request as before. Releasing the gem makes the shape public, so the content rule (binary String, byte list, or Base64 text) should settle in review. Releasing before the server deploys would make sends with these fields fail with the server naming the field.

💚 How did you test it?

  • Lane gate on df3ff92: bundle exec rspec spec/posthog/emails_spec.rb 71 examples, three runs in random order, 0 failures; the full suite, 2090 examples, 0 failures. bundle exec rubocop: 146 files, no offenses. bundle exec rake public_api:check passes.
  • CI on df3ff92: 20 checks green.
  • Test seam: spec/posthog/emails_spec.rb, describe 'copies and attachments', drives Client.new(...).emails.send against WebMock at the HTTP boundary: the wire body for every content form, Symbol and String keys, empty lists left out, one Base64 encoding shared by every retry, each client-side refusal with its field, and the template form refusing the fields.
  • Live send on df3ff92 through a local PostHog stack: Django at feat(messaging): send cc, bcc and attachments on a code send posthog#127988 (on #127123), cdp-api and the email and hogflow CDP workers at #127986 (on #127995 and #127985), the email flags on, and maildev as the SMTP server. A proxy between the Ruby client and Django records each request and response. Every case calls client.emails.send:
    1. to, two cc, one bcc, a PDF from File.binread and an inline PNG with content_id: 'logo': SendResult with an id. maildev holds one message, with to and both cc in the headers and the bcc address only in the envelope (no Bcc header). Both files arrive byte for byte (sha256), and the PNG is inline with Content-ID: <logo>, which the HTML's cid:logo uses.
    2. A byte list (File.binread(path).bytes) and an already-encoded Base64 string: delivered, both files byte for byte.
    3. An attachment given by path: PostHog::EmailError invalid_attachment, field attachments__0__path. No request reaches PostHog, and no mail arrives.
    4. An .exe: HTTP 400, EmailError invalid_request, field attachments__0__filename, "Mailbox providers block .exe files.", with the server's why and fix. No mail.
    5. Two files of 300 KB: HTTP 400, EmailError invalid_request, field attachments, "Send at most 512 KB of attachments in total, counted after decoding." No mail.
    6. Empty cc, bcc and attachments lists: the request body has none of the three keys, and the email is delivered.
  • Cross-version on df3ff92: bodies from feat(emails): send transactional email and read its status #324's SDK and from this PR go through the real SendEmailSerializer of the server before these fields (213fad6, the base of feat(messaging): send cc, bcc and attachments on a code send posthog#127988) and after (#127988). feat(emails): send transactional email and read its status #324's sends are accepted by the new server. This PR's plain sends and sends with empty lists are accepted by the old server. A send with cc, bcc or attachments is refused there, naming the field, rather than going out without it.
  • Contract test: the same six cases' request bodies also go through code_send_fields.py from #127988 on the Django and DRF versions the server pins. 15 of 15 checks pass.
  • Gaps: none.

📝 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. The YARD docs on Emails#send describe the three keywords.
  • No breaking change or entry added to the changelog.

If releasing new changes

  • Ran pnpm changeset to generate a changeset file. posthog-ruby minor.

🤖 Agent context

Autonomy: Human-driven (agent-assisted)

Built with Claude Code (Claude Opus 5.5) as one slice of a map that brings every server SDK to parity on emails.send. The shape follows posthog-node and posthog-python.

Question Answer Who Where
Do the SDKs take cc, bcc and attachments? Yes, every SDK at parity with Node and Python 👤 Human Silthus/posthog#461
How does Ruby tell file bytes from Base64 text? A binary String or a byte list is bytes and gets encoded; any other String must be Base64, else invalid_attachment 🤖 Agent comment
What does an attachment the client can't send raise? PostHog::EmailError invalid_attachment with a field, as Node and Python do, instead of ArgumentError 🤖 Agent comment

Involvement: no grilling rounds, no prototypes; QA swarm round 1 (nine lenses, including the team's michael and sdk-spec-conformance perspectives) and round 2 on the fixes, with fixes in a3a1aab and e29b8a7, then two clean final reads, the second on df3ff92; live send, cross-version and contract proofs captured.

🤖 Generated with Claude Code

Silthus and others added 6 commits November 2, 2026 15:47
Adds client.emails.send for a template made in PostHog (template_id) or
content your code rendered (template_key, subject, html, text), and
client.emails.get for the status of a sent email. Ports posthog-node and
posthog-python's emails to Ruby: the same wire body, app host rules,
idempotency key, retries and error names.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A child forked while another thread sent email inherited a positive
in-flight count that no thread could settle, so shutdown waited for its
whole timeout, or forever without one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Net::HTTP treats a connection closed partway through a body as the end of
the body, so a truncated 202 raised a non-retryable api_error. Turn EOF
suppression off so the cut-off read raises a network error and the send
retries with the same idempotency key.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The code form takes cc and bcc (one address or a list) and attachments:
Hashes with filename, content, and an optional content_type and
content_id. A binary String, such as File.binread returns, or a list of
byte values is encoded as Base64 once, before the retry loop; any other
String is taken as Base64 already and must look like it. Empty lists
are left out, so a PostHog version without the fields still sends.
A file the client can't send raises EmailError invalid_attachment with
the field, before any request. Mirrors posthog-node (posthog-js#5514)
and posthog-python#1172; the server holds the limits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Explicit nil email options fall back to the defaults, and the client checks
  them before the worker, instance count and flags poller start.
- app_host must be an http or https URL with a host.
- A refusal whose body broke off keeps its status and Retry-After; a corrupt
  compressed body is a network error and retries.
- skip_ssl_verification also applies to email requests.
- Specs: real-socket server reads whole requests and runs without WebMock,
  the backoff spec waits on a queue instead of a fixed sleep, and the
  warning spec reads the logger output.

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

github-actions Bot commented Nov 4, 2026 •

Copy link
Copy Markdown

posthog-ruby-sync Compliance Report

Date: 2026-11-06T07:52:12.635073+00:00
Duration: 56001ms

⚠️ 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 ✅ 9ms
Format Validation.Event Has Uuid ✅ 7ms
Format Validation.Event Has Lib Properties ✅ 6ms
Format Validation.Distinct Id Is String ✅ 9ms
Format Validation.Token Is Present ✅ 8ms
Format Validation.Custom Properties Preserved ✅ 7ms
Format Validation.Event Has Timestamp ✅ 8ms
Format Validation.Non Utc Event Timestamp Is Converted To Utc ✅ 6ms
Retry Behavior.Retries On 503 ✅ 5255ms
Retry Behavior.Does Not Retry On 400 ❌ 7ms
Retry Behavior.Does Not Retry On 401 ❌ 5ms
Retry Behavior.Respects Retry After Header ✅ 8016ms
Retry Behavior.Implements Backoff ❌ 360ms
Retry Behavior.Retries On 500 ✅ 5156ms
Retry Behavior.Retries On 502 ✅ 5146ms
Retry Behavior.Retries On 504 ✅ 5119ms
Retry Behavior.Max Retries Respected ❌ 442ms
Deduplication.Generates Unique Uuids ✅ 17ms
Deduplication.Preserves Uuid On Retry ✅ 5153ms
Deduplication.Preserves Uuid And Timestamp On Retry ✅ 10278ms
Deduplication.Preserves Uuid And Timestamp On Batch Retry ✅ 5121ms
Deduplication.No Duplicate Events In Batch ✅ 19ms
Deduplication.Different Events Have Different Uuids ✅ 10ms
Compression.Sends Gzip When Enabled ✅ 6ms
Batch Format.Uses Proper Batch Structure ✅ 6ms
Batch Format.Flush With No Events Sends Nothing ✅ 4ms
Batch Format.Multiple Events Batched Together ❌ 17ms
Error Handling.Does Not Retry On 403 ❌ 5ms
Error Handling.Does Not Retry On 413 ❌ 5ms
Error Handling.Retries On 408 ✅ 5148ms

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 ✅ 10ms
Request Payload.Flags Request Uses V2 Query Param ✅ 8ms
Request Payload.Flags Request Hits Flags Path Not Decide ✅ 8ms
Request Payload.Flags Request Omits Authorization Header ✅ 8ms
Request Payload.Token In Flags Body Matches Init ✅ 7ms
Request Payload.Groups Round Trip ✅ 7ms
Request Payload.Groups Default To Empty Object ✅ 6ms
Request Payload.Disable Geoip False Propagates As Geoip Disable False ✅ 7ms
Request Payload.Disable Geoip Omitted Defaults To False ❌ 7ms
Request Payload.Flag Keys To Evaluate Contains Only Requested Key ✅ 6ms
Request Lifecycle.No Flags Request On Init Alone ✅ 3ms
Request Lifecycle.No Flags Request On Normal Capture ✅ 6ms
Request Lifecycle.Two Flag Calls Produce Two Remote Requests ✅ 10ms
Request Lifecycle.Mock Response Value Is Returned To Caller ✅ 6ms
Retry Behavior.Retries Flags On 502 ✅ 109ms
Retry Behavior.Retries Flags On 504 ✅ 109ms
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 1a2acc7: 1 Should fix.

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 lib/posthog/emails.rb Resolved
@github-actions

github-actions Bot commented Nov 4, 2026 •

Copy link
Copy Markdown

posthog-ruby-async Compliance Report

Date: 2026-11-06T07:53:02.509974+00:00
Duration: 98290ms

⚠️ 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 ✅ 8ms
Format Validation.Event Has Uuid ✅ 104ms
Format Validation.Event Has Lib Properties ✅ 107ms
Format Validation.Distinct Id Is String ✅ 106ms
Format Validation.Token Is Present ✅ 106ms
Format Validation.Custom Properties Preserved ✅ 107ms
Format Validation.Event Has Timestamp ✅ 105ms
Format Validation.Non Utc Event Timestamp Is Converted To Utc ✅ 7ms
Retry Behavior.Retries On 503 ✅ 5409ms
Retry Behavior.Does Not Retry On 400 ✅ 2108ms
Retry Behavior.Does Not Retry On 401 ✅ 2107ms
Retry Behavior.Respects Retry After Header ✅ 8014ms
Retry Behavior.Implements Backoff ✅ 15715ms
Retry Behavior.Retries On 500 ✅ 5211ms
Retry Behavior.Retries On 502 ✅ 5211ms
Retry Behavior.Retries On 504 ✅ 5211ms
Retry Behavior.Max Retries Respected ✅ 15621ms
Deduplication.Generates Unique Uuids ✅ 109ms
Deduplication.Preserves Uuid On Retry ✅ 5210ms
Deduplication.Preserves Uuid And Timestamp On Retry ✅ 10417ms
Deduplication.Preserves Uuid And Timestamp On Batch Retry ✅ 5213ms
Deduplication.No Duplicate Events In Batch ✅ 109ms
Deduplication.Different Events Have Different Uuids ✅ 106ms
Compression.Sends Gzip When Enabled ✅ 105ms
Batch Format.Uses Proper Batch Structure ✅ 106ms
Batch Format.Flush With No Events Sends Nothing ✅ 6ms
Batch Format.Multiple Events Batched Together ✅ 108ms
Error Handling.Does Not Retry On 403 ✅ 2107ms
Error Handling.Does Not Retry On 413 ✅ 2108ms
Error Handling.Retries On 408 ✅ 5211ms

Feature_Flags Tests

⚠️ 16/17 tests passed, 1 failed

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

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']

Silthus and others added 2 commits November 4, 2026 07:16
A reply whose body broke off is a network error again, so a cut-off
409 idempotency_key_in_flight retries as before. It now keeps the head's
Retry-After: the retry waits for it, and one over 30 seconds raises at once.

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

Silthus commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor Author

Note

🤖 Claude Opus 5.5 responding on behalf of Michael

Decisions made while building this PR:

  • 🤖 How does Ruby tell file bytes from Base64 text? Ruby has one String type for both, so the encoding decides. A binary String (File.binread) or a list of byte values (Resend's Ruby style, File.binread(path).bytes) is the file's bytes, and the client encodes it. Any other String must already be Base64; text that isn't raises invalid_attachment naming the field. The alternative, a separate content_base64: key, would differ from Node, Python and the server's field names.
  • 🤖 What does an attachment the client can't send raise? PostHog::EmailError with code invalid_attachment (name validation_error) and a field such as attachments__0__content, as Node and Python do, instead of the ArgumentError other bad arguments raise. The caller handles a refused file the same way whether the client or PostHog refused it.
  • 🤖 Is a contract test enough in place of the live send? No longer needed. The live send ran through a local PostHog stack (Django at feat(messaging): send cc, bcc and attachments on a code send posthog#127988, the CDP chain to #127986, maildev) at df3ff92 and passed every case. The contract test stays as extra evidence.

Silthus and others added 4 commits November 4, 2026 07:23
The spec released the retry on a 50 ms timer, so it passed even when
shutdown returned at once. It now holds the retry until it has seen
shutdown still running, then releases it and expects true.

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

- A String that isn't ASCII, such as a PDF read with File.read or UTF-16
  text, raised a bare ArgumentError from the Base64 match. It now raises
  invalid_attachment with the field and the File.binread hint.
- Base64 with line breaks, as Base64.encode64 writes it, is sent without
  them instead of being refused.
- The retry spec checks both attempts carry the encoded file, and the
  Base64Check helper is inlined.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Removing every line break let a text file read with File.read, such as
"ABCD1234\nEFGH5678\n", pass as Base64, so the recipient got a garbled
file. Line breaks are now removed only when the content is exactly what
Base64.encode64 writes; other text is refused as invalid_attachment.

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

Silthus commented Nov 4, 2026

Copy link
Copy Markdown
Contributor Author

@PostHog review

@posthog

posthog Bot commented Nov 4, 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 df3ff92: no findings.

@stamphog stamphog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Not approved — this change needs a human reviewer.

Re-adding the stamphog label gives the same result unless the changed files change.

stamphog declined this pull request because two gates failed. The deny-list gate matched public_api: the change edits public_api_snapshot.txt and adds cc:, bcc: and attachments: to the public signature of PostHog::Emails#send in lib/posthog/emails.rb. The tier gate classified it as T2-never (230 lines, 5 files, cross-cutting feature), which a bot can't clear. The size and prerequisites gates passed, but they don't change the outcome.

The author should ask a human reviewer, such as a maintainer who owns the emails SDK, to review this pull request. The changes to the public API surface and the attachment content rules need that kind of review.

  • posthog[bot] reviewed the current head.
Gate mechanics and policy version
Gate Result
prerequisites ✓ all clear
deny-list ✗ matches: public_api
size ✓ 109L, 2F substantive, 230L/5F incl. docs/generated/snapshots — within ceiling
tier ✗ classified as T2-never: T2-never (230L, 5F, cross-cutting, feat)
stamphog 2.7.0 .stamphog/policy.yml @ unknown · reviewed head df3ff92

# Conflicts:
#	lib/posthog/client.rb
#	lib/posthog/email_error.rb
#	lib/posthog/emails.rb
#	public_api_snapshot.txt
#	spec/posthog/emails_spec.rb
@github-actions

github-actions Bot commented Nov 6, 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 111.07 µs/op 111.14 µs/op <0.1% more time Not flagged
Capture and deliver events (32-byte payload, burst)
Shard: capture
Idle process memory (RSS) 28.46 MiB 28.43 MiB 0.1% less memory Not flagged
Capture and deliver events (32-byte payload, burst)
Shard: capture
Observed peak process memory (RSS) 31.22 MiB 31.19 MiB <0.1% less memory Not flagged
Capture and deliver events (32-byte payload, burst)
Shard: capture
Managed heap after settling 5.27 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.46 MiB 30.43 MiB 0.1% less memory Not flagged
Capture and deliver events (32-byte payload, burst)
Shard: capture
Process CPU time per operation 24.93 µs/op 25.03 µs/op 0.4% more time Not flagged
Capture and deliver events (32-byte payload, burst)
Shard: capture
Lifetime peak process memory (RSS) 30.99 MiB 31.01 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.92 µs/op 110.95 µs/op <0.1% more time Not flagged
Capture and deliver events (4,096-byte payload, burst)
Shard: capture
Idle process memory (RSS) 33.23 MiB 33.29 MiB 0.2% more memory Not flagged
Capture and deliver events (4,096-byte payload, burst)
Shard: capture
Observed peak process memory (RSS) 41.73 MiB 42.44 MiB 1.7% more memory Not flagged
Capture and deliver events (4,096-byte payload, burst)
Shard: capture
Managed heap after settling 9.14 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) 39.77 MiB 40.27 MiB 1.3% more memory Not flagged
Capture and deliver events (4,096-byte payload, burst)
Shard: capture
Process CPU time per operation 35.22 µs/op 34.9 µs/op 0.9% less time Not flagged
Capture and deliver events (4,096-byte payload, burst)
Shard: capture
Lifetime peak process memory (RSS) 41.58 MiB 42.24 MiB 1.6% more 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.02 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.34 MiB 28.37 MiB <0.1% more memory Not flagged
Capture and deliver events (32-byte payload, steady)
Shard: capture
Observed peak process memory (RSS) 30.49 MiB 30.44 MiB 0.1% less memory Not flagged
Capture and deliver events (32-byte payload, steady)
Shard: capture
Managed heap after settling 5.27 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.49 MiB 30.44 MiB 0.1% less memory Not flagged
Capture and deliver events (32-byte payload, steady)
Shard: capture
Process CPU time per operation 35.91 µs/op 36.2 µs/op 0.8% more time Not flagged
Capture and deliver events (32-byte payload, steady)
Shard: capture
Lifetime peak process memory (RSS) 30.49 MiB 30.44 MiB 0.1% less 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% more time Not flagged
Capture and deliver events (4,096-byte payload, steady)
Shard: capture
Idle process memory (RSS) 33.22 MiB 33.28 MiB 0.2% more memory Not flagged
Capture and deliver events (4,096-byte payload, steady)
Shard: capture
Observed peak process memory (RSS) 40.52 MiB 40.65 MiB 0.3% more memory Not flagged
Capture and deliver events (4,096-byte payload, steady)
Shard: capture
Managed heap after settling 9.14 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.22 MiB 40.34 MiB 0.3% more memory Not flagged
Capture and deliver events (4,096-byte payload, steady)
Shard: capture
Process CPU time per operation 42.68 µs/op 43.68 µs/op 2.4% more time Not flagged
Capture and deliver events (4,096-byte payload, steady)
Shard: capture
Lifetime peak process memory (RSS) 40.86 MiB 40.93 MiB 0.2% more memory Not flagged
Queue an event (32-byte payload)
Shard: capture
Caller-thread CPU time per operation 10.87 µs/op 10.8 µs/op 0.7% less time Not flagged
Queue an event (32-byte payload)
Shard: capture
Time per operation 10.87 µs/op 10.79 µs/op 0.7% less time Not flagged
Queue an event (4,096-byte payload)
Shard: capture
Caller-thread CPU time per operation 10.88 µs/op 10.89 µs/op <0.1% more time Not flagged
Queue an event (4,096-byte payload)
Shard: capture
Time per operation 10.88 µs/op 10.89 µs/op <0.1% more time Not flagged
Evaluate all 10 flags
Shard: flags-scaling
Allocations per operation 343.02 count/op 343.02 count/op No observed change Not flagged
Evaluate all 10 flags
Shard: flags-scaling
Time per operation 157.02 µs/op 155.88 µs/op 0.7% less time Not flagged
Evaluate all 10 flags
Shard: flags-scaling
Process CPU time per operation 156.94 µs/op 155.86 µs/op 0.7% 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 1.5 ms/op 1.49 ms/op 0.3% less time Not flagged
Evaluate all 100 flags
Shard: flags-scaling
Process CPU time per operation 1.5 ms/op 1.49 ms/op 0.4% less time Not flagged
Evaluate all 1,000 flags
Shard: flags-scaling
Allocations per operation 33,113.62 count/op 33,113.5 count/op <0.1% fewer Not flagged
Evaluate all 1,000 flags
Shard: flags-scaling
Time per operation 15.56 ms/op 15.49 ms/op 0.5% less time Not flagged
Evaluate all 1,000 flags
Shard: flags-scaling
Process CPU time per operation 15.56 ms/op 15.49 ms/op 0.5% less time Not flagged
Evaluate 1,000 flags under concurrent load
Shard: flags-memory
Idle process memory (RSS) 39.4 MiB 38.88 MiB 1.3% less memory Not flagged
Evaluate 1,000 flags under concurrent load
Shard: flags-memory
Observed peak process memory (RSS) 41.1 MiB 40.72 MiB 0.9% 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.7 MiB 39.47 MiB 0.6% less memory Not flagged
Evaluate 1,000 flags under concurrent load
Shard: flags-memory
Lifetime peak process memory (RSS) 40.88 MiB 40.41 MiB 1.2% less memory Not flagged
Refresh 1,000 flag definitions
Shard: flags-memory
Idle process memory (RSS) 38.11 MiB 37.17 MiB 2.5% less memory Not flagged
Refresh 1,000 flag definitions
Shard: flags-memory
Observed peak process memory (RSS) 41.4 MiB 40.28 MiB 2.7% less memory Not flagged
Refresh 1,000 flag definitions
Shard: flags-memory
Managed heap after settling 9.48 MiB 9.48 MiB <0.1% more memory Not flagged
Refresh 1,000 flag definitions
Shard: flags-memory
Process memory after settling (RSS) 41.26 MiB 40.11 MiB 2.8% less memory Not flagged
Refresh 1,000 flag definitions
Shard: flags-memory
Lifetime peak process memory (RSS) 41.29 MiB 40.24 MiB 2.6% 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.85 µs/op 11.79 µs/op 0.6% less time Not flagged
Evaluate one boolean flag
Shard: flags-single
Process CPU time per operation 11.85 µs/op 11.79 µs/op 0.6% 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.76 µs/op 18.95 µs/op 1% more time Not flagged
Evaluate a cohort-filtered flag
Shard: flags-single
Process CPU time per operation 18.76 µs/op 18.95 µs/op 1% more 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.62 µs/op 22.86 µs/op 1.1% more time Not flagged
Evaluate a flag (early match)
Shard: flags-single
Process CPU time per operation 22.62 µs/op 22.86 µs/op 1.1% more 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.87 µs/op 31.32 µs/op 1.7% less time Not flagged
Evaluate a flag (late match)
Shard: flags-single
Process CPU time per operation 31.86 µs/op 31.32 µs/op 1.7% 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.24 µs/op 26.98 µs/op 1% less time Not flagged
Evaluate a flag (no match)
Shard: flags-single
Process CPU time per operation 27.24 µs/op 26.97 µs/op 1% 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.56 µs/op 13.28 µs/op 2% less time Not flagged
Evaluate a property-filtered flag
Shard: flags-single
Process CPU time per operation 13.56 µs/op 13.28 µs/op 2% less time Not flagged
Evaluate a regex-filtered flag
Shard: flags-single
Allocations per operation 99 count/op 99 count/op No observed change Not flagged
Evaluate a regex-filtered flag
Shard: flags-single
Time per operation 40.6 µs/op 40.6 µs/op <0.1% less time Not flagged
Evaluate a regex-filtered flag
Shard: flags-single
Process CPU time per operation 40.59 µs/op 40.59 µs/op <0.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.85 µs/op 10.96 µs/op 1% more time Not flagged
Evaluate a rollout flag
Shard: flags-single
Process CPU time per operation 10.85 µs/op 10.96 µs/op 1% 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.57 µs/op 13.74 µs/op 1.3% more time Not flagged
Evaluate a multivariate flag
Shard: flags-single
Process CPU time per operation 13.57 µs/op 13.74 µs/op 1.3% 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 6f660771d9430dce128dfeab3063a2a53dabc420 → 44c945b357ef71ad6194034e2e517b14f6efa37c.

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.

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