From d7b786af3c9c995189bf7b0bc46ed648ae00ce43 Mon Sep 17 00:00:00 2001 From: Dustin Byrne Date: Mon, 7 Sep 2026 15:50:22 -0400 Subject: [PATCH] ci: cover native Ruby UUIDs and synchronous capture in SDK harness --- .github/workflows/sdk-compliance.yml | 44 ++++++++- sdk_compliance_adapter/Dockerfile.sync | 15 +++ sdk_compliance_adapter/README.md | 71 ++++++++++++++ sdk_compliance_adapter/adapter.rb | 49 ++++++---- sdk_compliance_adapter/docker-compose.yml | 4 +- spec/sdk_compliance_adapter_spec.rb | 112 ++++++++++++++++++++++ 6 files changed, 275 insertions(+), 20 deletions(-) create mode 100644 sdk_compliance_adapter/Dockerfile.sync create mode 100644 sdk_compliance_adapter/README.md create mode 100644 spec/sdk_compliance_adapter_spec.rb diff --git a/.github/workflows/sdk-compliance.yml b/.github/workflows/sdk-compliance.yml index ba4a057..41a68aa 100644 --- a/.github/workflows/sdk-compliance.yml +++ b/.github/workflows/sdk-compliance.yml @@ -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 diff --git a/sdk_compliance_adapter/Dockerfile.sync b/sdk_compliance_adapter/Dockerfile.sync new file mode 100644 index 0000000..aa4859d --- /dev/null +++ b/sdk_compliance_adapter/Dockerfile.sync @@ -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"] diff --git a/sdk_compliance_adapter/README.md b/sdk_compliance_adapter/README.md new file mode 100644 index 0000000..060ba2a --- /dev/null +++ b/sdk_compliance_adapter/README.md @@ -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. diff --git a/sdk_compliance_adapter/adapter.rb b/sdk_compliance_adapter/adapter.rb index 0e89f28..12bcb6c 100644 --- a/sdk_compliance_adapter/adapter.rb +++ b/sdk_compliance_adapter/adapter.rb @@ -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 @@ -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 @@ -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] @@ -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) }, @@ -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] }] @@ -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 @@ -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 diff --git a/sdk_compliance_adapter/docker-compose.yml b/sdk_compliance_adapter/docker-compose.yml index b56ec36..f6cb20c 100644 --- a/sdk_compliance_adapter/docker-compose.yml +++ b/sdk_compliance_adapter/docker-compose.yml @@ -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 diff --git a/spec/sdk_compliance_adapter_spec.rb b/spec/sdk_compliance_adapter_spec.rb new file mode 100644 index 0000000..d643381 --- /dev/null +++ b/spec/sdk_compliance_adapter_spec.rb @@ -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