logbrew-sdk 0.1.1 → 0.1.2

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.
data/lib/logbrew.rb CHANGED
@@ -39,6 +39,10 @@ module LogBrew
39
39
  end
40
40
  end
41
41
 
42
+ require_relative "logbrew/event_batcher"
43
+ require_relative "logbrew/persistent_event_store"
44
+ require_relative "logbrew/bounded_event_queue"
45
+
42
46
  class TransportError < StandardError
43
47
  attr_reader :code, :retryable
44
48
 
@@ -54,14 +58,30 @@ module LogBrew
54
58
  end
55
59
 
56
60
  class TransportResponse
57
- attr_reader :status_code, :attempts
61
+ attr_reader :status_code, :attempts, :batches
58
62
 
59
- def initialize(status_code, attempts)
63
+ def initialize(status_code, attempts, batches = 1)
60
64
  @status_code = status_code
61
65
  @attempts = attempts
66
+ @batches = batches
62
67
  end
63
68
  end
64
69
 
70
+ class DeliveryFailure < SdkError
71
+ attr_reader :automatic_pause_reason
72
+
73
+ def initialize(code, message, automatic_retryable: false, automatic_pause_reason: nil)
74
+ @automatic_retryable = automatic_retryable
75
+ @automatic_pause_reason = automatic_pause_reason
76
+ super(code, message)
77
+ end
78
+
79
+ def automatic_retryable?
80
+ @automatic_retryable
81
+ end
82
+ end
83
+ private_constant :DeliveryFailure
84
+
65
85
  class RecordingTransport
66
86
  attr_reader :sent_bodies
67
87
 
@@ -92,7 +112,7 @@ module LogBrew
92
112
  end
93
113
 
94
114
  class HttpTransport
95
- DEFAULT_ENDPOINT = "https://api.logbrew.com/v1/events"
115
+ DEFAULT_ENDPOINT = "https://api.logbrew.co/v1/events"
96
116
  DEFAULT_TIMEOUT = 10
97
117
 
98
118
  attr_reader :endpoint, :headers, :timeout, :http_client
@@ -114,7 +134,9 @@ module LogBrew
114
134
  @headers.each { |name, value| request[name] = value }
115
135
  request.body = body
116
136
 
117
- response = @http_client ? @http_client.request(request) : request_with_default_client(request)
137
+ response = HttpClientTracing.suppress do
138
+ @http_client ? @http_client.request(request) : request_with_default_client(request)
139
+ end
118
140
  TransportResponse.new(response.code.to_i, 1)
119
141
  rescue TransportError
120
142
  raise
@@ -668,7 +690,7 @@ module LogBrew
668
690
  module_function
669
691
 
670
692
  def require_non_empty(label, value)
671
- return if value.is_a?(String) && !value.strip.empty?
693
+ return if value.is_a?(String) && value.valid_encoding? && !value.strip.empty?
672
694
 
673
695
  raise SdkError.new("validation_error", "#{label} must be non-empty")
674
696
  end
@@ -739,7 +761,23 @@ module LogBrew
739
761
  end
740
762
 
741
763
  class Client
742
- def self.create(api_key:, sdk_name:, sdk_version:, max_retries: 2)
764
+ DEFAULT_AUTOMATIC_FLUSH_INTERVAL = 5.0
765
+ DEFAULT_AUTOMATIC_FLUSH_THRESHOLD = 100
766
+ DEFAULT_AUTOMATIC_RETRY_BASE_DELAY = 0.25
767
+ DEFAULT_AUTOMATIC_RETRY_MAX_DELAY = 30.0
768
+
769
+ def self.create(
770
+ api_key:,
771
+ sdk_name:,
772
+ sdk_version:,
773
+ max_retries: 2,
774
+ max_queue_size: BoundedEventQueue::DEFAULT_MAX_SIZE,
775
+ max_queue_bytes: BoundedEventQueue::DEFAULT_MAX_BYTES,
776
+ on_event_dropped: nil,
777
+ max_batch_size: EventBatcher::DEFAULT_MAX_SIZE,
778
+ max_batch_bytes: EventBatcher::DEFAULT_MAX_BYTES,
779
+ persistent_queue_path: nil
780
+ )
743
781
  Validation.require_non_empty("api_key", api_key)
744
782
  Validation.require_non_empty("sdk_name", sdk_name)
745
783
  Validation.require_non_empty("sdk_version", sdk_version)
@@ -747,25 +785,158 @@ module LogBrew
747
785
 
748
786
  new(
749
787
  api_key: api_key,
750
- sdk: { "name" => sdk_name, "language" => "ruby", "version" => sdk_version },
751
- max_retries: max_retries
788
+ sdk: {
789
+ "name" => sdk_name.dup.freeze,
790
+ "language" => "ruby",
791
+ "version" => sdk_version.dup.freeze
792
+ }.freeze,
793
+ max_retries: max_retries,
794
+ max_queue_size: max_queue_size,
795
+ max_queue_bytes: max_queue_bytes,
796
+ on_event_dropped: on_event_dropped,
797
+ max_batch_size: max_batch_size,
798
+ max_batch_bytes: max_batch_bytes,
799
+ persistent_queue_path: persistent_queue_path
752
800
  )
753
801
  end
754
802
 
755
- def initialize(api_key:, sdk:, max_retries:)
803
+ def self.create_automatic(
804
+ api_key:,
805
+ sdk_name:,
806
+ sdk_version:,
807
+ transport:,
808
+ flush_interval: DEFAULT_AUTOMATIC_FLUSH_INTERVAL,
809
+ flush_threshold: DEFAULT_AUTOMATIC_FLUSH_THRESHOLD,
810
+ retry_base_delay: DEFAULT_AUTOMATIC_RETRY_BASE_DELAY,
811
+ retry_max_delay: DEFAULT_AUTOMATIC_RETRY_MAX_DELAY,
812
+ **options
813
+ )
814
+ max_queue_size = options.fetch(:max_queue_size, BoundedEventQueue::DEFAULT_MAX_SIZE)
815
+ AutomaticDelivery.validate!(
816
+ transport: transport,
817
+ flush_interval: flush_interval,
818
+ flush_threshold: flush_threshold,
819
+ retry_base_delay: retry_base_delay,
820
+ retry_max_delay: retry_max_delay,
821
+ max_queue_size: max_queue_size
822
+ )
823
+ client = create(
824
+ api_key: api_key,
825
+ sdk_name: sdk_name,
826
+ sdk_version: sdk_version,
827
+ **options
828
+ )
829
+ client.send(
830
+ :enable_automatic_delivery,
831
+ transport: transport,
832
+ flush_interval: flush_interval,
833
+ flush_threshold: flush_threshold,
834
+ retry_base_delay: retry_base_delay,
835
+ retry_max_delay: retry_max_delay
836
+ )
837
+ client
838
+ end
839
+
840
+ def initialize(
841
+ api_key:,
842
+ sdk:,
843
+ max_retries:,
844
+ max_queue_size: BoundedEventQueue::DEFAULT_MAX_SIZE,
845
+ max_queue_bytes: BoundedEventQueue::DEFAULT_MAX_BYTES,
846
+ on_event_dropped: nil,
847
+ max_batch_size: EventBatcher::DEFAULT_MAX_SIZE,
848
+ max_batch_bytes: EventBatcher::DEFAULT_MAX_BYTES,
849
+ persistent_queue_path: nil
850
+ )
756
851
  @api_key = api_key
757
852
  @sdk = sdk
758
853
  @max_retries = max_retries
759
- @events = []
854
+ @event_batcher = EventBatcher.new(sdk: sdk, max_size: max_batch_size, max_bytes: max_batch_bytes)
855
+ event_store = nil
856
+ begin
857
+ event_store = PersistentEventStore.open(path: persistent_queue_path) unless persistent_queue_path.nil?
858
+ @event_queue = BoundedEventQueue.new(
859
+ max_size: max_queue_size,
860
+ max_bytes: max_queue_bytes,
861
+ max_event_bytes: @event_batcher.max_event_bytes,
862
+ on_event_dropped: on_event_dropped,
863
+ event_store: event_store
864
+ )
865
+ rescue StandardError
866
+ event_store&.close
867
+ raise
868
+ end
869
+ @state_mutex = Mutex.new
870
+ @flush_mutex = Mutex.new
760
871
  @closed = false
872
+ @closing = false
873
+ @retry_batch = nil
874
+ @automatic_delivery = nil
761
875
  end
762
876
 
763
877
  def pending_events
764
- @events.length
878
+ @event_queue.length
879
+ end
880
+
881
+ def pending_event_bytes
882
+ @event_queue.pending_bytes
883
+ end
884
+
885
+ def dropped_events
886
+ @event_queue.dropped_events
887
+ end
888
+
889
+ def delivery_health
890
+ controller = @automatic_delivery
891
+ unless controller.nil?
892
+ controller.assert_process_ownership!
893
+ return controller.snapshot
894
+ end
895
+
896
+ metrics = queue_metrics
897
+ state = @state_mutex.synchronize { @closed ? "closed" : "manual" }
898
+ DeliveryHealth.new(
899
+ state: state,
900
+ queued_events: metrics.fetch(:queued_events),
901
+ queued_bytes: metrics.fetch(:queued_bytes),
902
+ dropped_events: metrics.fetch(:dropped_events),
903
+ in_flight: false,
904
+ last_outcome: "none",
905
+ consecutive_failures: 0,
906
+ pause_reason: nil,
907
+ successful_flushes: 0,
908
+ failed_flushes: 0,
909
+ retry_delay_ms: 0
910
+ )
911
+ end
912
+
913
+ def recover_automatic_delivery
914
+ controller = require_automatic_delivery
915
+ controller.assert_process_ownership!
916
+ flush
917
+ end
918
+
919
+ def stop_automatic_delivery
920
+ controller = require_automatic_delivery
921
+ controller.stop
765
922
  end
766
923
 
767
924
  def preview_json
768
- JSON.pretty_generate("sdk" => @sdk, "events" => @events)
925
+ snapshot = @event_queue.snapshot
926
+ JSON.pretty_generate("sdk" => @sdk, "events" => snapshot.events)
927
+ end
928
+
929
+ def purge_pending_events
930
+ ensure_not_reentrant_flush
931
+ controller = @automatic_delivery
932
+ controller&.assert_process_ownership!
933
+ @flush_mutex.synchronize do
934
+ @state_mutex.synchronize { ensure_open }
935
+ @retry_batch = nil
936
+ removed = @event_queue.purge
937
+ controller&.queue_changed
938
+ removed
939
+ end
769
940
  end
770
941
 
771
942
  def release(id, timestamp, attributes)
@@ -796,59 +967,208 @@ module LogBrew
796
967
  push_event("action", id, timestamp, validate_action(attributes))
797
968
  end
798
969
 
799
- def flush(transport)
800
- raise SdkError.new("shutdown_error", "client is already shut down") if @closed
801
-
802
- flush_internal(transport)
970
+ def flush(transport = nil)
971
+ ensure_not_reentrant_flush
972
+ controller = @automatic_delivery
973
+ controller&.assert_process_ownership!
974
+ resolved_transport = resolve_transport(transport)
975
+ @flush_mutex.synchronize do
976
+ @state_mutex.synchronize { ensure_open }
977
+ previous = controller&.begin_explicit_flush
978
+ begin
979
+ response = flush_internal(resolved_transport)
980
+ controller&.complete_explicit_flush(previous, response)
981
+ response
982
+ rescue StandardError => error
983
+ controller&.fail_explicit_flush(previous, error)
984
+ raise
985
+ end
986
+ end
803
987
  end
804
988
 
805
- def shutdown(transport)
806
- raise SdkError.new("shutdown_error", "client is already shut down") if @closed
989
+ def shutdown(transport = nil)
990
+ ensure_not_reentrant_flush
991
+ controller = @automatic_delivery
992
+ controller&.assert_process_ownership!
993
+ resolved_transport = resolve_transport(transport)
994
+ controller&.prepare_shutdown
995
+ @flush_mutex.synchronize do
996
+ @state_mutex.synchronize do
997
+ ensure_open
998
+ @closing = true
999
+ end
807
1000
 
808
- response = flush_internal(transport)
809
- @closed = true
810
- response
1001
+ succeeded = false
1002
+ failure = nil
1003
+ begin
1004
+ response = flush_internal(resolved_transport)
1005
+ @event_queue.close
1006
+ succeeded = true
1007
+ controller&.complete_shutdown(response)
1008
+ response
1009
+ rescue StandardError => error
1010
+ failure = error
1011
+ raise
1012
+ ensure
1013
+ @state_mutex.synchronize do
1014
+ @closed = true if succeeded
1015
+ @closing = false
1016
+ end
1017
+ controller&.resume_after_failed_shutdown(failure) unless succeeded
1018
+ end
1019
+ end
811
1020
  end
812
1021
 
813
1022
  private
814
1023
 
815
1024
  def push_event(type, id, timestamp, attributes)
816
- raise SdkError.new("shutdown_error", "client is already shut down") if @closed
817
-
1025
+ @automatic_delivery&.assert_process_ownership!
818
1026
  Validation.require_non_empty("event id", id)
819
1027
  Validation.require_timestamp(timestamp)
820
- @events << {
1028
+ event = {
821
1029
  "type" => type,
822
1030
  "timestamp" => timestamp,
823
1031
  "id" => id,
824
1032
  "attributes" => attributes
825
1033
  }
1034
+ notice = @state_mutex.synchronize do
1035
+ ensure_open
1036
+ @event_queue.enqueue(event_id: id, event_type: type, event: event)
1037
+ end
1038
+ @event_queue.notify_drop(notice)
1039
+ @automatic_delivery&.notify_capture if notice.nil?
1040
+ end
1041
+
1042
+ def enable_automatic_delivery(transport:, flush_interval:, flush_threshold:, retry_base_delay:, retry_max_delay:)
1043
+ @automatic_delivery = AutomaticDelivery.new(
1044
+ client: self,
1045
+ transport: transport,
1046
+ flush_interval: flush_interval,
1047
+ flush_threshold: flush_threshold,
1048
+ retry_base_delay: retry_base_delay,
1049
+ retry_max_delay: retry_max_delay
1050
+ )
1051
+ end
1052
+
1053
+ def perform_automatic_flush(controller, generation, transport)
1054
+ @flush_mutex.synchronize do
1055
+ begin
1056
+ @state_mutex.synchronize { ensure_open }
1057
+ response = flush_internal(transport)
1058
+ controller.complete_automatic_flush(generation, response)
1059
+ rescue StandardError => error
1060
+ controller.fail_automatic_flush(generation, error)
1061
+ end
1062
+ end
826
1063
  end
827
1064
 
828
1065
  def flush_internal(transport)
829
- return TransportResponse.new(204, 0) if @events.empty?
1066
+ snapshot = @event_queue.snapshot
1067
+ return TransportResponse.new(204, 0, 0) if snapshot.event_count.zero?
1068
+
1069
+ remaining_events = snapshot.event_count
1070
+ attempts = 0
1071
+ batches = 0
1072
+ status_code = 204
1073
+
1074
+ while remaining_events.positive?
1075
+ batch = @retry_batch || @event_batcher.next_batch(
1076
+ @event_queue.snapshot.serialized_events,
1077
+ limit: remaining_events
1078
+ )
1079
+ @retry_batch = batch
1080
+ response = send_batch(transport, batch.body)
1081
+ compaction_error = @event_queue.acknowledge(batch)
1082
+ @retry_batch = nil
1083
+ raise compaction_error unless compaction_error.nil?
1084
+ remaining_events -= batch.event_count
1085
+ attempts += response.attempts
1086
+ batches += 1
1087
+ status_code = response.status_code
1088
+ end
830
1089
 
831
- body = preview_json
1090
+ TransportResponse.new(status_code, attempts, batches)
1091
+ end
1092
+
1093
+ def send_batch(transport, body)
832
1094
  max_attempts = @max_retries + 1
833
1095
  (1..max_attempts).each do |attempt|
834
1096
  begin
835
1097
  response = transport.send(@api_key, body)
836
- raise SdkError.new("unauthenticated", "transport rejected the API key") if response.status_code == 401
1098
+ if response.status_code == 401 || response.status_code == 403
1099
+ raise DeliveryFailure.new(
1100
+ "unauthenticated",
1101
+ "transport rejected the API key",
1102
+ automatic_pause_reason: "authentication"
1103
+ )
1104
+ end
837
1105
 
838
1106
  if response.status_code >= 200 && response.status_code < 300
839
- @events.clear
840
1107
  return TransportResponse.new(response.status_code, attempt)
841
1108
  end
842
- next if response.status_code >= 500 && attempt < max_attempts
1109
+ retryable_status = response.status_code == 408 || response.status_code >= 500
1110
+ next if retryable_status && attempt < max_attempts
1111
+
1112
+ if retryable_status
1113
+ raise DeliveryFailure.new(
1114
+ "transport_error",
1115
+ "unexpected transport status #{response.status_code}",
1116
+ automatic_retryable: true
1117
+ )
1118
+ end
1119
+
1120
+ pause_reason = case response.status_code
1121
+ when 429 then "quota"
1122
+ when 400, 422 then "validation"
1123
+ else "nonretryable"
1124
+ end
843
1125
 
844
- raise SdkError.new("transport_error", "unexpected transport status #{response.status_code}")
1126
+ raise DeliveryFailure.new(
1127
+ "transport_error",
1128
+ "unexpected transport status #{response.status_code}",
1129
+ automatic_pause_reason: pause_reason
1130
+ )
845
1131
  rescue TransportError => error
846
1132
  next if error.retryable && attempt < max_attempts
847
1133
 
848
- raise SdkError.new(error.code, error.message)
1134
+ raise DeliveryFailure.new(
1135
+ error.code,
1136
+ error.message,
1137
+ automatic_retryable: error.retryable
1138
+ )
849
1139
  end
850
1140
  end
851
- raise SdkError.new("transport_error", "exhausted retries")
1141
+ raise DeliveryFailure.new("transport_error", "exhausted retries", automatic_retryable: true)
1142
+ end
1143
+
1144
+ def require_automatic_delivery
1145
+ return @automatic_delivery unless @automatic_delivery.nil?
1146
+
1147
+ raise SdkError.new("automatic_delivery_error", "client does not own automatic delivery")
1148
+ end
1149
+
1150
+ def resolve_transport(transport)
1151
+ resolved = transport || @automatic_delivery&.transport
1152
+ unless resolved.respond_to?(:send)
1153
+ raise SdkError.new("validation_error", "transport must respond to send")
1154
+ end
1155
+
1156
+ resolved
1157
+ end
1158
+
1159
+ def queue_metrics
1160
+ @event_queue.metrics
1161
+ end
1162
+
1163
+ def ensure_not_reentrant_flush
1164
+ return unless @flush_mutex.owned?
1165
+
1166
+ raise SdkError.new("flush_error", "flush is already in progress")
1167
+ end
1168
+
1169
+ def ensure_open
1170
+ raise SdkError.new("shutdown_error", "client is already shut down") if @closed
1171
+ raise SdkError.new("shutdown_error", "client is shutting down") if @closing
852
1172
  end
853
1173
 
854
1174
  def validate_release(attributes)
@@ -937,6 +1257,7 @@ module LogBrew
937
1257
  if !duration_ms.nil? && (!duration_ms.is_a?(Numeric) || duration_ms.negative?)
938
1258
  raise SdkError.new("validation_error", "span durationMs must be non-negative")
939
1259
  end
1260
+ span_events = SpanEvents.validate(Validation.read(attributes, "events"))
940
1261
 
941
1262
  with_metadata(
942
1263
  {
@@ -947,6 +1268,7 @@ module LogBrew
947
1268
  payload["parentSpanId"] = parent_span_id unless parent_span_id.nil?
948
1269
  payload["status"] = status
949
1270
  payload["durationMs"] = duration_ms unless duration_ms.nil?
1271
+ payload["events"] = span_events unless span_events.nil?
950
1272
  end,
951
1273
  attributes
952
1274
  )
@@ -992,8 +1314,10 @@ module LogBrew
992
1314
  payload
993
1315
  end
994
1316
  end
1317
+
1318
+ require_relative "logbrew/automatic_delivery"
995
1319
  end
996
1320
 
997
- %w[product_timeline traceparent trace operation_tracing support_ticket].each do |path|
1321
+ %w[product_timeline traceparent trace span_events operation_tracing http_client_tracing support_ticket worker_lifecycle].each do |path|
998
1322
  require_relative "logbrew/#{path}"
999
1323
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: logbrew-sdk
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.1.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - LogBrew
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-07-01 00:00:00.000000000 Z
11
+ date: 2026-07-23 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: Public LogBrew Ruby SDK for building, validating, and flushing event
14
14
  batches.
@@ -19,16 +19,28 @@ extra_rdoc_files: []
19
19
  files:
20
20
  - README.md
21
21
  - examples/Makefile
22
+ - examples/automatic_delivery.rb
22
23
  - examples/first_useful_telemetry.rb
23
24
  - examples/http_trace_correlation.rb
25
+ - examples/persistent_worker_delivery.rb
24
26
  - examples/readme_example.rb
25
27
  - examples/real_user_smoke.rb
28
+ - examples/sidekiq_tracing.rb
26
29
  - lib/logbrew.rb
30
+ - lib/logbrew/automatic_delivery.rb
31
+ - lib/logbrew/bounded_event_queue.rb
32
+ - lib/logbrew/event_batcher.rb
33
+ - lib/logbrew/faraday_tracing.rb
34
+ - lib/logbrew/http_client_tracing.rb
27
35
  - lib/logbrew/operation_tracing.rb
36
+ - lib/logbrew/persistent_event_store.rb
28
37
  - lib/logbrew/product_timeline.rb
38
+ - lib/logbrew/sidekiq.rb
39
+ - lib/logbrew/span_events.rb
29
40
  - lib/logbrew/support_ticket.rb
30
41
  - lib/logbrew/trace.rb
31
42
  - lib/logbrew/traceparent.rb
43
+ - lib/logbrew/worker_lifecycle.rb
32
44
  homepage: https://github.com/LogBrewCo/sdk
33
45
  licenses:
34
46
  - MIT