gapic-common 1.4.1 → 1.5.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.
@@ -0,0 +1,168 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Copyright 2026 Google LLC
4
+ #
5
+ # Licensed under the Apache License, Version 2.0 (the "License");
6
+ # you may not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # https://www.apache.org/licenses/LICENSE-2.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing, software
12
+ # distributed under the License is distributed on an "AS IS" BASIS,
13
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ # See the License for the specific language governing permissions and
15
+ # limitations under the License.
16
+
17
+ require "uri"
18
+
19
+ module Gapic
20
+ module Rest
21
+ module ResumableUpload
22
+ class Driver
23
+ ##
24
+ # @private
25
+ # Pure functions for redacting and abridging sensitive data and large payloads in logs.
26
+ #
27
+ module Abridge
28
+ module_function
29
+
30
+ ##
31
+ # @private
32
+ # Formats binary payload into truncated hex representation.
33
+ #
34
+ # @param data [Object, nil] Binary or string payload
35
+ # @return [String, nil] Truncated hex representation or nil
36
+ #
37
+ def bytes data
38
+ return nil if data.nil?
39
+
40
+ str = data.to_s
41
+ if str.bytesize >= 64
42
+ "#{str.byteslice(0, 32).unpack1('H*')}... <#{str.bytesize} bytes>"
43
+ else
44
+ str.unpack1 "H*"
45
+ end
46
+ end
47
+
48
+ ##
49
+ # @private
50
+ # Truncates error body to a safe log length.
51
+ #
52
+ # @param data [Object, nil] Error body payload
53
+ # @return [String, nil] UTF-8 scrubbed and truncated string
54
+ #
55
+ def error_body data
56
+ return nil if data.nil?
57
+
58
+ data.to_s.dup.force_encoding(Encoding::UTF_8).scrub[0, 512]
59
+ end
60
+
61
+ ##
62
+ # @private
63
+ # Redacts query parameter values in URLs for safe logging.
64
+ #
65
+ # @param url [Object, nil] URL string or URI
66
+ # @return [String, nil] URL with query values elided
67
+ #
68
+ def url url
69
+ return nil if url.nil?
70
+
71
+ uri = URI.parse url.to_s
72
+ if uri.query && !uri.query.empty?
73
+ elided = uri.query.split("&").map do |pair|
74
+ key, _val = pair.split "=", 2
75
+ "#{key}=<...>"
76
+ end.join "&"
77
+ uri.query = nil
78
+ return "#{uri}?#{elided}"
79
+ end
80
+ uri.to_s
81
+ rescue URI::InvalidURIError
82
+ url.to_s
83
+ end
84
+
85
+ ##
86
+ # @private
87
+ # Redacts non-protocol headers for safe logging.
88
+ #
89
+ # @param headers [Object] Headers hash
90
+ # @return [Hash<String, String>] Redacted headers
91
+ #
92
+ def headers headers
93
+ return {} unless headers.is_a? Hash
94
+
95
+ headers.each_with_object({}) do |(k, v), acc|
96
+ key_str = k.to_s
97
+ acc[key_str] = if key_str.downcase == "x-goog-upload-url"
98
+ url v
99
+ elsif key_str.downcase.start_with? "x-goog-upload-"
100
+ v
101
+ else
102
+ "<...>"
103
+ end
104
+ end
105
+ end
106
+
107
+ ##
108
+ # @private
109
+ # Converts a list of instructions into log-safe representation hashes.
110
+ #
111
+ # @param instructions [Array<Object>] List of instructions
112
+ # @return [Array<Hash>] Log-safe instruction summaries
113
+ #
114
+ def instructions instructions
115
+ instructions.map { |i| instruction i }
116
+ end
117
+
118
+ # rubocop:disable Metrics/MethodLength
119
+ ##
120
+ # @private
121
+ # Converts an instruction into a log-safe representation hash.
122
+ #
123
+ # @param instruction [Object] Instruction object
124
+ # @return [Hash] Log-safe instruction summary
125
+ #
126
+ def instruction instruction
127
+ case instruction
128
+ when Instruction::SendStart
129
+ { "type" => "SendStart", "url" => url(instruction.url) }
130
+ when Instruction::SendChunk
131
+ {
132
+ "type" => "SendChunk",
133
+ "url" => url(instruction.url),
134
+ "offset" => instruction.offset,
135
+ "length" => instruction.length,
136
+ "finalize" => instruction.finalize
137
+ }
138
+ when Instruction::SendFinalize
139
+ { "type" => "SendFinalize", "url" => url(instruction.url) }
140
+ when Instruction::SendQuery
141
+ { "type" => "SendQuery", "url" => url(instruction.url), "backoff" => instruction.backoff }
142
+ when Instruction::SendCancel
143
+ { "type" => "SendCancel", "url" => url(instruction.url) }
144
+ when Instruction::RealignBuffer
145
+ { "type" => "RealignBuffer", "serverOffset" => instruction.server_offset }
146
+ when Instruction::FillBuffer
147
+ { "type" => "FillBuffer", "targetBytesize" => instruction.target_bytesize }
148
+ when Instruction::NotifyProgress
149
+ {
150
+ "type" => "NotifyProgress",
151
+ "phase" => instruction.progress.phase.to_s,
152
+ "bytesUploaded" => instruction.progress.bytes_uploaded,
153
+ "totalBytes" => instruction.progress.total_bytes
154
+ }
155
+ when Instruction::TerminateSuccess
156
+ { "type" => "TerminateSuccess" }
157
+ when Instruction::TerminateFailure
158
+ { "type" => "TerminateFailure", "error" => instruction.error.to_s }
159
+ else
160
+ { "type" => instruction.class.name }
161
+ end
162
+ end
163
+ # rubocop:enable Metrics/MethodLength
164
+ end
165
+ end
166
+ end
167
+ end
168
+ end
@@ -0,0 +1,190 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Copyright 2026 Google LLC
4
+ #
5
+ # Licensed under the Apache License, Version 2.0 (the "License");
6
+ # you may not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # https://www.apache.org/licenses/LICENSE-2.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing, software
12
+ # distributed under the License is distributed on an "AS IS" BASIS,
13
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ # See the License for the specific language governing permissions and
15
+ # limitations under the License.
16
+
17
+ require "faraday"
18
+ require "gapic/rest/error"
19
+ require "gapic/rest/resumable_upload/rules"
20
+
21
+ module Gapic
22
+ module Rest
23
+ module ResumableUpload
24
+ class Driver
25
+ ##
26
+ # @private
27
+ # Single owner of every retry decision for one resumable upload command.
28
+ #
29
+ # `ClientStub` is handed a never-retry policy, so each `ClientStub` call is exactly one attempt and
30
+ # every outcome comes back to the Driver. The Driver asks this object whether to send the same request
31
+ # again. It only ever chooses between re-sending and surfacing: an outcome it declines, and whatever
32
+ # is left when the budget runs out, is converted to an event unchanged and handed to {Rules}, which
33
+ # alone decides between recovery and a terminal outcome.
34
+ #
35
+ # The decision table (first match wins):
36
+ #
37
+ # | # | Outcome | start / control | data |
38
+ # |---|-----------------------------------------------------|------------------------|------------------------|
39
+ # | 1 | non-transport error (e.g. auth refresh) | ask policy | ask policy |
40
+ # | 2 | `Faraday::TimeoutError`, or no response and no kind | ask policy | surface |
41
+ # | 3 | `Faraday::ConnectionFailed`, `Faraday::SSLError` | retry within budget | surface |
42
+ # | 4 | `200` without `X-Goog-Upload-Status` | retry within budget | surface |
43
+ # | 5 | non-200 with `X-Goog-Upload-Status: final` | surface | surface |
44
+ # | 6 | any 4xx | (falls through) | surface |
45
+ # | 7 | any other non-200 | ask policy | ask policy |
46
+ # | - | any other `2xx`, or `200` with the header | surface | surface |
47
+ #
48
+ # * **Retry within budget** is the no-argument `RetryPolicy#call`: it checks the policy deadline and
49
+ # applies backoff, and hands nothing to a caller predicate.
50
+ # * **Ask policy** is `retry_with_deadline? && call(error)`: the caller's `retry_predicate`, then its
51
+ # `retry_codes`. `RetryPolicy#call(error)` does not check the deadline itself, hence the guard.
52
+ # * The data plane never re-sends after an outcome that leaves the server offset unknown (rows 2, 3, 4,
53
+ # 6); recovery owns those. A non-transport error (row 1) fails before anything reaches the wire, so
54
+ # re-sending cannot duplicate bytes.
55
+ # * Rows 5 and 6 are fixed ahead of the policy, so no caller setting can retry a rejection or make the
56
+ # data plane re-send on a 4xx.
57
+ #
58
+ # Row 2 is inert for timeouts until attempts get their own timeouts: each attempt currently receives the
59
+ # whole remaining command budget, so a timed-out attempt leaves none to retry with.
60
+ #
61
+ # See `design/resumable_upload/implementation-guide.md` section 6.1.1.
62
+ #
63
+ class RetryDecider
64
+ ##
65
+ # @private
66
+ # Classifies a raised request error by what reached the wire.
67
+ #
68
+ # Shared with {Driver#rescue_request_error}, so the kind an error is retried as and the kind it is
69
+ # surfaced as cannot drift apart.
70
+ #
71
+ # @param error [StandardError] Error raised by, or through, `ClientStub#make_post_request`
72
+ # @return [Symbol] `:status` (an HTTP response arrived), `:timeout`, `:connection_failed`,
73
+ # `:no_response` (a transport error of no more specific kind), or `:non_transport`
74
+ def self.failure_kind error
75
+ case error
76
+ when Gapic::Rest::DeadlineExceededError then :timeout
77
+ when Gapic::Rest::Error then error.status_code ? :status : :connection_failed
78
+ when Faraday::Error then faraday_failure_kind error
79
+ else :non_transport
80
+ end
81
+ end
82
+
83
+ ##
84
+ # @private
85
+ # @param error [Faraday::Error]
86
+ # @return [Symbol] See {.failure_kind}
87
+ def self.faraday_failure_kind error
88
+ if error.response.is_a?(Hash) && error.response[:status]
89
+ :status
90
+ elsif error.is_a? Faraday::TimeoutError
91
+ :timeout
92
+ elsif error.is_a?(Faraday::ConnectionFailed) || error.is_a?(Faraday::SSLError)
93
+ :connection_failed
94
+ else
95
+ :no_response
96
+ end
97
+ end
98
+
99
+ ##
100
+ # @private
101
+ # @param policy [Gapic::Common::RetryPolicy] Started policy for this command
102
+ # @param data_plane [Boolean] Whether the command transmits upload bytes (`upload`, `finalize`)
103
+ def initialize policy, data_plane:
104
+ @policy = policy
105
+ @data_plane = data_plane
106
+ end
107
+
108
+ ##
109
+ # @private
110
+ # Decides whether to send the same request again, performing the backoff delay if so.
111
+ #
112
+ # @param outcome [Object, StandardError] The response returned by `ClientStub#make_post_request`, or
113
+ # the error it raised
114
+ # @return [Boolean] `true` if the request should be sent again (the delay has been performed)
115
+ def retry? outcome
116
+ return retry_response? outcome unless outcome.is_a? Exception
117
+
118
+ error = unwrap outcome
119
+ case self.class.failure_kind error
120
+ when :status then retry_status? error
121
+ when :connection_failed then !@data_plane && @policy.call
122
+ when :timeout, :no_response then !@data_plane && ask_policy(error)
123
+ else ask_policy error
124
+ end
125
+ end
126
+
127
+ private
128
+
129
+ ##
130
+ # @private
131
+ # Under `raise_faraday_errors: false`, `ClientStub` raises a {Gapic::Rest::Error} from inside its own
132
+ # `rescue`, so `cause` is the original Faraday error. `retry_codes` can read a status only from the
133
+ # latter.
134
+ #
135
+ # @param error [StandardError]
136
+ # @return [StandardError]
137
+ def unwrap error
138
+ return error.cause if error.is_a?(Gapic::Rest::Error) && error.cause.is_a?(Faraday::Error)
139
+ error
140
+ end
141
+
142
+ ##
143
+ # @private
144
+ # Faraday raises on every `4xx`/`5xx`, so a returned response is a `1xx`-`3xx`. Only a headerless
145
+ # `200` is worth re-sending.
146
+ #
147
+ # @param response [Object] Response exposing `status` and `headers`
148
+ # @return [Boolean]
149
+ def retry_response? response
150
+ return false unless response.status == 200
151
+ return false unless upload_status(response.headers).nil?
152
+ !@data_plane && @policy.call
153
+ end
154
+
155
+ ##
156
+ # @private
157
+ # @param error [Faraday::Error, Gapic::Rest::Error] Error carrying an HTTP status
158
+ # @return [Boolean]
159
+ def retry_status? error
160
+ status, headers = if error.is_a? Gapic::Rest::Error
161
+ [error.status_code, error.headers]
162
+ else
163
+ [error.response[:status], error.response[:headers]]
164
+ end
165
+ return false if upload_status(headers) == "final"
166
+ return false if @data_plane && (400..499).cover?(status)
167
+ ask_policy error
168
+ end
169
+
170
+ ##
171
+ # @private
172
+ # @param error [StandardError]
173
+ # @return [Boolean]
174
+ def ask_policy error
175
+ @policy.retry_with_deadline? && @policy.call(error)
176
+ end
177
+
178
+ ##
179
+ # @private
180
+ # @param headers [Hash, nil]
181
+ # @return [String, nil] Downcased `X-Goog-Upload-Status`, or `nil` when missing or empty
182
+ def upload_status headers
183
+ value = Rules.header_value headers, "x-goog-upload-status"
184
+ value.nil? || value.empty? ? nil : value.downcase
185
+ end
186
+ end
187
+ end
188
+ end
189
+ end
190
+ end
@@ -0,0 +1,343 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Copyright 2026 Google LLC
4
+ #
5
+ # Licensed under the Apache License, Version 2.0 (the "License");
6
+ # you may not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # https://www.apache.org/licenses/LICENSE-2.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing, software
12
+ # distributed under the License is distributed on an "AS IS" BASIS,
13
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ # See the License for the specific language governing permissions and
15
+ # limitations under the License.
16
+
17
+ require "google/logging/message"
18
+ require "gapic/rest/resumable_upload/driver/abridge"
19
+
20
+ module Gapic
21
+ module Rest
22
+ module ResumableUpload
23
+ class Driver
24
+ ##
25
+ # @private
26
+ # Structured logging helper for a single Resumable Upload run.
27
+ #
28
+ class UploadLog
29
+ ##
30
+ # @private
31
+ # Recipes omitted from INFO lifecycle logging.
32
+ # @return [Array<Symbol>]
33
+ SILENT_RECIPES = [
34
+ :ack_chunk, # per-chunk transition, doesn't belong at INFO
35
+ :fail_with_unmatched_transition # raises before Decision exists, logged by #unmatched_transition
36
+ ].freeze
37
+
38
+ ##
39
+ # @private
40
+ # Severity and message mapping for lifecycle transitions.
41
+ # @return [Hash<Symbol, Array>]
42
+ LIFECYCLE = {
43
+ start_session: [:info, "Initiating resumable upload"],
44
+ resume_session: [:info, "Resuming upload session"],
45
+ begin_transmission: [:info, "Upload session established"],
46
+ send_chunk: [:debug, "Sending upload chunk"],
47
+ send_upload_finalize: [:info, "Sending final upload chunk"],
48
+ send_finalize: [:info, "Sending finalize command"],
49
+ enter_recovery: [:info, "Entering upload recovery"],
50
+ retry_recovery: [:info, "Retrying upload recovery query"],
51
+ realign_from_recovery: [:info, "Resuming upload from server offset"],
52
+ complete_upload_with_data: [:info, "Resumable upload completed"],
53
+ complete_upload_finalized: [:info, "Resumable upload completed"],
54
+ cancel_session: [:info, "Canceling resumable upload"],
55
+ complete_cancellation: [:info, "Resumable upload canceled"],
56
+ fail_with_cancelled: [:warn, "Resumable upload canceled on the server"],
57
+ fail_with_deadline_exceeded: [:warn, "Resumable upload failed"],
58
+ fail_with_rejected: [:warn, "Resumable upload failed"],
59
+ fail_with_bad_response: [:warn, "Resumable upload failed"],
60
+ fail_with_request_error: [:warn, "Resumable upload failed"]
61
+ }.freeze
62
+
63
+ # @private
64
+ # @return [String]
65
+ attr_reader :upload_id
66
+
67
+ ##
68
+ # @private
69
+ # Initializes a new UploadLog logger wrapper.
70
+ #
71
+ # @param stub_logger [Logger, Object] Underlying structured logger
72
+ # @param upload_id [String] Unique session identifier
73
+ #
74
+ def initialize stub_logger, upload_id:
75
+ @stub_logger = stub_logger
76
+ @upload_id = upload_id
77
+ end
78
+
79
+ ##
80
+ # @private
81
+ # Logs state machine transition decision at DEBUG level.
82
+ #
83
+ # @param decision [Decision] Decision snapshot
84
+ #
85
+ def decision decision
86
+ msg = "Rules: #{decision.from_status} + #{decision.shape} -> " \
87
+ "#{decision.recipe} -> #{decision.next_state.status}"
88
+ entry(
89
+ :debug,
90
+ msg,
91
+ fromStatus: decision.from_status,
92
+ shape: decision.shape,
93
+ recipe: decision.recipe,
94
+ toStatus: decision.next_state.status,
95
+ offset: decision.next_state.offset,
96
+ inFlightLength: decision.next_state.in_flight_length,
97
+ recoveryOffset: decision.next_state.recovery_offset,
98
+ instructions: Abridge.instructions(decision.instructions)
99
+ )
100
+ end
101
+
102
+ ##
103
+ # @private
104
+ # Logs high-level protocol lifecycle milestone if configured.
105
+ #
106
+ # @param decision [Decision] Decision snapshot
107
+ # @param config [StartUploadConfig, ResumeUploadConfig] Upload configuration
108
+ #
109
+ def lifecycle decision, config
110
+ return if SILENT_RECIPES.include? decision.recipe
111
+
112
+ severity, message = LIFECYCLE[decision.recipe]
113
+ return unless severity
114
+
115
+ extra_fields = lifecycle_fields decision, config
116
+ entry severity, message, recipe: decision.recipe, **extra_fields
117
+ end
118
+
119
+ ##
120
+ # @private
121
+ # Logs an outgoing HTTP request at DEBUG level.
122
+ #
123
+ # @param method [String] HTTP method
124
+ # @param url [String] Request URL
125
+ # @param headers [Hash] Request headers
126
+ # @param start_attempt [Integer] Attempt index for start command
127
+ # @param body_size [Integer, nil] Byte size of request payload
128
+ # @param body [Object, nil] Request payload
129
+ # @param body_is_error [Boolean] Whether body contains an error payload
130
+ #
131
+ def wire_send method:, url:, headers:, start_attempt:, body_size: nil, body: nil, body_is_error: false
132
+ command = Rules.header_value headers, "x-goog-upload-command"
133
+ offset = Rules.header_value headers, "x-goog-upload-offset"
134
+ fields = {
135
+ method: method,
136
+ url: Abridge.url(url),
137
+ headers: Abridge.headers(headers),
138
+ startAttempt: start_attempt
139
+ }
140
+ fields[:command] = command if command
141
+ fields[:offset] = offset.to_i if offset
142
+ fields[:bodySize] = body_size if body_size
143
+ fields[:body] = body_is_error ? Abridge.error_body(body) : Abridge.bytes(body) if body
144
+
145
+ entry :debug, "Sending #{method} request", **fields
146
+ end
147
+
148
+ ##
149
+ # @private
150
+ # Logs a received HTTP response at DEBUG level.
151
+ #
152
+ # @param event [Event::HttpResponse] Received HTTP event
153
+ #
154
+ def wire_receive event
155
+ upload_status = Rules.header_value event.headers, "x-goog-upload-status"
156
+ size_recv = Rules.parse_header_non_negative_integer(
157
+ Rules.header_value(event.headers, "x-goog-upload-size-received")
158
+ )
159
+ gran = Rules.parse_header_non_negative_integer(
160
+ Rules.header_value(event.headers, "x-goog-upload-chunk-granularity")
161
+ )
162
+ err = event.error if event.respond_to? :error
163
+ fields = {
164
+ status: event.status,
165
+ headers: Abridge.headers(event.headers),
166
+ body: wire_receive_body(event, err)
167
+ }
168
+ fields[:uploadStatus] = upload_status if upload_status
169
+ fields[:errorStatus] = err.status if err&.status
170
+ fields[:sizeReceived] = size_recv if size_recv
171
+ fields[:granularity] = gran if gran
172
+
173
+ entry :debug, "Received HTTP #{event.status}", **fields
174
+ end
175
+
176
+ ##
177
+ # @private
178
+ # Logs a network or transport failure at DEBUG level.
179
+ #
180
+ # @param event [Event::RequestFailed] Failure event
181
+ #
182
+ def wire_failure event
183
+ entry(
184
+ :debug,
185
+ "Request failed: #{event.kind}",
186
+ kind: event.kind,
187
+ error: event.message
188
+ )
189
+ end
190
+
191
+ ##
192
+ # @private
193
+ # Logs the backoff delay about to be performed before a query that continues a recovery episode.
194
+ #
195
+ # @param delay [Numeric] Base delay in seconds, before jitter and the `max_delay` cap
196
+ # @param attempt [Integer] 1-based index of the delay within the recovery episode
197
+ #
198
+ def recovery_backoff delay:, attempt:
199
+ entry :debug, "Performing backoff delay before recovery query", delay: delay, backoffAttempt: attempt
200
+ end
201
+
202
+ ##
203
+ # @private
204
+ # Logs buffer realignment action.
205
+ #
206
+ # @param action [String] Realignment action description
207
+ # @param server_offset [Integer] Target server offset
208
+ # @param current_offset [Integer] Current buffer start offset
209
+ # @param unseekable [Boolean] Whether rewind was attempted on an unseekable stream
210
+ #
211
+ def buffer_realign action, server_offset:, current_offset:, unseekable: false
212
+ if unseekable
213
+ entry(
214
+ :warn,
215
+ "Server offset rewind on unseekable stream",
216
+ action: action,
217
+ serverOffset: server_offset,
218
+ currentOffset: current_offset
219
+ )
220
+ end
221
+
222
+ entry(
223
+ :debug,
224
+ "Buffer realignment: #{action}",
225
+ action: action,
226
+ serverOffset: server_offset,
227
+ currentOffset: current_offset
228
+ )
229
+ end
230
+
231
+ ##
232
+ # @private
233
+ # Logs an invalid or unmatched state machine transition at WARN level.
234
+ #
235
+ # @param state [State] Current state
236
+ # @param event [Object] Triggering event
237
+ # @param error [StandardError] Resulting error
238
+ #
239
+ def unmatched_transition state, event, error
240
+ entry(
241
+ :warn,
242
+ "Unmatched transition",
243
+ status: state.status,
244
+ shape: Rules.shape_of(event, state.status),
245
+ error: error.message
246
+ )
247
+ end
248
+
249
+ private
250
+
251
+ ##
252
+ # @private
253
+ # Formats response body for wire log entry.
254
+ #
255
+ # @param event [Event::HttpResponse] Response event
256
+ # @param err [Gapic::Rest::Error, nil] Error instance
257
+ # @return [String, nil] Formatted body
258
+ #
259
+ def wire_receive_body event, err
260
+ return Abridge.bytes event.body if event.status < 400
261
+
262
+ err&.message ? Abridge.error_body(err.message) : Abridge.error_body(event.body)
263
+ end
264
+
265
+ ##
266
+ # @private
267
+ # Extracts relevant state fields for lifecycle logging.
268
+ #
269
+ # @param decision [Decision] Decision snapshot
270
+ # @param config [StartUploadConfig, ResumeUploadConfig] Upload configuration
271
+ # @return [Hash] Metadata fields for log entry
272
+ #
273
+ def lifecycle_fields decision, config
274
+ state = decision.next_state
275
+ case decision.recipe
276
+ when :start_session
277
+ { uploadSize: config.upload_size, requestedChunkSize: config.chunk_size }
278
+ when :resume_session
279
+ {
280
+ uploadUrl: Abridge.url(state.upload_url),
281
+ chunkSize: state.chunk_size
282
+ }
283
+ when :begin_transmission
284
+ {
285
+ effectiveChunkSize: state.chunk_size,
286
+ granularity: state.chunk_granularity,
287
+ uploadUrl: Abridge.url(state.upload_url)
288
+ }
289
+ when :send_chunk, :send_upload_finalize
290
+ { offset: state.offset, inFlightLength: state.in_flight_length }
291
+ when :send_finalize, :enter_recovery, :retry_recovery,
292
+ :realign_from_recovery, :complete_upload_with_data, :complete_upload_finalized
293
+ { offset: state.offset }
294
+ when :cancel_session
295
+ { uploadUrl: Abridge.url(state.upload_url) }
296
+ when :fail_with_deadline_exceeded, :fail_with_rejected, :fail_with_bad_response,
297
+ :fail_with_request_error
298
+ failure_fields state
299
+ else
300
+ {}
301
+ end
302
+ end
303
+
304
+ ##
305
+ # @private
306
+ # Extracts error and response details for failure lifecycle logs.
307
+ #
308
+ # @param state [State] Current protocol state
309
+ # @return [Hash] Failure metadata fields
310
+ #
311
+ def failure_fields state
312
+ err = state.last_error
313
+ fields = { error: err&.message || err.to_s }
314
+ if err.respond_to?(:response_body) && err.response_body
315
+ fields[:responseBody] = Abridge.error_body err.response_body
316
+ end
317
+ fields
318
+ end
319
+
320
+ ##
321
+ # @private
322
+ # Dispatches structured log entry to stub logger.
323
+ #
324
+ # @param severity [Symbol] Log severity level
325
+ # @param log_msg [String] Primary log message
326
+ # @param fields [Hash] Structured key-value fields
327
+ #
328
+ def entry severity, log_msg, **fields
329
+ @stub_logger.public_send severity do |builder|
330
+ builder.set_system_name
331
+ builder.set_service
332
+ builder.set "uploadId", @upload_id
333
+ fields.each do |k, v|
334
+ builder.set k.to_s, v
335
+ end
336
+ builder.message = log_msg
337
+ end
338
+ end
339
+ end
340
+ end
341
+ end
342
+ end
343
+ end