sessionvision 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: f94cb9100274b790c0eeff2de556da99bd0a0f45980e2ddf7f350c7ab119026f
4
+ data.tar.gz: ea6cce1a64cf136ef40cf37d397a908295ee91fc6c63368e47d211f280c5a813
5
+ SHA512:
6
+ metadata.gz: 0bbaa3ccd591fcccf34e737a6b1469090d44742d813a8261e3b002e31396dfa289f1552683bfbce69ab25e052db749a13c451bed788f7a60206293fab8b54eff
7
+ data.tar.gz: 1ff05e7cd2de691da8fb71b471a595c4f592be2b9bb1e556913691320cec6a4bccc7c2dbd45c42dccf50241e06f921bf44a1dd37da0adf8e1ab08ab1d86efc5d
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "logger"
5
+
6
+ module SessionVision
7
+ class Client
8
+ def initialize(config)
9
+ @config = config
10
+ @lock = Mutex.new
11
+ @registered = {}
12
+ @shutdown_called = false
13
+
14
+ @logger = config.debug ? Logger.new($stdout, progname: "sessionvision") : nil
15
+
16
+ @transport = Transport.new(
17
+ ingest_host: config.ingest_host,
18
+ max_retries: config.max_retries,
19
+ retry_delays: config.retry_delays,
20
+ gzip_threshold: config.gzip_threshold,
21
+ on_error: config.on_error,
22
+ logger: @logger
23
+ )
24
+
25
+ @buffer = EventBuffer.new(
26
+ flush_size: config.flush_size,
27
+ flush_interval: config.flush_interval,
28
+ send_fn: method(:send_batch),
29
+ logger: @logger
30
+ )
31
+ @buffer.start
32
+
33
+ at_exit { shutdown }
34
+
35
+ @logger&.debug("Client initialized (host=#{config.ingest_host})")
36
+ end
37
+
38
+ def capture(event_name, user_id: nil, anonymous_id: nil, session_id: nil, properties: {})
39
+ raise ArgumentError, "event_name must be a non-empty string" unless event_name.is_a?(String) && !event_name.empty?
40
+ raise ArgumentError, "Either user_id or anonymous_id is required" if user_id.nil? && anonymous_id.nil?
41
+
42
+ merged_props = @lock.synchronize { @registered.merge(properties || {}) }
43
+
44
+ event = {
45
+ "event" => event_name,
46
+ "timestamp" => (Time.now.to_f * 1000).to_i,
47
+ "properties" => merged_props,
48
+ "sessionId" => session_id || SecureRandom.uuid
49
+ }
50
+ event["userId"] = user_id if user_id
51
+ event["anonymousId"] = anonymous_id if anonymous_id
52
+
53
+ @logger&.debug("Event captured: #{event_name}")
54
+ @buffer.push(event)
55
+ end
56
+
57
+ def identify(user_id, **traits)
58
+ raise ArgumentError, "user_id must be a non-empty string" unless user_id.is_a?(String) && !user_id.empty?
59
+
60
+ capture("$identify", user_id: user_id, properties: traits.transform_keys(&:to_s))
61
+ end
62
+
63
+ def register(**properties)
64
+ @lock.synchronize do
65
+ properties.each { |k, v| @registered[k.to_s] = v }
66
+ end
67
+ end
68
+
69
+ def register_once(**properties)
70
+ @lock.synchronize do
71
+ properties.each do |k, v|
72
+ key = k.to_s
73
+ @registered[key] = v unless @registered.key?(key)
74
+ end
75
+ end
76
+ end
77
+
78
+ def flush
79
+ @buffer.flush
80
+ end
81
+
82
+ def shutdown
83
+ @lock.synchronize do
84
+ return if @shutdown_called
85
+
86
+ @shutdown_called = true
87
+ end
88
+
89
+ @logger&.debug("Shutting down")
90
+ @buffer.shutdown
91
+ end
92
+
93
+ def reset
94
+ @lock.synchronize { @registered.clear }
95
+ end
96
+
97
+ private
98
+
99
+ def send_batch(events)
100
+ @transport.send_batch(@config.project_token, events)
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SessionVision
4
+ class Config
5
+ attr_reader :project_token, :ingest_host, :flush_size, :flush_interval,
6
+ :max_retries, :retry_delays, :gzip_threshold, :debug, :on_error
7
+
8
+ def initialize(
9
+ project_token:,
10
+ ingest_host: "https://app.sessionvision.com",
11
+ flush_size: 10,
12
+ flush_interval: 5,
13
+ max_retries: 3,
14
+ retry_delays: [1, 2, 4],
15
+ gzip_threshold: 1024,
16
+ debug: false,
17
+ on_error: nil
18
+ )
19
+ raise ArgumentError, "project_token is required" if project_token.nil? || project_token.empty?
20
+
21
+ @project_token = project_token
22
+ @ingest_host = ingest_host.chomp("/")
23
+ @flush_size = flush_size
24
+ @flush_interval = flush_interval
25
+ @max_retries = max_retries
26
+ @retry_delays = retry_delays
27
+ @gzip_threshold = gzip_threshold
28
+ @debug = debug
29
+ @on_error = on_error
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SessionVision
4
+ class EventBuffer
5
+ def initialize(flush_size:, flush_interval:, send_fn:, logger: nil)
6
+ @flush_size = flush_size
7
+ @flush_interval = flush_interval
8
+ @send_fn = send_fn
9
+ @logger = logger
10
+
11
+ @buffer = []
12
+ @lock = Mutex.new
13
+ @stop = false
14
+ @timer_thread = nil
15
+ end
16
+
17
+ def start
18
+ return if @timer_thread
19
+
20
+ @lock.synchronize { @stop = false }
21
+ @timer_thread = Thread.new { run_timer }
22
+ @timer_thread.abort_on_exception = false
23
+ end
24
+
25
+ def push(event)
26
+ should_flush = false
27
+
28
+ @lock.synchronize do
29
+ @buffer << event
30
+ should_flush = @buffer.length >= @flush_size
31
+ end
32
+
33
+ flush if should_flush
34
+ end
35
+
36
+ def flush
37
+ events = nil
38
+
39
+ @lock.synchronize do
40
+ return if @buffer.empty?
41
+
42
+ events = @buffer.dup
43
+ @buffer.clear
44
+ end
45
+
46
+ @logger&.debug("Flushing #{events.length} events")
47
+ @send_fn.call(events)
48
+ end
49
+
50
+ def shutdown
51
+ @lock.synchronize { @stop = true }
52
+
53
+ if @timer_thread
54
+ @timer_thread.join(10)
55
+ @timer_thread = nil
56
+ end
57
+
58
+ flush
59
+ end
60
+
61
+ private
62
+
63
+ def run_timer
64
+ loop do
65
+ sleep(@flush_interval)
66
+
67
+ should_stop = @lock.synchronize { @stop }
68
+ break if should_stop
69
+
70
+ flush
71
+ end
72
+ rescue StandardError => e
73
+ @logger&.error("Flush timer error: #{e.message}")
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+ require "json"
6
+ require "zlib"
7
+ require "stringio"
8
+ require "logger"
9
+
10
+ module SessionVision
11
+ class Transport
12
+ TIMEOUT_OPEN = 10
13
+ TIMEOUT_READ = 15
14
+
15
+ def initialize(ingest_host:, max_retries: 3, retry_delays: [1, 2, 4],
16
+ gzip_threshold: 1024, on_error: nil, logger: nil)
17
+ @url = URI("#{ingest_host}/api/v1/ingest/events")
18
+ @max_retries = max_retries
19
+ @retry_delays = retry_delays
20
+ @gzip_threshold = gzip_threshold
21
+ @on_error = on_error
22
+ @logger = logger
23
+ end
24
+
25
+ def send_batch(project_token, events)
26
+ payload = JSON.generate({ "projectToken" => project_token, "events" => events })
27
+ body = payload.encode("utf-8")
28
+ headers = { "Content-Type" => "application/json" }
29
+
30
+ if body.bytesize > @gzip_threshold
31
+ body = gzip_compress(body)
32
+ headers["Content-Encoding"] = "gzip"
33
+ end
34
+
35
+ last_error = nil
36
+
37
+ (1 + @max_retries).times do |attempt|
38
+ begin
39
+ response = post(body, headers)
40
+ status = response.code.to_i
41
+
42
+ if status >= 200 && status < 300
43
+ @logger&.debug("Batch sent successfully (#{events.length} events)")
44
+ return true
45
+ elsif status >= 400 && status < 500
46
+ @logger&.warn("Client error #{status}, not retrying")
47
+ call_on_error(RuntimeError.new("HTTP #{status}"), project_token, events)
48
+ return false
49
+ else
50
+ last_error = RuntimeError.new("HTTP #{status}")
51
+ @logger&.debug("Server error #{status} on attempt #{attempt + 1}/#{1 + @max_retries}")
52
+ end
53
+ rescue StandardError => e
54
+ last_error = e
55
+ @logger&.debug("Network error on attempt #{attempt + 1}/#{1 + @max_retries}: #{e.message}")
56
+ end
57
+
58
+ if attempt < @max_retries
59
+ delay = attempt < @retry_delays.length ? @retry_delays[attempt] : @retry_delays.last
60
+ sleep(delay)
61
+ end
62
+ end
63
+
64
+ @logger&.warn("Failed to send batch after #{@max_retries} retries")
65
+ call_on_error(last_error, project_token, events) if last_error
66
+ false
67
+ end
68
+
69
+ private
70
+
71
+ def post(body, headers)
72
+ http = Net::HTTP.new(@url.host, @url.port)
73
+ http.use_ssl = @url.scheme == "https"
74
+ http.open_timeout = TIMEOUT_OPEN
75
+ http.read_timeout = TIMEOUT_READ
76
+
77
+ request = Net::HTTP::Post.new(@url.request_uri, headers)
78
+ request.body = body
79
+ http.request(request)
80
+ end
81
+
82
+ def gzip_compress(data)
83
+ io = StringIO.new
84
+ io.set_encoding("BINARY")
85
+ gz = Zlib::GzipWriter.new(io)
86
+ gz.write(data)
87
+ gz.close
88
+ io.string
89
+ end
90
+
91
+ def call_on_error(error, project_token, events)
92
+ return unless @on_error
93
+
94
+ @on_error.call(error, { project_token: project_token, events: events })
95
+ rescue StandardError => e
96
+ @logger&.error("on_error callback raised: #{e.message}")
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SessionVision
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "sessionvision/version"
4
+ require_relative "sessionvision/config"
5
+ require_relative "sessionvision/transport"
6
+ require_relative "sessionvision/event_buffer"
7
+ require_relative "sessionvision/client"
8
+
9
+ # Session Vision server-side Ruby SDK.
10
+ #
11
+ # Usage:
12
+ #
13
+ # SessionVision.init("proj_xxx", ingest_host: "https://app.sessionvision.com")
14
+ # SessionVision.capture("purchase", user_id: "user_123", properties: { amount: 99.99 })
15
+ # SessionVision.shutdown
16
+ #
17
+ module SessionVision
18
+ class Error < StandardError; end
19
+
20
+ @client = nil
21
+ @mutex = Mutex.new
22
+
23
+ class << self
24
+ def init(project_token, **options)
25
+ @mutex.synchronize do
26
+ @client&.shutdown
27
+ config = Config.new(project_token: project_token, **options)
28
+ @client = Client.new(config)
29
+ end
30
+ end
31
+
32
+ def capture(event_name, user_id: nil, anonymous_id: nil, session_id: nil, properties: {})
33
+ client.capture(event_name, user_id: user_id, anonymous_id: anonymous_id,
34
+ session_id: session_id, properties: properties)
35
+ end
36
+
37
+ def identify(user_id, **traits)
38
+ client.identify(user_id, **traits)
39
+ end
40
+
41
+ def register(**properties)
42
+ client.register(**properties)
43
+ end
44
+
45
+ def register_once(**properties)
46
+ client.register_once(**properties)
47
+ end
48
+
49
+ def flush
50
+ client.flush
51
+ end
52
+
53
+ def shutdown
54
+ @mutex.synchronize do
55
+ return unless @client
56
+
57
+ @client.shutdown
58
+ @client = nil
59
+ end
60
+ end
61
+
62
+ def reset
63
+ client.reset
64
+ end
65
+
66
+ private
67
+
68
+ def client
69
+ raise Error, "SessionVision.init must be called before using the SDK" unless @client
70
+
71
+ @client
72
+ end
73
+ end
74
+ end
metadata ADDED
@@ -0,0 +1,78 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: sessionvision
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - sessionvision
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-03-13 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rspec
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '3.0'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '3.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: webmock
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '3.0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '3.0'
41
+ description: Server-side Ruby SDK for sessionvision product analytics. Track events,
42
+ identify users, and send data to your sessionvision instance.
43
+ email:
44
+ - support@sessionvision.com
45
+ executables: []
46
+ extensions: []
47
+ extra_rdoc_files: []
48
+ files:
49
+ - lib/sessionvision.rb
50
+ - lib/sessionvision/client.rb
51
+ - lib/sessionvision/config.rb
52
+ - lib/sessionvision/event_buffer.rb
53
+ - lib/sessionvision/transport.rb
54
+ - lib/sessionvision/version.rb
55
+ homepage: https://github.com/jjdinho/session_vision
56
+ licenses:
57
+ - MIT
58
+ metadata: {}
59
+ post_install_message:
60
+ rdoc_options: []
61
+ require_paths:
62
+ - lib
63
+ required_ruby_version: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: '3.0'
68
+ required_rubygems_version: !ruby/object:Gem::Requirement
69
+ requirements:
70
+ - - ">="
71
+ - !ruby/object:Gem::Version
72
+ version: '0'
73
+ requirements: []
74
+ rubygems_version: 3.5.11
75
+ signing_key:
76
+ specification_version: 4
77
+ summary: sessionvision analytics SDK for Ruby
78
+ test_files: []