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 +7 -0
- data/lib/sessionvision/client.rb +103 -0
- data/lib/sessionvision/config.rb +32 -0
- data/lib/sessionvision/event_buffer.rb +76 -0
- data/lib/sessionvision/transport.rb +99 -0
- data/lib/sessionvision/version.rb +5 -0
- data/lib/sessionvision.rb +74 -0
- metadata +78 -0
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,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: []
|