logbrew-sdk 0.1.0 → 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,179 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LogBrew
4
+ # Explicit dependency spans for app-owned database, cache, and queue work.
5
+ module OperationTracing
6
+ UNSAFE_METADATA_PATTERNS = [
7
+ /authorization/i,
8
+ /body/i,
9
+ /broker/i,
10
+ /cache.?key/i,
11
+ /command/i,
12
+ /connection/i,
13
+ /cookie/i,
14
+ /dsn/i,
15
+ /header/i,
16
+ /host/i,
17
+ /\bjid\z/i,
18
+ /job.?id/i,
19
+ /key/i,
20
+ /message/i,
21
+ /param/i,
22
+ /pass#{'word'}/i,
23
+ /payload/i,
24
+ /query/i,
25
+ /sec#{'ret'}/i,
26
+ /sql/i,
27
+ /statement/i,
28
+ /to#{'ken'}/i,
29
+ /url/i,
30
+ /username/i,
31
+ /value/i
32
+ ].freeze
33
+ private_constant :UNSAFE_METADATA_PATTERNS
34
+
35
+ module_function
36
+
37
+ def database_operation(client, name, **options, &block)
38
+ capture_operation(client, "database", name, options, &block)
39
+ end
40
+
41
+ def cache_operation(client, name, **options, &block)
42
+ capture_operation(client, "cache", name, options, &block)
43
+ end
44
+
45
+ def queue_operation(client, name, **options, &block)
46
+ capture_operation(client, "queue", name, options, &block)
47
+ end
48
+
49
+ def capture_operation(client, kind, name, options)
50
+ raise SdkError.new("validation_error", "#{kind} operation block is required") unless block_given?
51
+
52
+ Validation.require_non_empty("#{kind} operation name", name)
53
+ started_at = monotonic_time
54
+ context = child_context
55
+ error = nil
56
+ result = nil
57
+
58
+ Trace.with_context(context) do
59
+ begin
60
+ result = yield context
61
+ rescue StandardError => captured
62
+ error = captured
63
+ end
64
+ end
65
+
66
+ capture_span(client, kind, name, context, started_at, options, error)
67
+ raise error if error
68
+
69
+ result
70
+ end
71
+
72
+ def capture_span(client, kind, name, context, started_at, options, error)
73
+ client.span(
74
+ event_id(kind, context, options),
75
+ timestamp(options),
76
+ {
77
+ name: "#{kind}.operation:#{name}",
78
+ traceId: context.trace_id,
79
+ spanId: context.span_id,
80
+ parentSpanId: context.parent_span_id,
81
+ status: error ? "error" : "ok",
82
+ durationMs: duration_ms(started_at, options),
83
+ metadata: span_metadata(kind, options, error)
84
+ }
85
+ )
86
+ rescue StandardError => capture_error
87
+ on_error = read_option(options, :on_error)
88
+ begin
89
+ on_error.call(capture_error) if on_error.respond_to?(:call)
90
+ rescue StandardError
91
+ nil
92
+ end
93
+ end
94
+
95
+ def child_context
96
+ parent = Trace.current
97
+ return Trace.create_root unless parent
98
+
99
+ Trace.create(
100
+ trace_id: parent.trace_id,
101
+ span_id: generate_span_id,
102
+ parent_span_id: parent.span_id,
103
+ trace_flags: parent.trace_flags
104
+ )
105
+ end
106
+
107
+ def span_metadata(kind, options, error)
108
+ sanitized_metadata(read_option(options, :metadata)).tap do |metadata|
109
+ metadata["source"] = "#{kind}.operation"
110
+ add_option(metadata, "#{kind}.system", read_option(options, :system))
111
+ add_option(metadata, "#{kind}.operation", read_option(options, :operation))
112
+ add_option(metadata, "#{kind}.target", read_option(options, :target))
113
+ metadata["exceptionType"] = error.class.name if error
114
+ end
115
+ end
116
+
117
+ def sanitized_metadata(metadata)
118
+ return {} if metadata.nil?
119
+ raise SdkError.new("validation_error", "operation metadata must be an object") unless metadata.is_a?(Hash)
120
+
121
+ metadata.each_with_object({}) do |(key, value), copied|
122
+ normalized_key = key.to_s
123
+ next if normalized_key.strip.empty?
124
+ next if unsafe_metadata_key?(normalized_key)
125
+ next unless primitive_metadata_value?(value)
126
+
127
+ copied[normalized_key] = value
128
+ end
129
+ end
130
+
131
+ def unsafe_metadata_key?(key)
132
+ UNSAFE_METADATA_PATTERNS.any? { |pattern| key.match?(pattern) }
133
+ end
134
+
135
+ def primitive_metadata_value?(value)
136
+ return true if value.nil? || value == true || value == false
137
+ return true if value.is_a?(String) || value.is_a?(Integer)
138
+
139
+ value.is_a?(Float) && value.finite?
140
+ end
141
+
142
+ def add_option(metadata, key, value)
143
+ metadata[key] = value if primitive_metadata_value?(value) && !(value.is_a?(String) && value.strip.empty?)
144
+ end
145
+
146
+ def read_option(options, key)
147
+ options[key] || options[key.to_s]
148
+ end
149
+
150
+ def event_id(kind, context, options)
151
+ read_option(options, :event_id) || "ruby_#{kind}_span_#{context.span_id}"
152
+ end
153
+
154
+ def timestamp(options)
155
+ value = read_option(options, :timestamp)
156
+ return value unless value.nil?
157
+
158
+ Time.now.utc.iso8601
159
+ end
160
+
161
+ def duration_ms(started_at, options)
162
+ configured = read_option(options, :duration_ms)
163
+ return configured unless configured.nil?
164
+
165
+ ((monotonic_time - started_at) * 1000.0).round(3)
166
+ end
167
+
168
+ def monotonic_time
169
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
170
+ end
171
+
172
+ def generate_span_id
173
+ loop do
174
+ value = SecureRandom.hex(8)
175
+ return value unless value.delete("0").empty?
176
+ end
177
+ end
178
+ end
179
+ end
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LogBrew
4
+ # Builders for app-owned product and network timeline action events.
5
+ class ProductTimeline
6
+ private_class_method :new
7
+
8
+ def self.product_action(
9
+ name:,
10
+ status: "success",
11
+ route_template: nil,
12
+ session_id: nil,
13
+ trace_id: nil,
14
+ screen: nil,
15
+ funnel: nil,
16
+ step: nil,
17
+ metadata: nil
18
+ )
19
+ action_metadata = timeline_metadata("product_timeline", metadata)
20
+ put_if_present(action_metadata, "routeTemplate", sanitize_optional_route_template("product route_template", route_template))
21
+ put_if_present(action_metadata, "sessionId", optional_label("session_id", session_id))
22
+ put_if_present(action_metadata, "traceId", optional_label("trace_id", trace_id))
23
+ put_if_present(action_metadata, "screen", optional_label("screen", screen))
24
+ put_if_present(action_metadata, "funnel", optional_label("funnel", funnel))
25
+ put_if_present(action_metadata, "step", optional_label("step", step))
26
+
27
+ {
28
+ "name" => required_label("product action name", name),
29
+ "status" => normalize_status(status),
30
+ "metadata" => action_metadata
31
+ }
32
+ end
33
+
34
+ def self.network_milestone(
35
+ route_template:,
36
+ method: "GET",
37
+ status_code: nil,
38
+ duration_ms: nil,
39
+ status: nil,
40
+ name: nil,
41
+ session_id: nil,
42
+ trace_id: nil,
43
+ metadata: nil
44
+ )
45
+ route = sanitize_route_template("network milestone route_template", route_template)
46
+ normalized_method = normalize_method(method)
47
+ validate_status_code(status_code)
48
+ validate_duration_ms(duration_ms)
49
+ normalized_status = normalize_status(status || (status_code && status_code >= 400 ? "failure" : "success"))
50
+
51
+ action_metadata = timeline_metadata("network_timeline", metadata)
52
+ action_metadata["routeTemplate"] = route
53
+ action_metadata["method"] = normalized_method
54
+ put_if_present(action_metadata, "statusCode", status_code)
55
+ put_if_present(action_metadata, "durationMs", duration_ms)
56
+ put_if_present(action_metadata, "sessionId", optional_label("session_id", session_id))
57
+ put_if_present(action_metadata, "traceId", optional_label("trace_id", trace_id))
58
+
59
+ {
60
+ "name" => name.nil? ? "network.#{normalized_method.downcase} #{route}" : required_label("network milestone name", name),
61
+ "status" => normalized_status,
62
+ "metadata" => action_metadata
63
+ }
64
+ end
65
+
66
+ def self.timeline_metadata(source, metadata)
67
+ copied = { "source" => source }
68
+ validated = Validation.require_metadata(metadata)
69
+ return copied if validated.nil?
70
+
71
+ validated.each do |key, value|
72
+ copied[key] = value unless key == "source"
73
+ end
74
+ copied
75
+ end
76
+
77
+ def self.required_label(label, value)
78
+ Validation.require_non_empty(label, value)
79
+ value.to_s.strip
80
+ end
81
+
82
+ def self.optional_label(label, value)
83
+ return nil if value.nil?
84
+
85
+ required_label(label, value)
86
+ end
87
+
88
+ def self.normalize_status(status)
89
+ Validation.require_allowed_value("action status", status, LogBrew::ACTION_STATUSES)
90
+ status
91
+ end
92
+
93
+ def self.sanitize_optional_route_template(label, route_template)
94
+ return nil if route_template.nil?
95
+
96
+ sanitize_route_template(label, route_template)
97
+ end
98
+
99
+ def self.sanitize_route_template(label, route_template)
100
+ Validation.require_non_empty(label, route_template)
101
+ trimmed = route_template.to_s.strip
102
+ if trimmed.match?(/\Ahttps?:\/\//i)
103
+ uri = URI.parse(trimmed)
104
+ return uri.path.nil? || uri.path.empty? ? "/" : uri.path
105
+ end
106
+
107
+ cutoff = first_present_index(trimmed.index("?"), trimmed.index("#"))
108
+ cutoff.nil? ? trimmed : (trimmed[0...cutoff].rstrip.empty? ? "/" : trimmed[0...cutoff].rstrip)
109
+ rescue URI::InvalidURIError => error
110
+ raise SdkError.new("validation_error", "route_template must be a valid route or URL: #{error.message}")
111
+ end
112
+
113
+ def self.normalize_method(method)
114
+ normalized = method.to_s.strip.upcase
115
+ unless normalized.match?(/\A[A-Z0-9_-]+\z/)
116
+ raise SdkError.new("validation_error", "network milestone method must be a valid HTTP method")
117
+ end
118
+
119
+ normalized
120
+ end
121
+
122
+ def self.validate_status_code(status_code)
123
+ return if status_code.nil?
124
+
125
+ unless status_code.is_a?(Integer) && status_code >= 100 && status_code <= 599
126
+ raise SdkError.new("validation_error", "network milestone status_code must be between 100 and 599")
127
+ end
128
+ end
129
+
130
+ def self.validate_duration_ms(duration_ms)
131
+ return if duration_ms.nil?
132
+ unless duration_ms.is_a?(Numeric) && duration_ms.finite?
133
+ raise SdkError.new("validation_error", "network milestone duration_ms must be a finite number")
134
+ end
135
+ raise SdkError.new("validation_error", "network milestone duration_ms must be non-negative") if duration_ms.negative?
136
+ end
137
+
138
+ def self.put_if_present(metadata, key, value)
139
+ metadata[key] = value unless value.nil?
140
+ end
141
+
142
+ def self.first_present_index(first, second)
143
+ return second if first.nil?
144
+ return first if second.nil?
145
+
146
+ [first, second].min
147
+ end
148
+
149
+ private_class_method :timeline_metadata, :required_label, :optional_label, :normalize_status,
150
+ :sanitize_optional_route_template, :sanitize_route_template, :normalize_method,
151
+ :validate_status_code, :validate_duration_ms, :put_if_present, :first_present_index
152
+ end
153
+ end
@@ -0,0 +1,187 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LogBrew
4
+ # Local-only support-ticket payload drafts for explicit user or agent handoff.
5
+ module SupportTicketDraft
6
+ SOURCES = %w[cli sdk website docs mobile].freeze
7
+ CATEGORIES = %w[
8
+ sdk_install_failure
9
+ ingest_failure
10
+ auth_failure
11
+ project_setup
12
+ dashboard_issue
13
+ docs_confusion
14
+ cli_issue
15
+ mobile_issue
16
+ billing_question
17
+ other
18
+ ].freeze
19
+ SENSITIVE_KEY_MARKERS = %w[
20
+ apikey
21
+ auth
22
+ authorization
23
+ authtoken
24
+ bearer
25
+ clientsecret
26
+ connectionstring
27
+ cookie
28
+ credential
29
+ dsn
30
+ email
31
+ errormessage
32
+ exceptionmessage
33
+ password
34
+ passwd
35
+ privatekey
36
+ refreshtoken
37
+ secret
38
+ session
39
+ setcookie
40
+ stacktrace
41
+ token
42
+ traceback
43
+ ].freeze
44
+ REDACTED = "[redacted]"
45
+ MAX_DEPTH = 5
46
+ MAX_ARRAY_LENGTH = 20
47
+ MAX_STRING_LENGTH = 500
48
+
49
+ module_function
50
+
51
+ def create(source:, category:, title:, description:, **options)
52
+ Validation.require_allowed_value("support ticket source", source, SOURCES)
53
+ Validation.require_allowed_value("support ticket category", category, CATEGORIES)
54
+
55
+ draft = {
56
+ "source" => source,
57
+ "category" => category,
58
+ "title" => required_string("support ticket title", title),
59
+ "description" => required_string("support ticket description", description)
60
+ }
61
+ add_optional_string(draft, "project_id", options[:project_id])
62
+ add_optional_string(draft, "environment", options[:environment])
63
+ add_optional_string(draft, "runtime", options[:runtime])
64
+ add_optional_string(draft, "framework", options[:framework])
65
+ add_optional_string(draft, "sdk_package", options[:sdk_package])
66
+ add_optional_string(draft, "sdk_version", options[:sdk_version])
67
+ add_optional_string(draft, "release", options[:release])
68
+ add_trace_id(draft, options[:trace_id])
69
+ add_optional_string(draft, "event_id", options[:event_id])
70
+ add_diagnostics(draft, options[:diagnostics]) if options.key?(:diagnostics)
71
+ draft
72
+ end
73
+
74
+ def required_string(label, value)
75
+ Validation.require_non_empty(label, value)
76
+ value.strip
77
+ end
78
+ private_class_method :required_string
79
+
80
+ def add_optional_string(draft, key, value)
81
+ return if value.nil?
82
+
83
+ draft[key] = required_string("support ticket #{key}", value)
84
+ end
85
+ private_class_method :add_optional_string
86
+
87
+ def add_trace_id(draft, value)
88
+ return if value.nil?
89
+
90
+ normalized = value.to_s.strip.downcase
91
+ unless normalized.match?(/\A[0-9a-f]{32}\z/) && !normalized.delete("0").empty?
92
+ raise SdkError.new("validation_error", "support ticket trace_id must be 32 non-zero hex characters")
93
+ end
94
+ draft["trace_id"] = normalized
95
+ end
96
+ private_class_method :add_trace_id
97
+
98
+ def add_diagnostics(draft, diagnostics)
99
+ unless diagnostics.is_a?(Hash)
100
+ raise SdkError.new("validation_error", "support ticket diagnostics must be an object")
101
+ end
102
+
103
+ sanitized = sanitize_diagnostic_hash(diagnostics, 0)
104
+ draft["diagnostics"] = sanitized unless sanitized.empty?
105
+ end
106
+ private_class_method :add_diagnostics
107
+
108
+ def sanitize_diagnostic_hash(value, depth)
109
+ value.each_with_object({}) do |(key, child), safe|
110
+ break safe if safe.length >= MAX_ARRAY_LENGTH
111
+
112
+ normalized_key = key.to_s
113
+ next if normalized_key.strip.empty?
114
+
115
+ sanitized = if sensitive_key?(normalized_key)
116
+ REDACTED
117
+ else
118
+ sanitize_diagnostic_value(child, depth + 1)
119
+ end
120
+ safe[normalized_key] = sanitized unless sanitized.nil?
121
+ end
122
+ end
123
+ private_class_method :sanitize_diagnostic_hash
124
+
125
+ def sanitize_diagnostic_value(value, depth)
126
+ return { "type" => value.class.name } if value.is_a?(Exception)
127
+ return nil if depth > MAX_DEPTH
128
+
129
+ case value
130
+ when nil, true, false, String, Integer
131
+ sanitize_primitive(value)
132
+ when Float
133
+ value.finite? ? value : nil
134
+ when Hash
135
+ sanitize_diagnostic_hash(value, depth)
136
+ when Array
137
+ value.first(MAX_ARRAY_LENGTH).map { |item| sanitize_diagnostic_value(item, depth + 1) }.compact
138
+ else
139
+ nil
140
+ end
141
+ end
142
+ private_class_method :sanitize_diagnostic_value
143
+
144
+ def sanitize_primitive(value)
145
+ return value unless value.is_a?(String)
146
+
147
+ text = value.strip
148
+ return "" if text.empty?
149
+ return REDACTED if sensitive_string?(text)
150
+
151
+ redacted = redact_url(redact_local_path(text))
152
+ redacted.length > MAX_STRING_LENGTH ? "#{redacted[0, MAX_STRING_LENGTH]}..." : redacted
153
+ end
154
+ private_class_method :sanitize_primitive
155
+
156
+ def sensitive_key?(key)
157
+ normalized = key.downcase.gsub(/[^a-z0-9]/, "")
158
+ SENSITIVE_KEY_MARKERS.any? { |marker| normalized.include?(marker) }
159
+ end
160
+ private_class_method :sensitive_key?
161
+
162
+ def sensitive_string?(value)
163
+ value.match?(/(?:authorization|api[_-]?key|token|secret|password|passwd|cookie)\s*[:=]/i) ||
164
+ value.match?(/\bBearer\s+[A-Za-z0-9._~+\/=-]+/i) ||
165
+ value.match?(/\blbw_(?:ingest|client|api)_[A-Za-z0-9._-]+/i) ||
166
+ value.match?(/\b(?:github_pat|ghp|gho|npm|pypi|sk_live|sk_test|xox[baprs]|AKIA)[A-Za-z0-9._-]+/)
167
+ end
168
+ private_class_method :sensitive_string?
169
+
170
+ def redact_local_path(value)
171
+ return "[redacted-path]" if value.match?(/\A(?:\/Users\/|\/home\/|\/var\/folders\/|[A-Za-z]:\\)/)
172
+
173
+ value
174
+ end
175
+ private_class_method :redact_local_path
176
+
177
+ def redact_url(value)
178
+ uri = URI.parse(value)
179
+ return "[redacted-url]#{uri.path.empty? ? '/' : uri.path}" if uri.is_a?(URI::HTTP) && uri.host
180
+
181
+ value.split(/[?#]/, 2)[0]
182
+ rescue URI::InvalidURIError
183
+ value.split(/[?#]/, 2)[0]
184
+ end
185
+ private_class_method :redact_url
186
+ end
187
+ end