multiplayer-session-recorder 0.0.6

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: 1ca02b61e222654ebd5a78168a2455dd3ed138fe1c61dd30bb7189028568c670
4
+ data.tar.gz: f97f4949e513ff1fb19477fdbf079de84d4a9619c32c1b9da07430136e514955
5
+ SHA512:
6
+ metadata.gz: 789daab68f6ff9a9dff8ce56fb9e212a8d4d54340f942040db2481f5f951ecfc12eebf9022750486c895080b895e655d17dc7aee3bccc7c0153a272816ab0d34
7
+ data.tar.gz: a45acfb3e3163c8ed9c2166a78344a561989c4caa5c0dbc6ec0f4821d588a1605d9d4e15ddb727862bd4248a7aa2b1a9bd888241dc16fee5abeabedde3f6c088
@@ -0,0 +1,12 @@
1
+ require "session_recorder/exporters"
2
+ require "session_recorder/middleware"
3
+ require "session_recorder/trace"
4
+ require "session_recorder/type"
5
+ require "session_recorder/constants"
6
+ require "session_recorder/version"
7
+
8
+ module Multiplayer
9
+ module SessionRecorder
10
+ # Main module for Session Recorder functionality
11
+ end
12
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SessionRecorder
4
+ MULTIPLAYER_TRACE_DEBUG_PREFIX = 'debdeb'
5
+
6
+ MULTIPLAYER_TRACE_CONTINUOUS_DEBUG_PREFIX = 'cdbcdb'
7
+
8
+ MULTIPLAYER_TRACE_DEBUG_SESSION_SHORT_ID_LENGTH = 16
9
+
10
+ MULTIPLAYER_OTEL_DEFAULT_TRACES_EXPORTER_HTTP_URL = 'https://otlp.multiplayer.app/v1/traces'
11
+
12
+ MULTIPLAYER_OTEL_DEFAULT_LOGS_EXPORTER_HTTP_URL = 'https://otlp.multiplayer.app/v1/logs'
13
+
14
+ MULTIPLAYER_OTEL_DEFAULT_TRACES_EXPORTER_GRPC_URL = 'https://otlp.multiplayer.app:4317/v1/traces'
15
+
16
+ MULTIPLAYER_OTEL_DEFAULT_LOGS_EXPORTER_GRPC_URL = 'https://otlp.multiplayer.app:4317/v1/logs'
17
+
18
+ MULTIPLAYER_BASE_API_URL = 'https://api.multiplayer.app'
19
+
20
+ MULTIPLAYER_ATTRIBUTE_PREFIX = 'multiplayer.'
21
+
22
+ ATTR_MULTIPLAYER_WORKSPACE_ID = 'multiplayer.workspace.id'
23
+
24
+ ATTR_MULTIPLAYER_PROJECT_ID = 'multiplayer.project.id'
25
+
26
+ ATTR_MULTIPLAYER_PLATFORM_ID = 'multiplayer.platform.id'
27
+
28
+ ATTR_MULTIPLAYER_CONTINUOUS_SESSION_AUTO_SAVE = 'multiplayer.session.auto-save'
29
+
30
+ ATTR_MULTIPLAYER_CONTINUOUS_SESSION_AUTO_SAVE_REASON = 'multiplayer.session.auto-save.reason'
31
+
32
+ ATTR_MULTIPLAYER_PLATFORM_NAME = 'multiplayer.platform.name'
33
+
34
+ ATTR_MULTIPLAYER_CLIENT_ID = 'multiplayer.client.id'
35
+
36
+ ATTR_MULTIPLAYER_INTEGRATION_ID = 'multiplayer.integration.id'
37
+
38
+ ATTR_MULTIPLAYER_SESSION_ID = 'multiplayer.session.id'
39
+
40
+ ATTR_MULTIPLAYER_HTTP_PROXY = 'multiplayer.http.proxy'
41
+
42
+ ATTR_MULTIPLAYER_HTTP_PROXY_TYPE = 'multiplayer.http.proxy.type'
43
+
44
+ ATTR_MULTIPLAYER_HTTP_REQUEST_BODY = 'multiplayer.http.request.body'
45
+
46
+ ATTR_MULTIPLAYER_HTTP_RESPONSE_BODY = 'multiplayer.http.response.body'
47
+
48
+ ATTR_MULTIPLAYER_HTTP_REQUEST_HEADERS = 'multiplayer.http.request.headers'
49
+
50
+ ATTR_MULTIPLAYER_HTTP_RESPONSE_HEADERS = 'multiplayer.http.response.headers'
51
+
52
+ ATTR_MULTIPLAYER_HTTP_RESPONSE_BODY_ENCODING = 'multiplayer.http.response.body.encoding'
53
+
54
+ ATTR_MULTIPLAYER_RPC_REQUEST_MESSAGE = 'multiplayer.rpc.request.message'
55
+
56
+ ATTR_MULTIPLAYER_RPC_REQUEST_MESSAGE_ENCODING = 'multiplayer.rpc.request.message.encoding'
57
+
58
+ ATTR_MULTIPLAYER_RPC_RESPONSE_MESSAGE = 'multiplayer.rpc.response.message'
59
+
60
+ ATTR_MULTIPLAYER_GRPC_REQUEST_MESSAGE = 'multiplayer.rpc.grpc.request.message'
61
+
62
+ ATTR_MULTIPLAYER_GRPC_REQUEST_MESSAGE_ENCODING = 'multiplayer.rpc.request.message.encoding'
63
+
64
+ ATTR_MULTIPLAYER_GRPC_RESPONSE_MESSAGE = 'multiplayer.rpc.grpc.response.message'
65
+
66
+ ATTR_MULTIPLAYER_MESSAGING_MESSAGE_BODY = 'multiplayer.messaging.message.body'
67
+
68
+ ATTR_MULTIPLAYER_MESSAGING_MESSAGE_BODY_ENCODING = 'multiplayer.messaging.message.body.encoding'
69
+
70
+ ATTR_MULTIPLAYER_SESSION_RECORDER_VERSION = 'multiplayer.session-recorder.version'
71
+
72
+ MASK_PLACEHOLDER = '***MASKED***'
73
+
74
+ # Middleware constants
75
+ MAX_MASK_DEPTH = 10
76
+
77
+ # HTTP request/response size limits
78
+ MULTIPLAYER_MAX_HTTP_REQUEST_RESPONSE_SIZE = 1024 * 1024 # 1MB
79
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "opentelemetry/sdk"
5
+ require "opentelemetry/exporter/otlp"
6
+
7
+ module SessionRecorder
8
+ module Exporters
9
+ class SessionRecorderGrpcLogsExporter < OpenTelemetry::Exporter::OTLP::Exporter
10
+ def initialize(config = {})
11
+ endpoint = config[:endpoint] || SessionRecorder::MULTIPLAYER_OTEL_DEFAULT_LOGS_EXPORTER_GRPC_URL
12
+ api_key = config[:api_key]
13
+
14
+ raise ArgumentError, "api_key is required" if api_key.nil? || api_key.empty?
15
+
16
+ headers = { "Authorization" => api_key }
17
+ headers.merge!(config[:headers]) if config[:headers]
18
+
19
+ super(endpoint: endpoint, headers: headers)
20
+ end
21
+
22
+ def export(logs, timeout: nil)
23
+ # Filter logs by trace ID prefix
24
+ filtered_logs = logs.select do |log|
25
+ trace_id = log.trace_id
26
+ trace_id.start_with?(SessionRecorder::MULTIPLAYER_TRACE_DEBUG_PREFIX) ||
27
+ trace_id.start_with?(SessionRecorder::MULTIPLAYER_TRACE_CONTINUOUS_DEBUG_PREFIX)
28
+ end
29
+
30
+ return OpenTelemetry::SDK::Trace::Export::SUCCESS if filtered_logs.empty?
31
+
32
+ super(filtered_logs, timeout: timeout)
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "opentelemetry/sdk"
5
+ require "opentelemetry/exporter/otlp"
6
+
7
+ module SessionRecorder
8
+ module Exporters
9
+ class SessionRecorderGrpcTraceExporter < OpenTelemetry::Exporter::OTLP::Exporter
10
+ def initialize(config = {})
11
+ endpoint = config[:endpoint] || SessionRecorder::MULTIPLAYER_OTEL_DEFAULT_TRACES_EXPORTER_GRPC_URL
12
+ api_key = config[:api_key]
13
+
14
+ raise ArgumentError, "api_key is required" if api_key.nil? || api_key.empty?
15
+
16
+ headers = { "Authorization" => api_key }
17
+ headers.merge!(config[:headers]) if config[:headers]
18
+
19
+ super(endpoint: endpoint, headers: headers)
20
+ end
21
+
22
+ def export(spans, timeout: nil)
23
+ # Filter spans by trace ID prefix
24
+ filtered_spans = spans.select do |span|
25
+ trace_id = span.trace_id
26
+ trace_id.start_with?(SessionRecorder::MULTIPLAYER_TRACE_DEBUG_PREFIX) ||
27
+ trace_id.start_with?(SessionRecorder::MULTIPLAYER_TRACE_CONTINUOUS_DEBUG_PREFIX)
28
+ end
29
+
30
+ return OpenTelemetry::SDK::Trace::Export::SUCCESS if filtered_spans.empty?
31
+
32
+ super(filtered_spans, timeout: timeout)
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "opentelemetry/sdk"
5
+ require "opentelemetry/exporter/otlp"
6
+
7
+ module SessionRecorder
8
+ module Exporters
9
+ class SessionRecorderHttpLogsExporter < OpenTelemetry::Exporter::OTLP::Exporter
10
+ def initialize(config = {})
11
+ endpoint = config[:endpoint] || SessionRecorder::MULTIPLAYER_OTEL_DEFAULT_LOGS_EXPORTER_HTTP_URL
12
+ api_key = config[:api_key]
13
+
14
+ raise ArgumentError, "api_key is required" if api_key.nil? || api_key.empty?
15
+
16
+ headers = { "Authorization" => api_key }
17
+ headers.merge!(config[:headers]) if config[:headers]
18
+
19
+ super(endpoint: endpoint, headers: headers)
20
+ end
21
+
22
+ def export(logs, timeout: nil)
23
+ # Filter logs by trace ID prefix
24
+ filtered_logs = logs.select do |log|
25
+ trace_id = log.trace_id
26
+ trace_id.start_with?(SessionRecorder::MULTIPLAYER_TRACE_DEBUG_PREFIX) ||
27
+ trace_id.start_with?(SessionRecorder::MULTIPLAYER_TRACE_CONTINUOUS_DEBUG_PREFIX)
28
+ end
29
+
30
+ return OpenTelemetry::SDK::Trace::Export::SUCCESS if filtered_logs.empty?
31
+
32
+ super(filtered_logs, timeout: timeout)
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "opentelemetry/sdk"
5
+ require "opentelemetry/exporter/otlp"
6
+
7
+ module SessionRecorder
8
+ module Exporters
9
+ class SessionRecorderHttpTraceExporter < OpenTelemetry::Exporter::OTLP::Exporter
10
+ def initialize(config = {})
11
+ endpoint = config[:endpoint] || SessionRecorder::MULTIPLAYER_OTEL_DEFAULT_TRACES_EXPORTER_HTTP_URL
12
+ api_key = config[:api_key]
13
+
14
+ raise ArgumentError, "api_key is required" if api_key.nil? || api_key.empty?
15
+
16
+ headers = { "Authorization" => api_key }
17
+ headers.merge!(config[:headers]) if config[:headers]
18
+
19
+ super(endpoint: endpoint, headers: headers)
20
+ end
21
+
22
+ def export(spans, timeout: nil)
23
+ # Filter spans by trace ID prefix
24
+ filtered_spans = spans.select do |span|
25
+ trace_id = span.trace_id
26
+ trace_id.start_with?(SessionRecorder::MULTIPLAYER_TRACE_DEBUG_PREFIX) ||
27
+ trace_id.start_with?(SessionRecorder::MULTIPLAYER_TRACE_CONTINUOUS_DEBUG_PREFIX)
28
+ end
29
+
30
+ return OpenTelemetry::SDK::Trace::Export::SUCCESS if filtered_spans.empty?
31
+
32
+ super(filtered_spans, timeout: timeout)
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "opentelemetry/sdk"
4
+
5
+ module SessionRecorder
6
+ module Exporters
7
+ class SessionRecorderLogsExporterWrapper
8
+ def initialize(exporter)
9
+ @exporter = exporter
10
+ end
11
+
12
+ def export(logs, timeout: nil)
13
+ # Filter out multiplayer attributes from logs
14
+ filtered_logs = logs.map do |log|
15
+ filtered_log = log.dup
16
+ filtered_log[:attributes] = filter_attributes(log[:attributes])
17
+ filtered_log
18
+ end
19
+
20
+ @exporter.export(filtered_logs, timeout: timeout)
21
+ end
22
+
23
+ def shutdown(timeout: nil)
24
+ @exporter.shutdown(timeout: timeout)
25
+ end
26
+
27
+ def force_flush(timeout: nil)
28
+ @exporter.force_flush(timeout: timeout)
29
+ end
30
+
31
+ private
32
+
33
+ def filter_attributes(attributes)
34
+ return {} if attributes.nil?
35
+
36
+ attributes.each_with_object({}) do |(key, value), filtered|
37
+ unless key.to_s.start_with?(SessionRecorder::MULTIPLAYER_ATTRIBUTE_PREFIX)
38
+ filtered[key] = value
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "opentelemetry/sdk"
4
+
5
+ module SessionRecorder
6
+ module Exporters
7
+ class SessionRecorderTraceExporterWrapper
8
+ def initialize(exporter)
9
+ @exporter = exporter
10
+ end
11
+
12
+ def export(spans, timeout: nil)
13
+ # Filter out multiplayer attributes from spans
14
+ filtered_spans = spans.map do |span|
15
+ filtered_span = span.dup
16
+ filtered_span[:attributes] = filter_attributes(span[:attributes])
17
+ filtered_span
18
+ end
19
+
20
+ @exporter.export(filtered_spans, timeout: timeout)
21
+ end
22
+
23
+ def shutdown(timeout: nil)
24
+ @exporter.shutdown(timeout: timeout)
25
+ end
26
+
27
+ def force_flush(timeout: nil)
28
+ @exporter.force_flush(timeout: timeout)
29
+ end
30
+
31
+ private
32
+
33
+ def filter_attributes(attributes)
34
+ return {} if attributes.nil?
35
+
36
+ attributes.each_with_object({}) do |(key, value), filtered|
37
+ unless key.to_s.start_with?(SessionRecorder::MULTIPLAYER_ATTRIBUTE_PREFIX)
38
+ filtered[key] = value
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "exporters/http_trace_exporter"
4
+ require_relative "exporters/http_logs_exporter"
5
+ require_relative "exporters/grpc_trace_exporter"
6
+ require_relative "exporters/grpc_logs_exporter"
7
+ require_relative "exporters/trace_exporter_wrapper"
8
+ require_relative "exporters/logs_exporter_wrapper"
9
+
10
+ module SessionRecorder
11
+ module Exporters
12
+ # Convenience method to create HTTP trace exporter
13
+ def self.create_http_trace_exporter(config = {})
14
+ SessionRecorderHttpTraceExporter.new(config)
15
+ end
16
+
17
+ # Convenience method to create HTTP logs exporter
18
+ def self.create_http_logs_exporter(config = {})
19
+ SessionRecorderHttpLogsExporter.new(config)
20
+ end
21
+
22
+ # Convenience method to create gRPC trace exporter
23
+ def self.create_grpc_trace_exporter(config = {})
24
+ SessionRecorderGrpcTraceExporter.new(config)
25
+ end
26
+
27
+ # Convenience method to create gRPC logs exporter
28
+ def self.create_grpc_logs_exporter(config = {})
29
+ SessionRecorderGrpcLogsExporter.new(config)
30
+ end
31
+
32
+ # Convenience method to create trace exporter wrapper
33
+ def self.create_trace_exporter_wrapper(exporter)
34
+ SessionRecorderTraceExporterWrapper.new(exporter)
35
+ end
36
+
37
+ # Convenience method to create logs exporter wrapper
38
+ def self.create_logs_exporter_wrapper(exporter)
39
+ SessionRecorderLogsExporterWrapper.new(exporter)
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module SessionRecorder
6
+ class Middleware
7
+ attr_reader :mask_body, :mask_headers, :capture_headers, :capture_body,
8
+ :is_mask_body_enabled, :is_mask_headers_enabled, :max_payload_size_bytes
9
+
10
+ def initialize(app, options = {})
11
+ @app = app
12
+
13
+ # Masking functions
14
+ @mask_body = options[:maskBody] || method(:default_mask_body)
15
+ @mask_headers = options[:maskHeaders] || method(:default_mask_headers)
16
+
17
+ # Capture flags
18
+ @capture_headers = options.fetch(:captureHeaders, true)
19
+ @capture_body = options.fetch(:captureBody, true)
20
+
21
+ # Masking flags
22
+ @is_mask_body_enabled = options.fetch(:isMaskBodyEnabled, true)
23
+ @is_mask_headers_enabled = options.fetch(:isMaskHeadersEnabled, true)
24
+
25
+ # Payload size limit
26
+ @max_payload_size_bytes = options[:maxPayloadSizeBytes] || SessionRecorder::MULTIPLAYER_MAX_HTTP_REQUEST_RESPONSE_SIZE
27
+ end
28
+
29
+ protected
30
+
31
+ def default_mask_body(value)
32
+ return SessionRecorder::MASK_PLACEHOLDER unless @is_mask_body_enabled
33
+
34
+ payload_json = begin
35
+ JSON.parse(value)
36
+ rescue JSON::ParserError
37
+ value
38
+ end
39
+
40
+ masked_data = mask_primitives(payload_json)
41
+
42
+ unless masked_data.is_a?(String)
43
+ masked_data = masked_data.to_json
44
+ end
45
+
46
+ masked_data
47
+ end
48
+
49
+ def default_mask_headers(headers, custom_header_names_to_mask = [])
50
+ return headers unless @is_mask_headers_enabled
51
+
52
+ default_header_names_to_mask = ["set-cookie", "cookie", "authorization", "proxy-authorization"]
53
+ masked_headers = headers.dup
54
+ headers_to_mask = default_header_names_to_mask + custom_header_names_to_mask
55
+
56
+ headers_to_mask.each do |header_name|
57
+ masked_headers[header_name] = SessionRecorder::MASK_PLACEHOLDER if masked_headers.key?(header_name)
58
+ end
59
+
60
+ masked_headers
61
+ end
62
+
63
+ def mask_primitives(input, current_depth = 0)
64
+ return SessionRecorder::MASK_PLACEHOLDER if current_depth >= SessionRecorder::MAX_MASK_DEPTH
65
+
66
+ case input
67
+ when Hash
68
+ input.transform_values { |value| mask_primitives(value, current_depth + 1) }
69
+ when Array
70
+ input.map { |value| mask_primitives(value, current_depth + 1) }
71
+ when String, Numeric, TrueClass, FalseClass, NilClass, Symbol
72
+ SessionRecorder::MASK_PLACEHOLDER
73
+ else
74
+ input
75
+ end
76
+ end
77
+
78
+ # Extracts request headers from the Rack environment
79
+ def extract_request_headers(env)
80
+ return {} unless @capture_headers
81
+
82
+ env.select { |k, _| k.start_with?("HTTP_") }
83
+ .transform_keys { |k| k.sub(/^HTTP_/, "").split("_").map(&:downcase).join("-") }
84
+ end
85
+
86
+ # Reads the request body safely
87
+ def extract_request_body(request)
88
+ return nil unless @capture_body
89
+
90
+ body = request.body.read
91
+ request.body.rewind # Rewind the stream for downstream middlewares
92
+ body
93
+ end
94
+
95
+ # Reads the response body safely
96
+ def extract_response_body(response)
97
+ return nil unless @capture_body
98
+
99
+ body = []
100
+ response.each { |part| body << part }
101
+ body.join
102
+ end
103
+
104
+ # Truncates payload if maxPayloadSizeBytes is set
105
+ def truncate_if_needed(data)
106
+ return data unless data.to_s.size > @max_payload_size_bytes
107
+ "#{data[0...@max_payload_size_bytes]}...[TRUNCATED]"
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SessionRecorder
4
+ class RequestMiddleware < SessionRecorder::Middleware
5
+ def call(env)
6
+ current_span = OpenTelemetry::Trace.current_span
7
+ trace_id = current_span.context.trace_id.unpack1("H*")
8
+ request = Rack::Request.new(env)
9
+
10
+ request_headers = extract_request_headers(env)
11
+ masked_headers = @mask_headers.call(request_headers, current_span)
12
+ current_span.set_attribute(SessionRecorder::ATTR_MULTIPLAYER_HTTP_REQUEST_HEADERS, masked_headers.to_json)
13
+
14
+ if request_headers.key?("content-type") && request_headers["content-type"] == "application/json"
15
+ request_body = extract_request_body(request)
16
+
17
+ if request_body
18
+ masked_body = @mask_body.call(request_body, current_span)
19
+ masked_body = truncate_if_needed(masked_body)
20
+
21
+ current_span.set_attribute(SessionRecorder::ATTR_MULTIPLAYER_HTTP_REQUEST_BODY,
22
+ masked_body.is_a?(String) ? masked_body : masked_body.to_json)
23
+ end
24
+ end
25
+
26
+ @app.call(env)
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SessionRecorder
4
+ class ResponseMiddleware < SessionRecorder::Middleware
5
+ def call(env)
6
+ status, headers, response = @app.call(env)
7
+
8
+ begin
9
+ current_span = OpenTelemetry::Trace.current_span
10
+ trace_id = current_span.context.trace_id.unpack1("H*")
11
+
12
+ headers["x-trace-id"] = trace_id
13
+
14
+ masked_headers = @mask_headers.call(headers, current_span)
15
+ response_body = extract_response_body(response)
16
+
17
+ if response_body
18
+ masked_body = @mask_body.call(response_body, current_span)
19
+ masked_body = truncate_if_needed(masked_body)
20
+
21
+ current_span.set_attribute(SessionRecorder::ATTR_MULTIPLAYER_HTTP_RESPONSE_HEADERS, masked_headers.to_json)
22
+ current_span.set_attribute(SessionRecorder::ATTR_MULTIPLAYER_HTTP_RESPONSE_BODY,
23
+ masked_body.is_a?(String) ? masked_body : masked_body.to_json)
24
+ end
25
+
26
+ [status, headers, response]
27
+ rescue
28
+ [status, headers, response]
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "opentelemetry-sdk"
4
+ require_relative "../type/session_type"
5
+
6
+ module SessionRecorder
7
+ module Trace
8
+ class SessionRecorderIdGenerator
9
+ include OpenTelemetry::Trace
10
+
11
+ attr_accessor :session_short_id, :session_type
12
+ attr_reader :generate_long_id, :generate_short_id
13
+
14
+ def initialize
15
+ @generate_long_id = self.class.get_id_generator(16)
16
+ @generate_short_id = self.class.get_id_generator(8)
17
+ @session_short_id = ''
18
+ @session_type = SessionType::PLAIN
19
+ end
20
+
21
+ def generate_trace_id
22
+ trace_id = @generate_long_id.call
23
+
24
+ if @session_short_id && !@session_short_id.empty?
25
+ session_type_prefix = case @session_type
26
+ when SessionType::CONTINUOUS
27
+ SessionRecorder::MULTIPLAYER_TRACE_CONTINUOUS_DEBUG_PREFIX
28
+ else
29
+ SessionRecorder::MULTIPLAYER_TRACE_DEBUG_PREFIX
30
+ end
31
+
32
+ prefix = "#{session_type_prefix}#{@session_short_id}"
33
+ session_trace_id = "#{prefix}#{trace_id[prefix.length..-1]}"
34
+
35
+ return session_trace_id
36
+ end
37
+
38
+ trace_id
39
+ end
40
+
41
+ def generate_span_id
42
+ @generate_short_id.call
43
+ end
44
+
45
+ def set_session_id(session_short_id, session_type = SessionType::PLAIN)
46
+ @session_short_id = session_short_id
47
+ @session_type = session_type
48
+ end
49
+
50
+ private
51
+
52
+ def self.get_id_generator(bytes)
53
+ lambda do
54
+ (0...(bytes * 2)).map do |i|
55
+ char_code = rand(16) + 48
56
+ # valid hex characters in the range 48-57 and 97-102
57
+ if char_code >= 58
58
+ char_code += 39
59
+ end
60
+ char_code.chr
61
+ end.join
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "opentelemetry-sdk"
4
+
5
+ module SessionRecorder
6
+ module Trace
7
+ class TraceIdRatioBasedSampler < OpenTelemetry::SDK::Trace::Samplers::TraceIdRatioBased
8
+ def initialize(ratio = 0)
9
+ @ratio = normalize(ratio)
10
+ @upper_bound = (@ratio * 0xffffffff).floor
11
+ super(@ratio)
12
+ end
13
+
14
+ def should_sample?(trace_id:, parent_context:, links:, name:, kind:, attributes:)
15
+ tracestate = OpenTelemetry::Trace.current_span(parent_context).context.tracestate
16
+
17
+ # Convert trace_id to hex string for comparison
18
+ trace_id_hex = trace_id.unpack1("H*")
19
+
20
+ # Always sample if trace ID begins with debug prefixes
21
+ if trace_id_hex.start_with?(SessionRecorder::MULTIPLAYER_TRACE_DEBUG_PREFIX) ||
22
+ trace_id_hex.start_with?(SessionRecorder::MULTIPLAYER_TRACE_CONTINUOUS_DEBUG_PREFIX)
23
+ return OpenTelemetry::SDK::Trace::Samplers::Result.new(
24
+ decision: OpenTelemetry::SDK::Trace::Samplers::Decision::RECORD_AND_SAMPLE,
25
+ tracestate: tracestate
26
+ )
27
+ end
28
+
29
+ # For all other trace IDs, use the provided sampling ratio
30
+ if valid_trace_id?(trace_id_hex) && accumulate(trace_id_hex) < @upper_bound
31
+ OpenTelemetry::SDK::Trace::Samplers::Result.new(
32
+ decision: OpenTelemetry::SDK::Trace::Samplers::Decision::RECORD_AND_SAMPLE,
33
+ tracestate: tracestate
34
+ )
35
+ else
36
+ OpenTelemetry::SDK::Trace::Samplers::Result.new(
37
+ decision: OpenTelemetry::SDK::Trace::Samplers::Decision::DROP,
38
+ tracestate: tracestate
39
+ )
40
+ end
41
+ end
42
+
43
+ def description
44
+ "SessionRecorderTraceIdRatioBasedSampler{ratio=#{@ratio}}"
45
+ end
46
+
47
+ private
48
+
49
+ def valid_trace_id?(trace_id)
50
+ trace_id.is_a?(String) && trace_id.length == 32 && trace_id =~ /^([0-9a-f]{32})$/i
51
+ end
52
+
53
+ def normalize(ratio)
54
+ return 0 unless defined?(ratio) && ratio.is_a?(Numeric)
55
+ return 1 if ratio >= 1
56
+ return 0 if ratio <= 0
57
+
58
+ ratio
59
+ end
60
+
61
+ def accumulate(trace_id)
62
+ accumulation = 0
63
+ (0...trace_id.length / 8).each do |i|
64
+ pos = i * 8
65
+ part = trace_id[pos, 8].to_i(16)
66
+ accumulation = (accumulation ^ part) & 0xffffffff
67
+ end
68
+ accumulation
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SessionRecorder
4
+ module Trace
5
+ module SessionType
6
+ PLAIN = 'plain'
7
+ CONTINUOUS = 'continuous'
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Multiplayer
4
+ module SessionRecorder
5
+ VERSION = "0.0.6"
6
+ end
7
+ end
metadata ADDED
@@ -0,0 +1,86 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: multiplayer-session-recorder
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.6
5
+ platform: ruby
6
+ authors:
7
+ - Multiplayer Inc.
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2025-09-02 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: opentelemetry-sdk
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '1.6'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '1.6'
27
+ - !ruby/object:Gem::Dependency
28
+ name: opentelemetry-exporter-otlp
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: 0.29.1
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: 0.29.1
41
+ description:
42
+ email:
43
+ executables: []
44
+ extensions: []
45
+ extra_rdoc_files: []
46
+ files:
47
+ - lib/session-recorder.rb
48
+ - lib/session_recorder/constants.rb
49
+ - lib/session_recorder/exporters.rb
50
+ - lib/session_recorder/exporters/grpc_logs_exporter.rb
51
+ - lib/session_recorder/exporters/grpc_trace_exporter.rb
52
+ - lib/session_recorder/exporters/http_logs_exporter.rb
53
+ - lib/session_recorder/exporters/http_trace_exporter.rb
54
+ - lib/session_recorder/exporters/logs_exporter_wrapper.rb
55
+ - lib/session_recorder/exporters/trace_exporter_wrapper.rb
56
+ - lib/session_recorder/middleware/middleware.rb
57
+ - lib/session_recorder/middleware/request_middleware.rb
58
+ - lib/session_recorder/middleware/response_middleware.rb
59
+ - lib/session_recorder/trace/id_generator.rb
60
+ - lib/session_recorder/trace/trace_id_ratio_based_sampler.rb
61
+ - lib/session_recorder/type/session_type.rb
62
+ - lib/session_recorder/version.rb
63
+ homepage: https://rubygems.org/gems/multiplayer_session_recorder
64
+ licenses:
65
+ - MIT
66
+ metadata: {}
67
+ post_install_message:
68
+ rdoc_options: []
69
+ require_paths:
70
+ - lib
71
+ required_ruby_version: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: '3.0'
76
+ required_rubygems_version: !ruby/object:Gem::Requirement
77
+ requirements:
78
+ - - ">="
79
+ - !ruby/object:Gem::Version
80
+ version: '0'
81
+ requirements: []
82
+ rubygems_version: 3.5.22
83
+ signing_key:
84
+ specification_version: 4
85
+ summary: Multiplayer Fullstack Session Recorder
86
+ test_files: []