prauga-flexdoc 0.4.7 → 0.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.
- checksums.yaml +4 -4
- data/README.md +22 -0
- data/lib/prauga/flexdoc/host_execution.rb +80 -25
- data/lib/prauga/flexdoc/host_execution_observability.rb +211 -0
- data/lib/prauga/flexdoc/version.rb +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2cfa79303d5ae57884ca1c3fc8157eecf7852a764c04edeece1f72302cb9f881
|
|
4
|
+
data.tar.gz: e2946cba95ac973864b0f81e8b0e9a46165fa1685b2bff3b1bfa7e3b07e8bda3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: fe41dcf5a51516ac2993bd1ef08e6c698d9f9998c53c856975023c0a2000a017fa0cdbb94417eb415f5035f1b7b86dd16d7eaf279d0a7db81ec504607250db66
|
|
7
|
+
data.tar.gz: 164d984ad22590f7dafd522888de0ab3d93d46a533f355dbb2ae602383137153fe674767d7f651f760478429666eea833beebf54b5e57b9f281d3dd462642754
|
data/README.md
CHANGED
|
@@ -53,6 +53,28 @@ This first native slice supports the canonical JSON and multipart envelopes, Bas
|
|
|
53
53
|
|
|
54
54
|
The Ruby executor blocks link-local/cloud-metadata targets and validates DNS results before connecting. It then pins `Net::HTTP` to one of the validated addresses with `ipaddr=` while retaining the original hostname for the HTTP `Host` header and TLS SNI/certificate verification. Environment proxy routing is disabled for native execution, so the validated destination cannot be bypassed through `http_proxy`/`HTTP_PROXY`. Private-network relaxation is not part of this slice.
|
|
55
55
|
|
|
56
|
+
### Execution evidence
|
|
57
|
+
|
|
58
|
+
An executor that reports nothing leaves an operator guessing whether a failing Try It is a policy rejection, a slow upstream or traffic that never carried an execute marker. Pass a metric sink to emit the same metric names, labels and reason vocabulary as the Node, Python, Go and Rust hosts, so one collector reads a mixed fleet:
|
|
59
|
+
|
|
60
|
+
```ruby
|
|
61
|
+
observation = Prauga::FlexDoc::HostExecutionObservation.new
|
|
62
|
+
|
|
63
|
+
executor = Prauga::FlexDoc::HostExecution.new(
|
|
64
|
+
allowed_origins: ["https://api.example.internal"],
|
|
65
|
+
metric_sink: observation.sink
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
# Whenever an operator asks for evidence:
|
|
69
|
+
report = Prauga::FlexDoc.host_execution_observation_report(observation)
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
The sink receives `HostExecutionMetric` values carrying a name, kind, value and labels, and nothing else: no URL, header, body or credential reaches it. Bridge it to Prometheus or OpenTelemetry where such a stack exists; where none does, `HostExecutionObservation` folds the same updates into a mutex-guarded aggregate safe to share across threaded or forked-with-threads servers, and `host_execution_observation_report` produces the shared `flexdoc.host-execution.observation/1` document every other runtime also emits. Under a forking server each worker keeps its own window, so treat the export as per-process evidence.
|
|
73
|
+
|
|
74
|
+
Every non-successful execution carries one of the stable categories in `HostExecutionObservability::HOST_EXECUTION_REASONS`, which is why rejections and upstream failures are separable at all — the human-readable messages interpolate origins and field names, so they are unbounded and unusable as a metric label. Requests arriving without `X-FlexDoc-Execute` are counted by `flexdoc_execute_unmarked_total` and deliberately move no lifecycle metric, since they produced no validated envelope.
|
|
75
|
+
|
|
76
|
+
The report declares `browser-direct-transport-mix` in its gaps: a browser-direct execution never reaches this process, so the transport mix cannot be derived here. See [host-execution observability](../../docs/host-execution-observability.md) for the full metric contract and the browser half of a review. Metric delivery is best effort: a sink that raises cannot fail an execution.
|
|
77
|
+
|
|
56
78
|
## Rails
|
|
57
79
|
|
|
58
80
|
In `config/routes.rb`:
|
|
@@ -9,6 +9,8 @@ require "socket"
|
|
|
9
9
|
require "timeout"
|
|
10
10
|
require "uri"
|
|
11
11
|
|
|
12
|
+
require_relative "host_execution_observability"
|
|
13
|
+
|
|
12
14
|
module Prauga
|
|
13
15
|
module FlexDoc
|
|
14
16
|
HostExecutionFile = Data.define(:filename, :content_type, :data)
|
|
@@ -33,7 +35,8 @@ module Prauga
|
|
|
33
35
|
|
|
34
36
|
attr_reader :allowed_origins
|
|
35
37
|
|
|
36
|
-
def initialize(allowed_origins:)
|
|
38
|
+
def initialize(allowed_origins:, metric_sink: nil)
|
|
39
|
+
@metric_sink = metric_sink
|
|
37
40
|
normalized = Array(allowed_origins).filter_map do |raw|
|
|
38
41
|
value = raw.to_s.strip
|
|
39
42
|
next if value.empty?
|
|
@@ -52,38 +55,90 @@ module Prauga
|
|
|
52
55
|
@allowed_origins = normalized.uniq.freeze
|
|
53
56
|
end
|
|
54
57
|
|
|
58
|
+
attr_reader :metric_sink
|
|
59
|
+
|
|
55
60
|
def capabilities
|
|
56
61
|
[]
|
|
57
62
|
end
|
|
58
63
|
|
|
64
|
+
# Validate the marker and map executor failures to canonical JSON errors.
|
|
65
|
+
#
|
|
66
|
+
# This is the single choke point every transport reaches, so it is also where
|
|
67
|
+
# execution evidence is emitted: one started/completed pair per validated
|
|
68
|
+
# envelope, and an unmarked counter for requests that never became executions.
|
|
59
69
|
def handle(marker:, envelope:, files: {})
|
|
60
|
-
|
|
70
|
+
unless marker == "1"
|
|
71
|
+
# Counted outside the lifecycle: an unmarked request never became an
|
|
72
|
+
# execution, and folding it into rejections would double-count attempts.
|
|
73
|
+
metric("flexdoc_execute_unmarked_total", "counter", 1, { "reason" => "marker-missing" })
|
|
74
|
+
return HostExecutionResult.new(status: 403, body: { "error" => "Missing X-FlexDoc-Execute header." })
|
|
75
|
+
end
|
|
61
76
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
77
|
+
metric("flexdoc_execute_requests_total", "counter", 1)
|
|
78
|
+
metric("flexdoc_execute_in_flight", "gauge", 1)
|
|
79
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
80
|
+
begin
|
|
81
|
+
body = execute(envelope, files)
|
|
82
|
+
rescue ExecutionError => error
|
|
83
|
+
complete(error.status >= 500 ? "error" : "rejected", started, error.reason, error.status)
|
|
84
|
+
return HostExecutionResult.new(status: error.status, body: { "error" => error.message })
|
|
85
|
+
rescue StandardError
|
|
86
|
+
complete("error", started, "upstream-error", 502)
|
|
87
|
+
raise
|
|
88
|
+
end
|
|
89
|
+
complete("success", started)
|
|
90
|
+
HostExecutionResult.new(status: 200, body:)
|
|
65
91
|
end
|
|
66
92
|
|
|
67
93
|
private
|
|
68
94
|
|
|
69
95
|
class ExecutionError < StandardError
|
|
70
|
-
attr_reader :status
|
|
96
|
+
attr_reader :status, :reason
|
|
71
97
|
|
|
72
|
-
|
|
98
|
+
# Messages interpolate origins, field names and methods, so they are
|
|
99
|
+
# unbounded and cannot be aggregated. The reason can, and it matches the
|
|
100
|
+
# Node, Python, Go and Rust vocabulary exactly.
|
|
101
|
+
def initialize(status, message, reason)
|
|
73
102
|
@status = status
|
|
103
|
+
@reason = reason
|
|
74
104
|
super(message)
|
|
75
105
|
end
|
|
76
106
|
end
|
|
77
107
|
|
|
78
|
-
def bad_request(message) = raise(ExecutionError.new(400, message))
|
|
79
|
-
def
|
|
80
|
-
def
|
|
108
|
+
def bad_request(message, reason = "request-invalid") = raise(ExecutionError.new(400, message, reason))
|
|
109
|
+
def unsupported(message, reason = "auth-unsupported") = raise(ExecutionError.new(400, message, reason))
|
|
110
|
+
def forbidden(message, reason = "destination-forbidden") = raise(ExecutionError.new(403, message, reason))
|
|
111
|
+
def upstream(message, reason = "upstream-error") = raise(ExecutionError.new(502, message, reason))
|
|
112
|
+
|
|
113
|
+
def complete(outcome, started, reason = nil, status = nil)
|
|
114
|
+
metric("flexdoc_execute_in_flight", "gauge", -1)
|
|
115
|
+
metric("flexdoc_execute_completions_total", "counter", 1, { "outcome" => outcome })
|
|
116
|
+
elapsed = [Process.clock_gettime(Process::CLOCK_MONOTONIC) - started, 0.0].max
|
|
117
|
+
metric("flexdoc_execute_duration_seconds", "histogram", elapsed, { "outcome" => outcome })
|
|
118
|
+
return if reason.nil?
|
|
119
|
+
|
|
120
|
+
if outcome == "rejected"
|
|
121
|
+
metric("flexdoc_execute_rejections_total", "counter", 1,
|
|
122
|
+
{ "source" => "route", "statusCode" => (status || 400).to_s, "reason" => reason })
|
|
123
|
+
else
|
|
124
|
+
metric("flexdoc_execute_errors_total", "counter", 1, { "reason" => reason })
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def metric(name, kind, value, labels = {})
|
|
129
|
+
return if @metric_sink.nil?
|
|
130
|
+
|
|
131
|
+
@metric_sink.call(HostExecutionMetric.new(name:, kind:, value:, labels:))
|
|
132
|
+
rescue StandardError
|
|
133
|
+
# Observability must never decide whether an execution succeeds.
|
|
134
|
+
nil
|
|
135
|
+
end
|
|
81
136
|
|
|
82
137
|
def execute(envelope, files)
|
|
83
|
-
root = envelope.is_a?(Hash) ? envelope : bad_request("Host execution body must be a JSON object.")
|
|
84
|
-
|
|
138
|
+
root = envelope.is_a?(Hash) ? envelope : bad_request("Host execution body must be a JSON object.", "body-malformed")
|
|
139
|
+
unsupported("Session cookie jars are not implemented by the Ruby host executor.") if root["cookieJar"] == "session"
|
|
85
140
|
certificate_id = root["certificateId"].to_s
|
|
86
|
-
|
|
141
|
+
unsupported("Client certificates are not implemented by the Ruby host executor.") unless certificate_id.strip.empty?
|
|
87
142
|
|
|
88
143
|
draft = root["request"]
|
|
89
144
|
bad_request("Host execution body requires a canonical request draft.") unless draft.is_a?(Hash)
|
|
@@ -123,9 +178,9 @@ module Prauga
|
|
|
123
178
|
status = response[:status]
|
|
124
179
|
|
|
125
180
|
if status.between?(300, 399) && response[:location]
|
|
126
|
-
forbidden("Host execution exceeded the redirect safety limit.") if redirect_count == MAX_REDIRECTS
|
|
181
|
+
forbidden("Host execution exceeded the redirect safety limit.", "redirect-forbidden") if redirect_count == MAX_REDIRECTS
|
|
127
182
|
next_uri = request_uri.merge(response[:location])
|
|
128
|
-
forbidden("Host execution does not follow cross-origin redirects.") unless origin_of(next_uri) == origin_of(request_uri)
|
|
183
|
+
forbidden("Host execution does not follow cross-origin redirects.", "redirect-forbidden") unless origin_of(next_uri) == origin_of(request_uri)
|
|
129
184
|
assert_allowed!(next_uri)
|
|
130
185
|
if status == 303
|
|
131
186
|
method = "GET"
|
|
@@ -146,13 +201,13 @@ module Prauga
|
|
|
146
201
|
}
|
|
147
202
|
end
|
|
148
203
|
end
|
|
149
|
-
forbidden("Host execution exceeded the redirect safety limit.")
|
|
204
|
+
forbidden("Host execution exceeded the redirect safety limit.", "redirect-forbidden")
|
|
150
205
|
rescue Timeout::Error
|
|
151
|
-
upstream("Host execution request timed out after #{timeout_ms} ms.")
|
|
206
|
+
upstream("Host execution request timed out after #{timeout_ms} ms.", "upstream-timeout")
|
|
152
207
|
rescue ExecutionError
|
|
153
208
|
raise
|
|
154
209
|
rescue StandardError => error
|
|
155
|
-
upstream("Host execution request failed: #{error.message}")
|
|
210
|
+
upstream("Host execution request failed: #{error.message}", "upstream-unreachable")
|
|
156
211
|
end
|
|
157
212
|
|
|
158
213
|
def perform_request(method, uri, headers, body, timeout_ms, validated_ip)
|
|
@@ -188,7 +243,7 @@ module Prauga
|
|
|
188
243
|
end
|
|
189
244
|
response.read_body do |chunk|
|
|
190
245
|
if response_body.bytesize + chunk.bytesize > MAX_EXECUTION_RESPONSE_BYTES
|
|
191
|
-
upstream("Host execution response exceeded the 10 MiB safety limit.")
|
|
246
|
+
upstream("Host execution response exceeded the 10 MiB safety limit.", "body-too-large")
|
|
192
247
|
end
|
|
193
248
|
response_body << chunk.b
|
|
194
249
|
end
|
|
@@ -212,14 +267,14 @@ module Prauga
|
|
|
212
267
|
end
|
|
213
268
|
|
|
214
269
|
addresses = Addrinfo.getaddrinfo(host, uri.port, nil, :STREAM)
|
|
215
|
-
upstream("Host execution could not resolve target hostname.") if addresses.empty?
|
|
270
|
+
upstream("Host execution could not resolve target hostname.", "upstream-unreachable") if addresses.empty?
|
|
216
271
|
if addresses.any? { |address| metadata_ip?(address.ip_address) }
|
|
217
272
|
forbidden("Host execution blocks DNS resolutions to link-local and cloud metadata endpoints.")
|
|
218
273
|
end
|
|
219
274
|
|
|
220
275
|
addresses.first.ip_address
|
|
221
276
|
rescue SocketError
|
|
222
|
-
upstream("Host execution could not resolve target hostname.")
|
|
277
|
+
upstream("Host execution could not resolve target hostname.", "upstream-unreachable")
|
|
223
278
|
end
|
|
224
279
|
|
|
225
280
|
def parse_http_uri(raw)
|
|
@@ -301,12 +356,12 @@ module Prauga
|
|
|
301
356
|
when "query"
|
|
302
357
|
nil
|
|
303
358
|
when "cookie"
|
|
304
|
-
|
|
359
|
+
unsupported("Cookie authentication is not implemented by the Ruby host executor.")
|
|
305
360
|
else
|
|
306
361
|
bad_request("Unsupported API key location: #{location}")
|
|
307
362
|
end
|
|
308
363
|
else
|
|
309
|
-
|
|
364
|
+
unsupported("Authentication type #{string_value(auth["type"])} is not implemented by the Ruby host executor.")
|
|
310
365
|
end
|
|
311
366
|
end
|
|
312
367
|
|
|
@@ -354,7 +409,7 @@ module Prauga
|
|
|
354
409
|
when "formdata"
|
|
355
410
|
build_multipart(draft, files)
|
|
356
411
|
else
|
|
357
|
-
|
|
412
|
+
unsupported("Body mode #{mode} is not implemented by the Ruby host executor.")
|
|
358
413
|
end
|
|
359
414
|
rescue ArgumentError
|
|
360
415
|
bad_request("Binary host execution bodyBase64 is invalid.") if mode == "binary"
|
|
@@ -376,7 +431,7 @@ module Prauga
|
|
|
376
431
|
bad_request("Host execution multipart file formData[#{index}] is missing.") unless file
|
|
377
432
|
filename = non_empty(file.filename.to_s) || non_empty(string_value(entry["fileName"])) || "upload.bin"
|
|
378
433
|
content_type = non_empty(file.content_type.to_s) || non_empty(string_value(entry["contentType"])) || "application/octet-stream"
|
|
379
|
-
bad_request("Host execution multipart Content-Type is invalid.") if content_type.include?("\r") || content_type.include?("\n")
|
|
434
|
+
bad_request("Host execution multipart Content-Type is invalid.", "unsupported-media-type") if content_type.include?("\r") || content_type.include?("\n")
|
|
380
435
|
output << "--#{boundary}\r\n"
|
|
381
436
|
output << "Content-Disposition: form-data; name=\"#{quote_multipart(key)}\"; filename=\"#{quote_multipart(filename)}\"\r\n"
|
|
382
437
|
output << "Content-Type: #{content_type}\r\n\r\n"
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Prauga
|
|
6
|
+
module FlexDoc
|
|
7
|
+
# Execution evidence for the Ruby host executor.
|
|
8
|
+
#
|
|
9
|
+
# This mirrors the Node, Python, Go and Rust contract deliberately: the same
|
|
10
|
+
# reason vocabulary, the same metric names and labels, and the same export
|
|
11
|
+
# schema. An operator running a mixed fleet should read one document shape
|
|
12
|
+
# regardless of which runtime served the execute route, and a collector
|
|
13
|
+
# written for one runtime should not need a second parser for another.
|
|
14
|
+
module HostExecutionObservability
|
|
15
|
+
# Stable low-cardinality categories for a non-successful execution.
|
|
16
|
+
#
|
|
17
|
+
# Rejection messages interpolate request values such as origins, field
|
|
18
|
+
# names and methods, so they are unbounded and cannot be used as a metric
|
|
19
|
+
# label. These can.
|
|
20
|
+
HOST_EXECUTION_REASONS = %w[
|
|
21
|
+
marker-missing
|
|
22
|
+
execution-disabled
|
|
23
|
+
admission-saturated
|
|
24
|
+
destination-forbidden
|
|
25
|
+
redirect-forbidden
|
|
26
|
+
body-malformed
|
|
27
|
+
body-too-large
|
|
28
|
+
unsupported-media-type
|
|
29
|
+
request-invalid
|
|
30
|
+
auth-unsupported
|
|
31
|
+
upstream-timeout
|
|
32
|
+
upstream-unreachable
|
|
33
|
+
upstream-error
|
|
34
|
+
].freeze
|
|
35
|
+
|
|
36
|
+
OBSERVATION_SCHEMA = "flexdoc.host-execution.observation/1"
|
|
37
|
+
|
|
38
|
+
# Evidence an API host cannot observe by itself, declared rather than omitted.
|
|
39
|
+
OBSERVATION_GAPS = %w[browser-direct-transport-mix].freeze
|
|
40
|
+
|
|
41
|
+
OUTCOMES = %w[success rejected error].freeze
|
|
42
|
+
|
|
43
|
+
def self.host_execution_reason?(value)
|
|
44
|
+
HOST_EXECUTION_REASONS.include?(value)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# One dependency-free metric update that can be bridged to Prometheus or
|
|
49
|
+
# OpenTelemetry without FlexDoc owning a registry.
|
|
50
|
+
HostExecutionMetric = Data.define(:name, :kind, :value, :labels) do
|
|
51
|
+
def initialize(name:, kind:, value:, labels: {})
|
|
52
|
+
super
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Aggregates host-execution evidence for one window, free of request content.
|
|
57
|
+
#
|
|
58
|
+
# Only counters and durations are retained. No URL, header, body, credential
|
|
59
|
+
# or per-request timestamp reaches this recorder, so the aggregate cannot
|
|
60
|
+
# carry request content by construction.
|
|
61
|
+
#
|
|
62
|
+
# Durations are retained up to +duration_sample_capacity+ and then replaced by
|
|
63
|
+
# reservoir sampling, so memory stays bounded for a host that runs
|
|
64
|
+
# indefinitely while percentiles stay representative of the whole window
|
|
65
|
+
# rather than only its opening. Counts stay exact regardless.
|
|
66
|
+
class HostExecutionObservation
|
|
67
|
+
def initialize(duration_sample_capacity: 8192, clock: -> { Time.now }, random: -> { Kernel.rand })
|
|
68
|
+
@capacity = [duration_sample_capacity.to_i, 1].max
|
|
69
|
+
@clock = clock
|
|
70
|
+
@random = random
|
|
71
|
+
@mutex = Mutex.new
|
|
72
|
+
reset
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Discard all counts and start a new window.
|
|
76
|
+
def reset
|
|
77
|
+
@mutex.synchronize { clear_state }
|
|
78
|
+
nil
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Fold one metric update into the aggregate. Safe to pass as a metric sink
|
|
82
|
+
# from concurrent request handlers.
|
|
83
|
+
def record(metric)
|
|
84
|
+
@mutex.synchronize do
|
|
85
|
+
timestamp = @clock.call
|
|
86
|
+
@window_start ||= timestamp
|
|
87
|
+
@window_end = timestamp
|
|
88
|
+
|
|
89
|
+
case metric.name
|
|
90
|
+
when "flexdoc_execute_requests_total"
|
|
91
|
+
@started += 1
|
|
92
|
+
when "flexdoc_execute_in_flight"
|
|
93
|
+
@in_flight = [@in_flight + metric.value.to_i, 0].max
|
|
94
|
+
@peak_in_flight = [@peak_in_flight, @in_flight].max
|
|
95
|
+
when "flexdoc_execute_completions_total"
|
|
96
|
+
@completed += 1
|
|
97
|
+
outcome = metric.labels["outcome"] || metric.labels[:outcome]
|
|
98
|
+
@outcomes[outcome] += 1 if @outcomes.key?(outcome)
|
|
99
|
+
when "flexdoc_execute_rejections_total"
|
|
100
|
+
tally(@rejections, metric.labels["reason"] || metric.labels[:reason])
|
|
101
|
+
when "flexdoc_execute_unmarked_total"
|
|
102
|
+
# Deliberately not folded into rejections: those describe validated
|
|
103
|
+
# envelopes, and merging the two would double-count attempts.
|
|
104
|
+
@unmarked += 1
|
|
105
|
+
when "flexdoc_execute_errors_total"
|
|
106
|
+
tally(@errors, metric.labels["reason"] || metric.labels[:reason])
|
|
107
|
+
when "flexdoc_execute_duration_seconds"
|
|
108
|
+
record_duration(metric.value.to_f)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
nil
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# A callable sink usable directly as the executor's +metric_sink+.
|
|
115
|
+
def sink
|
|
116
|
+
method(:record)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Current aggregate; safe to call at any time.
|
|
120
|
+
def snapshot
|
|
121
|
+
@mutex.synchronize do
|
|
122
|
+
ordered = @durations.sort
|
|
123
|
+
{
|
|
124
|
+
"windowStart" => @window_start&.utc&.iso8601(3),
|
|
125
|
+
"windowEnd" => @window_end&.utc&.iso8601(3),
|
|
126
|
+
"startedExecutions" => @started,
|
|
127
|
+
"unmarkedRequests" => @unmarked,
|
|
128
|
+
"completedExecutions" => @completed,
|
|
129
|
+
"outcomes" => @outcomes.dup,
|
|
130
|
+
"inFlight" => @in_flight,
|
|
131
|
+
"peakInFlight" => @peak_in_flight,
|
|
132
|
+
"rejectionsByReason" => @rejections.dup,
|
|
133
|
+
"errorsByReason" => @errors.dup,
|
|
134
|
+
"durations" => duration_summary(ordered)
|
|
135
|
+
}
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
private
|
|
140
|
+
|
|
141
|
+
def clear_state
|
|
142
|
+
@window_start = nil
|
|
143
|
+
@window_end = nil
|
|
144
|
+
@started = 0
|
|
145
|
+
@unmarked = 0
|
|
146
|
+
@completed = 0
|
|
147
|
+
@in_flight = 0
|
|
148
|
+
@peak_in_flight = 0
|
|
149
|
+
@observed_durations = 0
|
|
150
|
+
@outcomes = HostExecutionObservability::OUTCOMES.to_h { |outcome| [outcome, 0] }
|
|
151
|
+
@rejections = {}
|
|
152
|
+
@errors = {}
|
|
153
|
+
@durations = []
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def tally(counts, reason)
|
|
157
|
+
return unless HostExecutionObservability.host_execution_reason?(reason)
|
|
158
|
+
|
|
159
|
+
counts[reason] = counts.fetch(reason, 0) + 1
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def record_duration(seconds)
|
|
163
|
+
milliseconds = [seconds * 1000, 0.0].max
|
|
164
|
+
@observed_durations += 1
|
|
165
|
+
if @durations.length < @capacity
|
|
166
|
+
@durations << milliseconds
|
|
167
|
+
return
|
|
168
|
+
end
|
|
169
|
+
candidate = (@random.call * @observed_durations).to_i
|
|
170
|
+
@durations[candidate] = milliseconds if candidate < @capacity
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def duration_summary(ordered)
|
|
174
|
+
return nil if ordered.empty?
|
|
175
|
+
|
|
176
|
+
{
|
|
177
|
+
"sampleCount" => ordered.length,
|
|
178
|
+
"sampled" => @observed_durations > ordered.length,
|
|
179
|
+
"minMs" => ordered.first,
|
|
180
|
+
"p50Ms" => percentile(ordered, 0.5),
|
|
181
|
+
"p95Ms" => percentile(ordered, 0.95),
|
|
182
|
+
"p99Ms" => percentile(ordered, 0.99),
|
|
183
|
+
"maxMs" => ordered.last
|
|
184
|
+
}
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def percentile(ordered, fraction)
|
|
188
|
+
return ordered.first if ordered.length == 1
|
|
189
|
+
|
|
190
|
+
rank = (fraction * ordered.length).ceil.clamp(1, ordered.length)
|
|
191
|
+
ordered[rank - 1]
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Build the operator export document for a host-execution observation.
|
|
196
|
+
#
|
|
197
|
+
# The document is aggregate-only and is meant to be written to disk or handed
|
|
198
|
+
# to an operator; FlexDoc never transmits it. Browser-direct executions never
|
|
199
|
+
# reach an API host, so the transport mix cannot be derived here, and that gap
|
|
200
|
+
# is declared so a review cannot mistake this document for complete evidence.
|
|
201
|
+
def self.host_execution_observation_report(observation, generated_at: nil)
|
|
202
|
+
{
|
|
203
|
+
"schema" => HostExecutionObservability::OBSERVATION_SCHEMA,
|
|
204
|
+
"generatedAt" => generated_at || Time.now.utc.iso8601(3),
|
|
205
|
+
"runtime" => "ruby",
|
|
206
|
+
"observation" => observation.snapshot,
|
|
207
|
+
"gaps" => HostExecutionObservability::OBSERVATION_GAPS.dup
|
|
208
|
+
}
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: prauga-flexdoc
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.5.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Prauga
|
|
@@ -113,6 +113,7 @@ files:
|
|
|
113
113
|
- lib/prauga/flexdoc/config.rb
|
|
114
114
|
- lib/prauga/flexdoc/host.rb
|
|
115
115
|
- lib/prauga/flexdoc/host_execution.rb
|
|
116
|
+
- lib/prauga/flexdoc/host_execution_observability.rb
|
|
116
117
|
- lib/prauga/flexdoc/rack_app.rb
|
|
117
118
|
- lib/prauga/flexdoc/rails.rb
|
|
118
119
|
- lib/prauga/flexdoc/response.rb
|