allstak 0.1.1

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.
@@ -0,0 +1,170 @@
1
+ require "securerandom"
2
+
3
+ module AllStak
4
+ module Modules
5
+ # Distributed tracing — spans with parent-child hierarchy via Thread-local state.
6
+ class Tracing
7
+ PATH = "/ingest/v1/spans".freeze
8
+ VALID_STATUSES = %w[ok error timeout].freeze
9
+
10
+ def initialize(transport, config, logger)
11
+ @transport = transport
12
+ @config = config
13
+ @logger = logger
14
+ @buffer = Transport::FlushBuffer.new(
15
+ name: "tracing",
16
+ max_size: config.buffer_size,
17
+ interval_ms: config.flush_interval_ms,
18
+ flush_proc: method(:flush_batch),
19
+ logger: logger
20
+ )
21
+ end
22
+
23
+ def current_trace_id
24
+ Thread.current[:allstak_trace_id] ||= SecureRandom.hex(16)
25
+ end
26
+
27
+ def set_trace_id(trace_id)
28
+ Thread.current[:allstak_trace_id] = trace_id
29
+ end
30
+
31
+ def current_span_id
32
+ stack = Thread.current[:allstak_span_stack]
33
+ stack&.last
34
+ end
35
+
36
+ def reset_trace
37
+ Thread.current[:allstak_trace_id] = nil
38
+ Thread.current[:allstak_span_stack] = nil
39
+ end
40
+
41
+ def start_span(operation, description: "", tags: nil)
42
+ trace_id = current_trace_id
43
+ span_id = SecureRandom.hex(8)
44
+ parent = current_span_id || ""
45
+ Thread.current[:allstak_span_stack] ||= []
46
+ Thread.current[:allstak_span_stack] << span_id
47
+
48
+ Span.new(
49
+ trace_id: trace_id,
50
+ span_id: span_id,
51
+ parent_span_id: parent,
52
+ operation: operation,
53
+ description: description,
54
+ service: @config.service_name,
55
+ environment: @config.environment || "",
56
+ release: (@config.respond_to?(:release) ? @config.release : nil) || "",
57
+ tags: tags || {},
58
+ start_time_millis: (Time.now.to_f * 1000).to_i,
59
+ on_finish: method(:on_span_finish)
60
+ )
61
+ end
62
+
63
+ # Block-form helper: automatically finishes the span on return,
64
+ # on raise, or on non-local flow (e.g. Sinatra's `throw :halt`).
65
+ def in_span(operation, description: "", tags: nil)
66
+ span = start_span(operation, description: description, tags: tags)
67
+ status = "ok"
68
+ begin
69
+ return yield(span)
70
+ rescue => e
71
+ status = "error"
72
+ raise
73
+ ensure
74
+ span.finish(status) unless span.finished?
75
+ end
76
+ end
77
+
78
+ def flush
79
+ @buffer.flush
80
+ end
81
+
82
+ def shutdown
83
+ @buffer.shutdown
84
+ end
85
+
86
+ private
87
+
88
+ def on_span_finish(span)
89
+ stack = Thread.current[:allstak_span_stack]
90
+ stack&.delete(span.span_id)
91
+ @buffer.push(span.to_h)
92
+ end
93
+
94
+ def flush_batch(items)
95
+ begin
96
+ @transport.post(PATH, { spans: items })
97
+ rescue Transport::AllStakAuthError
98
+ return
99
+ rescue Transport::AllStakTransportError => e
100
+ @logger.debug("[AllStak] span transport error: #{e.message}")
101
+ rescue => e
102
+ @logger.debug("[AllStak] span unexpected error: #{e.message}")
103
+ end
104
+ end
105
+ end
106
+
107
+ class Span
108
+ attr_reader :trace_id, :span_id
109
+
110
+ def initialize(trace_id:, span_id:, parent_span_id:, operation:, description:,
111
+ service:, environment:, tags:, start_time_millis:, on_finish:, release: "")
112
+ @trace_id = trace_id
113
+ @span_id = span_id
114
+ @parent_span_id = parent_span_id
115
+ @operation = operation
116
+ @description = description
117
+ @service = service
118
+ @environment = environment
119
+ @release = release
120
+ @tags = tags.dup
121
+ @start_time_millis = start_time_millis
122
+ @end_time_millis = nil
123
+ @status = "ok"
124
+ @finished = false
125
+ @on_finish = on_finish
126
+ end
127
+
128
+ def set_tag(key, value)
129
+ @tags[key.to_s] = value.to_s
130
+ self
131
+ end
132
+
133
+ def set_description(description)
134
+ @description = description
135
+ self
136
+ end
137
+
138
+ def finished?
139
+ @finished
140
+ end
141
+
142
+ def finish(status = "ok")
143
+ return if @finished
144
+ @finished = true
145
+ @status = Tracing::VALID_STATUSES.include?(status) ? status : "ok"
146
+ @end_time_millis = (Time.now.to_f * 1000).to_i
147
+ @on_finish.call(self)
148
+ end
149
+
150
+ def to_h
151
+ end_ms = @end_time_millis || (Time.now.to_f * 1000).to_i
152
+ {
153
+ traceId: @trace_id,
154
+ spanId: @span_id,
155
+ parentSpanId: @parent_span_id,
156
+ operation: @operation,
157
+ description: @description,
158
+ status: @status,
159
+ durationMs: end_ms - @start_time_millis,
160
+ startTimeMillis: @start_time_millis,
161
+ endTimeMillis: end_ms,
162
+ service: @service,
163
+ environment: @environment,
164
+ release: @release,
165
+ tags: @tags
166
+ }
167
+ end
168
+ end
169
+ end
170
+ end
@@ -0,0 +1,91 @@
1
+ require "monitor"
2
+
3
+ module AllStak
4
+ module Transport
5
+ # Bounded ring buffer with a background flush thread.
6
+ #
7
+ # * Max size: `maxsize` (default 500)
8
+ # * Eviction: oldest item dropped when full
9
+ # * Flush triggers: interval timer, >= 80% capacity, explicit flush, shutdown
10
+ # * Single-flight: only one flush runs at a time
11
+ class FlushBuffer
12
+ include MonitorMixin
13
+
14
+ def initialize(name:, max_size:, interval_ms:, flush_proc:, logger:)
15
+ super()
16
+ @name = name
17
+ @max_size = max_size
18
+ @interval = interval_ms / 1000.0
19
+ @flush_proc = flush_proc
20
+ @logger = logger
21
+ @queue = []
22
+ @stopped = false
23
+ @overflow_warned = false
24
+ @flushing_mutex = Mutex.new
25
+ start_timer
26
+ end
27
+
28
+ def push(item)
29
+ synchronize do
30
+ if @queue.length >= @max_size
31
+ @queue.shift
32
+ unless @overflow_warned
33
+ @overflow_warned = true
34
+ @logger.warn("[AllStak] Buffer #{@name} full (#{@max_size}); oldest events dropped")
35
+ end
36
+ else
37
+ @overflow_warned = false
38
+ end
39
+ @queue << item
40
+ end
41
+ flush if count >= (@max_size * 0.8)
42
+ end
43
+
44
+ def count
45
+ synchronize { @queue.length }
46
+ end
47
+
48
+ def flush
49
+ @flushing_mutex.synchronize do
50
+ drained = synchronize do
51
+ next [] if @queue.empty?
52
+ current = @queue
53
+ @queue = []
54
+ current
55
+ end
56
+ return if drained.empty?
57
+ begin
58
+ @flush_proc.call(drained)
59
+ rescue => e
60
+ @logger.debug("[AllStak] flush error in #{@name}: #{e.class}: #{e.message}")
61
+ end
62
+ end
63
+ end
64
+
65
+ def shutdown
66
+ @stopped = true
67
+ @timer_thread&.wakeup rescue nil
68
+ @timer_thread&.join(2)
69
+ flush
70
+ end
71
+
72
+ private
73
+
74
+ def start_timer
75
+ @timer_thread = Thread.new do
76
+ Thread.current.name = "allstak-flush-#{@name}" if Thread.current.respond_to?(:name=)
77
+ until @stopped
78
+ sleep @interval
79
+ break if @stopped
80
+ begin
81
+ flush
82
+ rescue => e
83
+ @logger.debug("[AllStak] timer flush error: #{e.message}")
84
+ end
85
+ end
86
+ end
87
+ @timer_thread.abort_on_exception = false
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,97 @@
1
+ require "net/http"
2
+ require "uri"
3
+ require "json"
4
+
5
+ module AllStak
6
+ module Transport
7
+ class AllStakAuthError < StandardError; end
8
+ class AllStakTransportError < StandardError; end
9
+
10
+ # HTTP transport with retry/backoff and 401-disable.
11
+ #
12
+ # Contract:
13
+ # connect timeout = 3s · read timeout = 3s
14
+ # backoff = 1s → 2s → 4s → 8s (+ jitter 0-500ms)
15
+ # max attempts = 5
16
+ # 401 → disable SDK
17
+ # 4xx (400/403/404/422) → no retry
18
+ # 5xx / network → retry
19
+ class HttpTransport
20
+ NON_RETRYABLE_STATUSES = [400, 401, 403, 404, 422].freeze
21
+ BACKOFF_DELAYS = [1.0, 2.0, 4.0, 8.0].freeze
22
+
23
+ attr_reader :disabled
24
+
25
+ def initialize(config, logger)
26
+ @config = config
27
+ @logger = logger
28
+ @base_url = config.host
29
+ @api_key = config.api_key
30
+ @disabled = false
31
+ end
32
+
33
+ def disabled?
34
+ @disabled
35
+ end
36
+
37
+ def post(path, payload)
38
+ raise AllStakAuthError, "SDK disabled" if @disabled
39
+
40
+ uri = URI.parse("#{@base_url}#{path}")
41
+ http = Net::HTTP.new(uri.host, uri.port)
42
+ http.use_ssl = (uri.scheme == "https")
43
+ http.open_timeout = @config.connect_timeout
44
+ http.read_timeout = @config.read_timeout
45
+
46
+ last_exc = nil
47
+ last_status = 0
48
+ max_attempts = [[@config.max_retries.to_i, 1].max, 5].min
49
+
50
+ (1..max_attempts).each do |attempt|
51
+ begin
52
+ req = Net::HTTP::Post.new(uri.request_uri, {
53
+ "Content-Type" => "application/json",
54
+ "X-AllStak-Key" => @api_key,
55
+ "User-Agent" => "allstak-ruby/#{AllStak::VERSION}"
56
+ })
57
+ req.body = payload.is_a?(String) ? payload : JSON.generate(payload)
58
+ @logger.debug("[AllStak] POST #{path} attempt=#{attempt}") if @config.debug
59
+
60
+ resp = http.request(req)
61
+ last_status = resp.code.to_i
62
+ body = resp.body.to_s
63
+
64
+ if last_status == 401
65
+ @disabled = true
66
+ @logger.warn("[AllStak] SDK disabled: invalid API key (401). No further events will be sent.")
67
+ raise AllStakAuthError, "Invalid API key"
68
+ end
69
+
70
+ return [last_status, body] if NON_RETRYABLE_STATUSES.include?(last_status)
71
+ return [last_status, body] if last_status < 400
72
+
73
+ # 5xx → retry
74
+ rescue AllStakAuthError
75
+ raise
76
+ rescue Net::OpenTimeout, Net::ReadTimeout, Errno::ECONNREFUSED, Errno::ECONNRESET,
77
+ SocketError, EOFError, IOError => e
78
+ last_exc = e
79
+ @logger.debug("[AllStak] transport error attempt=#{attempt}: #{e.class}: #{e.message}") if @config.debug
80
+ rescue => e
81
+ last_exc = e
82
+ @logger.debug("[AllStak] unexpected transport error attempt=#{attempt}: #{e.class}: #{e.message}") if @config.debug
83
+ end
84
+
85
+ if attempt < max_attempts
86
+ delay = BACKOFF_DELAYS[[attempt - 1, BACKOFF_DELAYS.length - 1].min]
87
+ delay += rand * 0.5
88
+ sleep(delay)
89
+ end
90
+ end
91
+
92
+ raise AllStakTransportError,
93
+ "All #{max_attempts} attempts failed for POST #{path}. last_status=#{last_status} last_error=#{last_exc&.message}"
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,3 @@
1
+ module AllStak
2
+ VERSION = "0.1.1"
3
+ end
data/lib/allstak.rb ADDED
@@ -0,0 +1,151 @@
1
+ require "logger"
2
+ require "time"
3
+
4
+ require_relative "allstak/version"
5
+ require_relative "allstak/config"
6
+ require_relative "allstak/transport/http_transport"
7
+ require_relative "allstak/transport/flush_buffer"
8
+ require_relative "allstak/models/user_context"
9
+ require_relative "allstak/modules/errors"
10
+ require_relative "allstak/modules/logs"
11
+ require_relative "allstak/modules/http_monitor"
12
+ require_relative "allstak/modules/tracing"
13
+ require_relative "allstak/modules/database"
14
+ require_relative "allstak/modules/cron"
15
+ require_relative "allstak/client"
16
+ require_relative "allstak/integrations/rack"
17
+ require_relative "allstak/integrations/active_record"
18
+ require_relative "allstak/integrations/net_http"
19
+
20
+ # Official AllStak Ruby SDK.
21
+ #
22
+ # Quick start:
23
+ #
24
+ # require "allstak"
25
+ #
26
+ # AllStak.configure do |c|
27
+ # c.api_key = ENV["ALLSTAK_API_KEY"]
28
+ # c.environment = "production"
29
+ # c.release = "myapp@1.2.3"
30
+ # c.service_name = "myapp-api"
31
+ # end
32
+ #
33
+ # # Rack / Rails: add the middleware
34
+ # use AllStak::Integrations::Rack::Middleware
35
+ #
36
+ # # Manual:
37
+ # AllStak.capture_exception(exc)
38
+ # AllStak.log.info("hello", metadata: { foo: "bar" })
39
+ # AllStak.cron.job("daily-report") { generate_report }
40
+ module AllStak
41
+ @mutex = Mutex.new
42
+
43
+ class << self
44
+ attr_reader :logger
45
+
46
+ def configure
47
+ @mutex.synchronize do
48
+ @config ||= Config.new
49
+ yield @config if block_given?
50
+ @logger = Logger.new($stderr).tap do |l|
51
+ l.level = @config.debug ? Logger::DEBUG : Logger::WARN
52
+ l.progname = "allstak"
53
+ end
54
+ if @config.valid?
55
+ @client = Client.new(@config, @logger)
56
+ # Auto-wire integrations that are safe to install
57
+ AllStak::Integrations::ActiveRecordIntegration::Subscriber.install!
58
+ AllStak::Integrations::NetHTTP.install!
59
+ else
60
+ @logger.warn("[AllStak] api_key not set — SDK not started")
61
+ @client = nil
62
+ end
63
+ @client
64
+ end
65
+ end
66
+
67
+ def initialized?
68
+ !@client.nil?
69
+ end
70
+
71
+ def client
72
+ @client or raise "AllStak not configured. Call AllStak.configure { |c| ... } first."
73
+ end
74
+
75
+ def capture_exception(exc, **kw)
76
+ @client&.capture_exception(exc, **kw)
77
+ end
78
+
79
+ def capture_error(exception_class, message, **kw)
80
+ @client&.capture_error(exception_class, message, **kw)
81
+ end
82
+
83
+ # Cross-SDK parity with JS captureMessage / Python capture_message /
84
+ # Java captureMessage. Emits a string as an error-group entry at the
85
+ # given level. Safe no-op if the SDK is not configured.
86
+ def capture_message(message, level: "info", **kw)
87
+ @client&.capture_message(message, level: level, **kw)
88
+ end
89
+
90
+ def set_user(**kw)
91
+ @client&.set_user(**kw)
92
+ end
93
+
94
+ def clear_user
95
+ @client&.clear_user
96
+ end
97
+
98
+ # Attach a tag that sticks to every future event.
99
+ # Cross-SDK parity with JS setTag / Python set_tag.
100
+ def set_tag(key, value)
101
+ @client&.set_tag(key, value)
102
+ end
103
+
104
+ def set_tags(pairs)
105
+ @client&.set_tags(pairs)
106
+ end
107
+
108
+ # Attach a custom context entry to every future event.
109
+ # Cross-SDK parity with JS/Python setContext.
110
+ def set_context(key, value)
111
+ @client&.set_context(key, value)
112
+ end
113
+
114
+ def log
115
+ @client&.logs
116
+ end
117
+
118
+ def tracing
119
+ @client&.tracing
120
+ end
121
+
122
+ def http
123
+ @client&.http
124
+ end
125
+
126
+ def database
127
+ @client&.database
128
+ end
129
+
130
+ def cron
131
+ @client&.cron
132
+ end
133
+
134
+ def flush
135
+ @client&.flush
136
+ end
137
+
138
+ def shutdown
139
+ @client&.shutdown
140
+ end
141
+
142
+ # Test helper.
143
+ def reset!
144
+ @mutex.synchronize do
145
+ @client&.shutdown rescue nil
146
+ @client = nil
147
+ @config = nil
148
+ end
149
+ end
150
+ end
151
+ end
metadata ADDED
@@ -0,0 +1,128 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: allstak
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.1
5
+ platform: ruby
6
+ authors:
7
+ - AllStak
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-04-22 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.12'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '3.12'
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.19'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '3.19'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rack
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '3.0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '3.0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: activerecord
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '8.0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '8.0'
69
+ description: 'Production-ready Ruby SDK for AllStak observability: Rack/Rails middleware,
70
+ ActiveRecord instrumentation, outbound HTTP capture, distributed tracing, cron monitoring,
71
+ and structured logs.'
72
+ email:
73
+ - sdk@allstak.dev
74
+ executables: []
75
+ extensions: []
76
+ extra_rdoc_files: []
77
+ files:
78
+ - CHANGELOG.md
79
+ - LICENSE
80
+ - README.md
81
+ - allstak.gemspec
82
+ - lib/allstak.rb
83
+ - lib/allstak/client.rb
84
+ - lib/allstak/config.rb
85
+ - lib/allstak/integrations/active_record.rb
86
+ - lib/allstak/integrations/net_http.rb
87
+ - lib/allstak/integrations/rack.rb
88
+ - lib/allstak/models/user_context.rb
89
+ - lib/allstak/modules/cron.rb
90
+ - lib/allstak/modules/database.rb
91
+ - lib/allstak/modules/errors.rb
92
+ - lib/allstak/modules/http_monitor.rb
93
+ - lib/allstak/modules/logs.rb
94
+ - lib/allstak/modules/tracing.rb
95
+ - lib/allstak/transport/flush_buffer.rb
96
+ - lib/allstak/transport/http_transport.rb
97
+ - lib/allstak/version.rb
98
+ homepage: https://allstak.dev
99
+ licenses:
100
+ - MIT
101
+ metadata:
102
+ homepage_uri: https://allstak.dev
103
+ source_code_uri: https://github.com/allstak-io/allstak-ruby
104
+ changelog_uri: https://github.com/allstak-io/allstak-ruby/blob/main/CHANGELOG.md
105
+ bug_tracker_uri: https://github.com/allstak-io/allstak-ruby/issues
106
+ documentation_uri: https://allstak.dev/docs/sdks/ruby
107
+ rubygems_mfa_required: 'true'
108
+ post_install_message:
109
+ rdoc_options: []
110
+ require_paths:
111
+ - lib
112
+ required_ruby_version: !ruby/object:Gem::Requirement
113
+ requirements:
114
+ - - ">="
115
+ - !ruby/object:Gem::Version
116
+ version: 3.0.0
117
+ required_rubygems_version: !ruby/object:Gem::Requirement
118
+ requirements:
119
+ - - ">="
120
+ - !ruby/object:Gem::Version
121
+ version: '0'
122
+ requirements: []
123
+ rubygems_version: 3.4.19
124
+ signing_key:
125
+ specification_version: 4
126
+ summary: Official AllStak Ruby SDK — error tracking, logs, HTTP + ActiveRecord monitoring,
127
+ tracing, and cron monitoring
128
+ test_files: []