Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 41 additions & 3 deletions .github/workflows/sdk-compliance.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,47 @@ on:

jobs:
compliance:
name: PostHog SDK compliance tests
name: PostHog Ruby (${{ matrix.mode }}) compliance
strategy:
fail-fast: false
matrix:
include:
- mode: async
dockerfile: sdk_compliance_adapter/Dockerfile
- mode: sync
dockerfile: sdk_compliance_adapter/Dockerfile.sync
uses: PostHog/posthog-sdk-test-harness/.github/workflows/test-sdk-action.yml@6d19abb9c81e2262dacbe340e7dddda9c871c178
with:
adapter-dockerfile: "sdk_compliance_adapter/Dockerfile"
adapter-dockerfile: ${{ matrix.dockerfile }}
adapter-context: "."
test-harness-version: "0.10.0"
test-harness-version: "1.0.0"
report-name: sdk-compliance-ruby-${{ matrix.mode }}
sdk-type: server
continue-on-error: true

report-inventory:
name: Verify compliance report inventory
needs: compliance
if: always()
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
with:
pattern: sdk-compliance-ruby-*
path: reports
- name: Require both complete reports (assertion failures remain advisory)
run: |
python3 - <<'PY'
import pathlib
import re

for mode in ('async', 'sync'):
path = pathlib.Path(f'reports/sdk-compliance-ruby-{mode}/sdk-compliance-report.md')
report = path.read_text()
assert report.startswith(f'# posthog-ruby-{mode} Compliance Report\n'), path
counts = re.findall(r'\*\*(\d+)/(\d+)\*\* tests passed', report)
assert [int(total) for _, total in counts] == [47, 30, 17], (path, counts)
rows = re.findall(r'^\| .+ \| [鉁呪潓] \| \d+ms \|$', report, re.MULTILINE)
assert len(rows) == 47, (path, len(rows))
print(f'{mode}: {counts[0][0]}/47 passed; all 47 results present')
PY
15 changes: 15 additions & 0 deletions sdk_compliance_adapter/Dockerfile.sync
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
FROM ruby:3.3-slim

WORKDIR /app

RUN gem install concurrent-ruby --no-document

COPY lib/ /app/lib/
COPY sdk_compliance_adapter/adapter.rb /app/adapter.rb

ENV RUBYLIB=/app/lib
ENV SDK_MODE=sync

EXPOSE 8080

CMD ["ruby", "/app/adapter.rb"]
71 changes: 71 additions & 0 deletions sdk_compliance_adapter/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# Ruby compliance adapter

This adapter loads `posthog` from the checkout's `lib/` and constructs the public
`PostHog::Client`. `SDK_MODE=async` (default) uses the queue/worker;
`SDK_MODE=sync` uses the existing `sync_mode: true` per-event transport. `PORT`
defaults to 8080. Neither profile loads the Rails integration.

Capture UUIDs are generated by the SDK when omitted. The public `before_send`
callback observes the UUID for the adapter response without modifying the event.
An explicit UUID is forwarded to `capture`. Timestamp strings are parsed into
`Time` objects; UTC conversion and serialization remain SDK-owned. Compression
uses the SDK's gzip default unless `enable_compression` is supplied at init.

Flags use `evaluate_flags(..., flag_keys: [key]).get_flag(key)`, not the deprecated
`get_feature_flag` API. The SDK owns response parsing, retries and the real
`$feature_flag_called` event. No local-evaluation secret is configured, so the
remote profile evaluates on each action. Omitted `disable_geoip` remains omitted;
explicit values are forwarded.

## Inventory and interpretation

CI uses harness **1.0.0**, server wire format, sequential execution, and separate
async/sync artifacts. Each profile selects the unchanged **47** definitions:
30 V0 capture and 17 feature flags. Assertion failures remain advisory; a
separate inventory check requires both complete reports. V1, dedicated AI and
non-gzip codecs are unsupported and are not advertised.

Local harness 1.0.0 results with Ruby 3.4.7 and SDK 3.23.6:

| Profile | Selected | Passed | Failed |
| --- | ---: | ---: | ---: |
| async | 47 | 46 | 1 |
| sync | 47 | 45 | 2 |

Both profiles expose
`feature_flags.request_payload.disable_geoip_omitted_defaults_to_false`: the
native SDK omits `geoip_disable`, while this definition requires literal false.
Explicit false propagation passes. The adapter does not override this default.

Sync additionally fails `capture.batch_format.multiple_events_batched_together`
(five requests rather than one). Sync does not accumulate a multi-event batch,
so this definition and
`capture.deduplication.preserves_uuid_and_timestamp_on_batch_retry` are not
multi-event batching coverage for that profile. The latter passes by inspecting
a retried single-event request. Both definitions still execute without filtering;
the other 28 capture definitions exercise supported synchronous behavior.

The new UTC override case and SDK-generated UUID presence/uniqueness definitions
pass in both profiles. These results certify neither every Ruby version nor the
Docker runtime until CI runs.

`/state` retains diagnostic limitations: transport observation covers capture
requests, not flags HTTP retries; capture counts exclude automatic flag-access
events; pending counts can retain asynchronously discarded events after terminal
errors. Wire assertions use the harness mock's requests, not these counters.
`events_flushed` is the successful-send delta during a flush, not a cumulative
count; synchronous captures have already sent before flushing.

## Running

From the repository root:

```sh
bundle exec rspec spec/sdk_compliance_adapter_spec.rb
SDK_MODE=async docker compose -f sdk_compliance_adapter/docker-compose.yml up --build --abort-on-container-exit
SDK_MODE=sync docker compose -f sdk_compliance_adapter/docker-compose.yml up --build --abort-on-container-exit
```

For a native launch, unset `STUB` (it disables SDK HTTP), set `RUBYLIB=lib`, and
run `ruby sdk_compliance_adapter/adapter.rb` with the desired `PORT` and
`SDK_MODE`. Point harness 1.0.0 at that listener and an available local mock port.
49 changes: 33 additions & 16 deletions sdk_compliance_adapter/adapter.rb
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,10 @@ def client=(new_client)
@mutex.synchronize { @client = new_client }
end

def increment_captured
def increment_captured(pending: true)
@mutex.synchronize do
@total_events_captured += 1
@pending_events += 1
@pending_events += 1 if pending
end
end

Expand Down Expand Up @@ -144,7 +144,11 @@ def send_request(api_key, batch)
end

class ComplianceServer
def initialize(host: '0.0.0.0', port: 8080)
def initialize(host: '0.0.0.0', port: Integer(ENV.fetch('PORT', '8080')),
mode: ENV.fetch('SDK_MODE', 'async'))
raise ArgumentError, 'SDK_MODE must be async or sync' unless %w[async sync].include?(mode)

@mode = mode
@server = TCPServer.new(host, port)
end

Expand Down Expand Up @@ -236,7 +240,7 @@ def health
[
200,
{
sdk_name: 'posthog-ruby',
sdk_name: "posthog-ruby-#{@mode}",
sdk_version: PostHog::VERSION,
adapter_version: '1.0.0',
capabilities: %w[capture_v0 encoding_gzip]
Expand All @@ -255,6 +259,12 @@ def init(data)
options = {
api_key: api_key,
host: host,
sync_mode: @mode == 'sync',
before_send: proc do |event|
# The SDK has already generated the UUID; observe without changing the event.
Thread.current[:sdk_compliance_uuid] = event['uuid']
event
end,
batch_size: data.fetch('flush_at', 100),
flush_interval_seconds: data.fetch('flush_interval_ms', 500).to_f / 1000.0,
on_error: proc { |_status, error| SDKComplianceAdapter.state.record_error(error) },
Expand All @@ -276,29 +286,33 @@ def capture(data)
return [400, { error: 'distinct_id is required' }] if distinct_id.nil? || distinct_id.empty?
return [400, { error: 'event is required' }] if event.nil? || event.empty?

uuid = SecureRandom.uuid
attrs = {
distinct_id: distinct_id,
event: event,
properties: data['properties'] || {},
uuid: uuid
properties: data['properties'] || {}
}
attrs[:uuid] = data['uuid'] if data.key?('uuid')
attrs[:timestamp] = Time.iso8601(data['timestamp']) if data['timestamp']

Thread.current[:sdk_compliance_uuid] = nil
if client.capture(attrs)
SDKComplianceAdapter.state.increment_captured
[200, { success: true, uuid: uuid }]
SDKComplianceAdapter.state.increment_captured(pending: @mode != 'sync')
[200, { success: true, uuid: Thread.current[:sdk_compliance_uuid] }]
else
[500, { error: 'capture was not queued' }]
end
ensure
Thread.current[:sdk_compliance_uuid] = nil
end

def flush
client = SDKComplianceAdapter.state.client
return [400, { error: 'SDK not initialized' }] unless client

sent_before_flush = SDKComplianceAdapter.state.snapshot[:total_events_sent]
client.flush
[200, { success: true, events_flushed: SDKComplianceAdapter.state.snapshot[:total_events_sent] }]
events_flushed = SDKComplianceAdapter.state.snapshot[:total_events_sent] - sent_before_flush
[200, { success: true, events_flushed: events_flushed }]
rescue StandardError => e
SDKComplianceAdapter.state.record_error(e.message)
[500, { error: e.message, errors: [e.message] }]
Expand All @@ -313,15 +327,16 @@ def get_feature_flag(data)
return [400, { error: 'key is required' }] if key.nil? || key.empty?
return [400, { error: 'distinct_id is required' }] if distinct_id.nil? || distinct_id.empty?

disable_geoip = data.key?('disable_geoip') ? data['disable_geoip'] : false
options = {}
options[:disable_geoip] = data['disable_geoip'] if data.key?('disable_geoip')
flags = client.evaluate_flags(
distinct_id,
groups: data['groups'] || {},
person_properties: data['person_properties'] || {},
group_properties: data['group_properties'] || {},
only_evaluate_locally: data.fetch('force_remote', true) == false,
disable_geoip: disable_geoip,
flag_keys: [key]
flag_keys: [key],
**options
)
value = flags.get_flag(key)
client.flush
Expand All @@ -347,7 +362,9 @@ def write_response(socket, status, payload)
end
end

trap('TERM') { exit }
trap('INT') { exit }
if $PROGRAM_NAME == __FILE__
trap('TERM') { exit }
trap('INT') { exit }

ComplianceServer.new.run
ComplianceServer.new.run
end
4 changes: 3 additions & 1 deletion sdk_compliance_adapter/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@ services:
build:
context: ..
dockerfile: sdk_compliance_adapter/Dockerfile
environment:
SDK_MODE: ${SDK_MODE:-async}
networks:
- test-network

test-harness:
image: ghcr.io/posthog/sdk-test-harness:0.10.0
image: ghcr.io/posthog/sdk-test-harness:1.0.0
command: ["run", "--adapter-url", "http://sdk-adapter:8080", "--mock-url", "http://test-harness:8081"]
networks:
- test-network
Expand Down
112 changes: 112 additions & 0 deletions spec/sdk_compliance_adapter_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
# frozen_string_literal: true

require 'spec_helper'
require_relative '../sdk_compliance_adapter/adapter'

RSpec.describe ComplianceServer do
let(:host) { 'http://127.0.0.1:19250' }
let(:batches) { [] }

def request(server, path, data = {})
server.send(:route, 'POST', path, JSON.generate(data))
end

before do
allow(TCPServer).to receive(:new).and_return(double('listener'))
stub_request(:post, "#{host}/batch/").to_return do |req|
body = req.headers['Content-Encoding'] == 'gzip' ? Zlib.gunzip(req.body) : req.body
batches << JSON.parse(body)
{ status: 200, body: '{}' }
end
end

after { SDKComplianceAdapter.state.reset }

%w[async sync].each do |mode|
context "with #{mode} capture" do
let(:server) { described_class.new(mode: mode) }

before do
expect(request(server, '/init', api_key: 'test-key', host: host,
flush_at: 100, flush_interval_ms: 60_000)).to eq([200, { success: true }])
end

it 'identifies the profile without changing SDK wire attribution' do
expect(server.send(:health).last[:sdk_name]).to eq("posthog-ruby-#{mode}")
request(server, '/capture', distinct_id: 'user', event: 'example')
request(server, '/flush')
expect(batches.first['batch'].first['properties']['$lib']).to eq('posthog-ruby')
end

it 'returns the UUID generated by the SDK and leaves transport timing to the mode' do
expect(SDKComplianceAdapter.state.client).to receive(:capture).with(
{ distinct_id: 'user', event: 'example', properties: {} }
).and_call_original
status, result = request(server, '/capture', distinct_id: 'user', event: 'example')
expect(status).to eq(200)
expect(result[:uuid]).to match(/\A[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}\z/)
expect(batches.length).to eq(mode == 'sync' ? 1 : 0)
request(server, '/flush')
expect(batches.first['batch'].first['uuid']).to eq(result[:uuid])
expect(SDKComplianceAdapter.state.snapshot).to include(total_events_captured: 1, pending_events: 0)
end

it 'generates distinct UUIDs across captures and forwards a supplied UUID unchanged' do
results = 2.times.map { request(server, '/capture', distinct_id: 'user', event: 'example').last }
uuid = '12345678-1234-4567-890a-123456789abc'
results << request(server, '/capture', distinct_id: 'user', event: 'example', uuid: uuid).last
request(server, '/flush')
uuids = batches.flat_map { |batch| batch['batch'].map { |event| event['uuid'] } }
expect(uuids).to eq(results.map { |result| result[:uuid] })
expect(uuids.uniq.length).to eq(3)
expect(uuids.last).to eq(uuid)
end

it 'passes a timestamp instant through the SDK UTC serializer' do
request(server, '/capture', distinct_id: 'user', event: 'example', timestamp: '2024-01-02T03:04:05+05:30')
request(server, '/flush')
expect(batches.first['batch'].first['timestamp']).to eq('2024-01-01T21:34:05.000Z')
end

it 'honors disabled compression' do
request(server, '/init', api_key: 'test-key', host: host, enable_compression: false)
request(server, '/capture', distinct_id: 'user', event: 'example')
request(server, '/flush')
expect(a_request(:post, "#{host}/batch/").with do |req|
!req.headers.key?('Content-Encoding')
end).to have_been_made.once
end

it 'reports only events sent during each flush' do
request(server, '/capture', distinct_id: 'user', event: 'example')
expect(request(server, '/flush').last[:events_flushed]).to eq(mode == 'sync' ? 0 : 1)
expect(request(server, '/flush').last[:events_flushed]).to eq(0)
end

[nil, false, true].each do |disable_geoip|
it "preserves #{disable_geoip.inspect} GeoIP input through snapshot evaluation and real access capture" do
flag_requests = []
stub_request(:post, "#{host}/flags/?v=2").to_return do |req|
flag_requests << JSON.parse(req.body)
{ status: 200, body: JSON.generate(featureFlags: { example: 'variant' }) }
end
data = { key: 'example', distinct_id: 'user' }
data[:disable_geoip] = disable_geoip unless disable_geoip.nil?
expect(request(server, '/get_feature_flag', data)).to eq([200, { success: true, value: 'variant' }])
expect(flag_requests.first['flag_keys_to_evaluate']).to eq(['example'])
if disable_geoip.nil?
expect(flag_requests.first).not_to have_key('geoip_disable')
else
expect(flag_requests.first['geoip_disable']).to eq(disable_geoip)
end
expect(batches.flat_map { |batch| batch['batch'].map { |event| event['event'] } })
.to eq(['$feature_flag_called'])
end
end
end
end

it 'rejects an unknown profile' do
expect { described_class.new(mode: 'unknown') }.to raise_error(ArgumentError, 'SDK_MODE must be async or sync')
end
end
Loading