little_ghost 0.1.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 +7 -0
- data/LICENSE.txt +22 -0
- data/README.md +122 -0
- data/docs/guides/Core Concepts.md +203 -0
- data/docs/guides/Getting Started.md +187 -0
- data/lib/little_ghost/ag_ui/adapter.rb +194 -0
- data/lib/little_ghost/ag_ui.rb +5 -0
- data/lib/little_ghost/agent/context_management.rb +285 -0
- data/lib/little_ghost/agent/delegation.rb +128 -0
- data/lib/little_ghost/agent/skills.rb +96 -0
- data/lib/little_ghost/agent/tool_loop.rb +239 -0
- data/lib/little_ghost/agent.rb +2111 -0
- data/lib/little_ghost/agent_builder.rb +191 -0
- data/lib/little_ghost/agent_interruptions.rb +197 -0
- data/lib/little_ghost/configuration.rb +337 -0
- data/lib/little_ghost/content.rb +324 -0
- data/lib/little_ghost/default_model_registry.rb +71 -0
- data/lib/little_ghost/errors.rb +48 -0
- data/lib/little_ghost/events.rb +264 -0
- data/lib/little_ghost/execution_state.rb +58 -0
- data/lib/little_ghost/instrumentation.rb +475 -0
- data/lib/little_ghost/invocation.rb +285 -0
- data/lib/little_ghost/lookup.rb +37 -0
- data/lib/little_ghost/mcp/client.rb +396 -0
- data/lib/little_ghost/mcp.rb +5 -0
- data/lib/little_ghost/message.rb +75 -0
- data/lib/little_ghost/model.rb +88 -0
- data/lib/little_ghost/model_capabilities.rb +126 -0
- data/lib/little_ghost/model_registry.rb +173 -0
- data/lib/little_ghost/model_request.rb +107 -0
- data/lib/little_ghost/model_response.rb +48 -0
- data/lib/little_ghost/path_set.rb +32 -0
- data/lib/little_ghost/prompt_resolver.rb +251 -0
- data/lib/little_ghost/providers/bedrock.rb +506 -0
- data/lib/little_ghost/providers/http_transport.rb +149 -0
- data/lib/little_ghost/providers/open_router.rb +171 -0
- data/lib/little_ghost/providers/openai.rb +27 -0
- data/lib/little_ghost/providers/openai_compatible.rb +745 -0
- data/lib/little_ghost/providers/sse_parser.rb +35 -0
- data/lib/little_ghost/run.rb +607 -0
- data/lib/little_ghost/run_context.rb +129 -0
- data/lib/little_ghost/run_result.rb +111 -0
- data/lib/little_ghost/runtime/hook.rb +31 -0
- data/lib/little_ghost/runtime.rb +392 -0
- data/lib/little_ghost/sandbox.rb +138 -0
- data/lib/little_ghost/session.rb +229 -0
- data/lib/little_ghost/session_store.rb +96 -0
- data/lib/little_ghost/session_stores/agent_core_memory.rb +1086 -0
- data/lib/little_ghost/session_stores/memory.rb +86 -0
- data/lib/little_ghost/skills/catalog.rb +283 -0
- data/lib/little_ghost/skills/skill.rb +60 -0
- data/lib/little_ghost/skills.rb +4 -0
- data/lib/little_ghost/stream_event.rb +49 -0
- data/lib/little_ghost/structured_output.rb +126 -0
- data/lib/little_ghost/subagents/agent_path.rb +63 -0
- data/lib/little_ghost/subagents/definition.rb +42 -0
- data/lib/little_ghost/subagents/manager.rb +1615 -0
- data/lib/little_ghost/support/callbacks.rb +151 -0
- data/lib/little_ghost/support/cancellation_token.rb +86 -0
- data/lib/little_ghost/support/class_attributes.rb +40 -0
- data/lib/little_ghost/support/content_capture.rb +150 -0
- data/lib/little_ghost/support/executor.rb +75 -0
- data/lib/little_ghost/support/interruptible_stream.rb +103 -0
- data/lib/little_ghost/support/loader.rb +263 -0
- data/lib/little_ghost/support/output_truncation.rb +71 -0
- data/lib/little_ghost/support/redactor.rb +66 -0
- data/lib/little_ghost/support.rb +34 -0
- data/lib/little_ghost/tool.rb +448 -0
- data/lib/little_ghost/tool_execution.rb +59 -0
- data/lib/little_ghost/tool_registry.rb +156 -0
- data/lib/little_ghost/tools/filesystem.rb +119 -0
- data/lib/little_ghost/tools/shell.rb +45 -0
- data/lib/little_ghost/tools/write_todos.rb +91 -0
- data/lib/little_ghost/tools.rb +6 -0
- data/lib/little_ghost/tracing/open_telemetry.rb +517 -0
- data/lib/little_ghost/unrestricted_sandbox.rb +306 -0
- data/lib/little_ghost/usage.rb +47 -0
- data/lib/little_ghost/version.rb +6 -0
- data/lib/little_ghost/workflow.rb +351 -0
- data/lib/little_ghost/workspace.rb +31 -0
- data/lib/little_ghost.rb +120 -0
- metadata +225 -0
|
@@ -0,0 +1,475 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module LittleGhost
|
|
6
|
+
# Instrumentation turns agent work into structured lifecycle notifications.
|
|
7
|
+
# Applications can measure agents, models, tools, workflows, and sessions with
|
|
8
|
+
# the telemetry backend they already use.
|
|
9
|
+
#
|
|
10
|
+
# class TimingSubscriber < LittleGhost::Instrumentation::Subscriber
|
|
11
|
+
# def finish(name, attributes)
|
|
12
|
+
# puts "#{name}: #{attributes.fetch(:duration_ms)}ms"
|
|
13
|
+
# end
|
|
14
|
+
# end
|
|
15
|
+
#
|
|
16
|
+
# LittleGhost.configure do |config|
|
|
17
|
+
# config.instrument TimingSubscriber.new
|
|
18
|
+
# end
|
|
19
|
+
#
|
|
20
|
+
# A subscriber receives structured attributes when an operation starts,
|
|
21
|
+
# finishes, or emits a point-in-time event. Subscriber failures are reported
|
|
22
|
+
# once and kept separate from agent execution.
|
|
23
|
+
#
|
|
24
|
+
# === Content and trust
|
|
25
|
+
#
|
|
26
|
+
# Diagnostic content is excluded unless the application installs an explicit
|
|
27
|
+
# Support::ContentCapture policy. One process is one telemetry and content
|
|
28
|
+
# policy boundary; applications that need different exporters or data policies
|
|
29
|
+
# should use separate processes.
|
|
30
|
+
module Instrumentation
|
|
31
|
+
# Subclass Subscriber to connect a telemetry backend.
|
|
32
|
+
#
|
|
33
|
+
# Override the callbacks a backend supports. +start+ and +finish+ receive the
|
|
34
|
+
# same operation name and correlated attributes. +emit+ receives point
|
|
35
|
+
# events. Implementations may return a propagation carrier from
|
|
36
|
+
# #trace_context. Callbacks should be thread-safe.
|
|
37
|
+
class Subscriber
|
|
38
|
+
# Called when a lifecycle operation starts.
|
|
39
|
+
def start(_name, _attributes) = nil
|
|
40
|
+
# Called when a lifecycle operation finishes.
|
|
41
|
+
def finish(_name, _attributes) = nil
|
|
42
|
+
# Called for a point-in-time instrumentation event.
|
|
43
|
+
def emit(_name, _attributes) = nil
|
|
44
|
+
# Flushes buffered telemetry within an optional timeout budget.
|
|
45
|
+
def flush(timeout: nil) = nil
|
|
46
|
+
# Releases subscriber resources within an optional timeout budget.
|
|
47
|
+
def shutdown(timeout: nil) = nil
|
|
48
|
+
# Supplies trace fields that should travel with downstream work.
|
|
49
|
+
def trace_context(**) = {}
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# A Handle represents work between Instrumentation.start and #finish.
|
|
53
|
+
#
|
|
54
|
+
# Non-detached handles are fiber-owned and must finish in LIFO order after
|
|
55
|
+
# their children. Detached handles may finish outside the creating fiber but
|
|
56
|
+
# still cannot finish while local children remain active.
|
|
57
|
+
class Handle
|
|
58
|
+
# Operation identity, inherited attributes, previous local handle, and
|
|
59
|
+
# monotonic start time.
|
|
60
|
+
attr_reader :name, :operation_id, :parent_operation_id, :payload, :previous, :started_at
|
|
61
|
+
|
|
62
|
+
def initialize(bus, name, operation_id:, parent_operation_id:, local_parent:, previous:, payload:, started_at:, detached:) # :nodoc:
|
|
63
|
+
@bus = bus
|
|
64
|
+
@name = name.to_sym
|
|
65
|
+
@operation_id = operation_id
|
|
66
|
+
@parent_operation_id = parent_operation_id
|
|
67
|
+
@local_parent = local_parent
|
|
68
|
+
@previous = previous
|
|
69
|
+
@payload = payload
|
|
70
|
+
@started_at = started_at
|
|
71
|
+
@detached = detached
|
|
72
|
+
@owner = Fiber.current
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Completes this operation with additional attributes.
|
|
76
|
+
def finish(**attributes)
|
|
77
|
+
@bus.finish(self, **attributes)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Indicates whether this handle belongs to the current fiber.
|
|
81
|
+
def owner? = @owner.equal?(Fiber.current)
|
|
82
|
+
# Indicates whether this handle is outside the fiber-local operation stack.
|
|
83
|
+
def detached? = @detached
|
|
84
|
+
# Indicates whether the parent is another active handle on this bus.
|
|
85
|
+
def local_parent? = @local_parent
|
|
86
|
+
# Indicates whether this handle is still active.
|
|
87
|
+
def active? = @bus.active?(self)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Thread-safe notification bus used by the process-wide Instrumentation API.
|
|
91
|
+
class Bus
|
|
92
|
+
# Starts an independent bus with ordered subscribers and a content policy.
|
|
93
|
+
def initialize(subscribers: [], content_capture: Support::ContentCapture.disabled)
|
|
94
|
+
@subscribers = []
|
|
95
|
+
@content_capture = content_capture
|
|
96
|
+
@handles = {}
|
|
97
|
+
@children = Hash.new(0)
|
|
98
|
+
@finishing = {}
|
|
99
|
+
@mutex = Mutex.new
|
|
100
|
+
@reported_failures = {}
|
|
101
|
+
@shutdown = false
|
|
102
|
+
Array(subscribers).each { |subscriber| subscribe(subscriber) }
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Subscribes a backend once. +prepend+ controls notification order.
|
|
106
|
+
def subscribe(subscriber, prepend: false)
|
|
107
|
+
unless subscriber.is_a?(Subscriber)
|
|
108
|
+
raise ArgumentError, "instrumentation subscriber must be a LittleGhost::Instrumentation::Subscriber"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
@mutex.synchronize do
|
|
112
|
+
@subscribers.reject! { |listener| listener.equal?(subscriber) }
|
|
113
|
+
prepend ? @subscribers.unshift(subscriber) : @subscribers << subscriber
|
|
114
|
+
end
|
|
115
|
+
subscriber
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Unsubscribes a backend by identity.
|
|
119
|
+
def unsubscribe(subscriber)
|
|
120
|
+
@mutex.synchronize { @subscribers.reject! { |listener| listener.equal?(subscriber) } }
|
|
121
|
+
subscriber
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Selects the diagnostic content policy used for future notifications.
|
|
125
|
+
def capture_content(policy)
|
|
126
|
+
raise ArgumentError, "content capture policy must respond to capture" unless policy.respond_to?(:capture)
|
|
127
|
+
|
|
128
|
+
@mutex.synchronize { @content_capture = policy }
|
|
129
|
+
policy
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Publishes a point event with the current context and operation ID.
|
|
133
|
+
def publish(name, diagnostic: nil, **attributes)
|
|
134
|
+
current_handle = current
|
|
135
|
+
values = context.merge(attributes)
|
|
136
|
+
values[:operation_id] ||= current_handle&.operation_id
|
|
137
|
+
values = prepare_attributes(values.compact, diagnostic:)
|
|
138
|
+
notify(:emit, name.to_sym, values)
|
|
139
|
+
values
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Starts an operation. +parent+ may be a local Handle, a remote operation
|
|
143
|
+
# ID, or nil. Set +detached+ for work that will not finish in stack order.
|
|
144
|
+
def start(name, parent: current, operation_id: SecureRandom.uuid, detached: false, diagnostic: nil, **attributes)
|
|
145
|
+
previous = current unless detached
|
|
146
|
+
validate_parent!(parent)
|
|
147
|
+
parent_operation_id = parent.is_a?(Handle) ? parent.operation_id : parent
|
|
148
|
+
payload = context.merge(attributes).merge(operation_id:, parent_operation_id:).compact
|
|
149
|
+
payload = prepare_attributes(payload, diagnostic:)
|
|
150
|
+
handle = Handle.new(
|
|
151
|
+
self,
|
|
152
|
+
name,
|
|
153
|
+
operation_id:,
|
|
154
|
+
parent_operation_id:,
|
|
155
|
+
local_parent: parent.is_a?(Handle),
|
|
156
|
+
previous:,
|
|
157
|
+
payload:,
|
|
158
|
+
started_at: monotonic_time,
|
|
159
|
+
detached:
|
|
160
|
+
)
|
|
161
|
+
@mutex.synchronize do
|
|
162
|
+
raise Error, "instrumentation is shut down" if @shutdown
|
|
163
|
+
raise ArgumentError, "instrumentation operation is already active" if @handles.key?(operation_id)
|
|
164
|
+
if parent.is_a?(Handle)
|
|
165
|
+
raise Error, "instrumentation parent is not active" unless @handles[parent.operation_id].equal?(parent)
|
|
166
|
+
raise Error, "instrumentation parent is finishing" if @finishing.key?(parent.operation_id)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
@handles[operation_id] = handle
|
|
170
|
+
@children[parent_operation_id] += 1 if parent.is_a?(Handle)
|
|
171
|
+
end
|
|
172
|
+
set_current(handle) unless detached
|
|
173
|
+
notify(:start, handle.name, handle.payload)
|
|
174
|
+
handle
|
|
175
|
+
rescue
|
|
176
|
+
abandon(handle) if handle
|
|
177
|
+
raise
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Finishes an active handle and returns the final attribute hash.
|
|
181
|
+
def finish(handle, diagnostic: nil, **attributes)
|
|
182
|
+
validate_finish!(handle)
|
|
183
|
+
validated = true
|
|
184
|
+
values = handle.payload.merge(attributes).merge(duration_ms: elapsed_ms(handle.started_at))
|
|
185
|
+
values = prepare_attributes(values.compact, diagnostic:)
|
|
186
|
+
notify(:finish, handle.name, values)
|
|
187
|
+
values
|
|
188
|
+
ensure
|
|
189
|
+
complete(handle) if validated && active_handle?(handle)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Measures a block and records raised errors before re-raising them.
|
|
193
|
+
def instrument(name, payload = {})
|
|
194
|
+
values = payload.dup
|
|
195
|
+
handle = start(name, **values)
|
|
196
|
+
result = yield values if block_given?
|
|
197
|
+
handle.finish(**values)
|
|
198
|
+
result
|
|
199
|
+
rescue => error
|
|
200
|
+
values ||= payload.dup
|
|
201
|
+
values[:outcome] ||= :error
|
|
202
|
+
values[:error_type] ||= error.class.name
|
|
203
|
+
values[:diagnostic] ||= {exception: diagnostic_exception(error)}
|
|
204
|
+
handle.finish(**values) if handle && active_handle?(handle)
|
|
205
|
+
raise
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# Adds copied attributes to notifications emitted while the block runs.
|
|
209
|
+
def with_context(attributes)
|
|
210
|
+
values = deep_copy(context).merge(deep_copy(attributes.compact))
|
|
211
|
+
ExecutionState.with(context_key => values) { yield }
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# Copies the attributes active in the current execution.
|
|
215
|
+
def context
|
|
216
|
+
deep_copy(ExecutionState[context_key] || {})
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# Finds the current non-detached Handle for this fiber, if any.
|
|
220
|
+
def current
|
|
221
|
+
ExecutionState[current_key]
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# With a handle, tests whether that exact handle is active. Without one,
|
|
225
|
+
# reports whether the bus owns any active operations.
|
|
226
|
+
def active?(handle = nil)
|
|
227
|
+
@mutex.synchronize do
|
|
228
|
+
handle ? @handles[handle.operation_id].equal?(handle) : !@handles.empty?
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# Flushes subscribers in registration order within an optional total
|
|
233
|
+
# timeout budget.
|
|
234
|
+
def flush(timeout: nil)
|
|
235
|
+
deadline = monotonic_time + Float(timeout) if timeout
|
|
236
|
+
subscribers.each do |subscriber|
|
|
237
|
+
remaining = deadline && [deadline - monotonic_time, 0].max
|
|
238
|
+
notify_subscriber(subscriber, :flush, timeout: remaining)
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# Permanently shuts down this bus after all operations have finished.
|
|
243
|
+
def shutdown(timeout: nil)
|
|
244
|
+
should_shutdown = @mutex.synchronize do
|
|
245
|
+
return if @shutdown
|
|
246
|
+
raise Error, "cannot shut down instrumentation with active operations" unless @handles.empty?
|
|
247
|
+
|
|
248
|
+
@shutdown = true
|
|
249
|
+
end
|
|
250
|
+
return unless should_shutdown
|
|
251
|
+
|
|
252
|
+
deadline = monotonic_time + Float(timeout) if timeout
|
|
253
|
+
subscribers.reverse_each do |subscriber|
|
|
254
|
+
remaining = deadline && [deadline - monotonic_time, 0].max
|
|
255
|
+
notify_subscriber(subscriber, :shutdown, timeout: remaining)
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# Uses the first non-empty downstream trace context supplied by a subscriber.
|
|
260
|
+
def trace_context(**attributes)
|
|
261
|
+
subscribers.each do |subscriber|
|
|
262
|
+
value = subscriber.trace_context(**attributes)
|
|
263
|
+
return value unless value.nil? || value.empty?
|
|
264
|
+
rescue => error
|
|
265
|
+
warn_failure(error, component: :subscriber)
|
|
266
|
+
end
|
|
267
|
+
{}
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
private
|
|
271
|
+
|
|
272
|
+
def validate_parent!(parent)
|
|
273
|
+
return unless parent
|
|
274
|
+
unless parent.is_a?(Handle) || parent.is_a?(String)
|
|
275
|
+
raise ArgumentError, "instrumentation parent must be a handle or remote operation ID"
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def validate_finish!(handle)
|
|
280
|
+
raise ArgumentError, "instrumentation handle is required" unless handle.is_a?(Handle)
|
|
281
|
+
unless handle.detached?
|
|
282
|
+
raise Error, "instrumentation handle belongs to another fiber" unless handle.owner?
|
|
283
|
+
raise Error, "instrumentation operations must finish in nesting order" unless current.equal?(handle)
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
@mutex.synchronize do
|
|
287
|
+
raise Error, "instrumentation operation is not active" unless @handles[handle.operation_id].equal?(handle)
|
|
288
|
+
raise Error, "instrumentation operation is already finishing" if @finishing.key?(handle.operation_id)
|
|
289
|
+
raise Error, "instrumentation operation has active children" unless @children[handle.operation_id].zero?
|
|
290
|
+
|
|
291
|
+
@finishing[handle.operation_id] = true
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def active_handle?(handle)
|
|
296
|
+
active?(handle)
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def complete(handle)
|
|
300
|
+
@mutex.synchronize do
|
|
301
|
+
@handles.delete(handle.operation_id)
|
|
302
|
+
@children.delete(handle.operation_id)
|
|
303
|
+
@finishing.delete(handle.operation_id)
|
|
304
|
+
if handle.local_parent?
|
|
305
|
+
@children[handle.parent_operation_id] -= 1
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
set_current(handle.previous) if current.equal?(handle)
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
def abandon(handle)
|
|
312
|
+
complete(handle) if active_handle?(handle)
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
def set_current(handle)
|
|
316
|
+
if handle
|
|
317
|
+
ExecutionState[current_key] = handle
|
|
318
|
+
else
|
|
319
|
+
ExecutionState.delete(current_key)
|
|
320
|
+
end
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
def prepare_attributes(attributes, diagnostic:)
|
|
324
|
+
return attributes unless diagnostic
|
|
325
|
+
|
|
326
|
+
policy = @mutex.synchronize { @content_capture }
|
|
327
|
+
attributes.merge(policy.capture(diagnostic))
|
|
328
|
+
rescue => error
|
|
329
|
+
warn_failure(error, component: :content_capture)
|
|
330
|
+
attributes
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
def notify(method, name, attributes)
|
|
334
|
+
event_subscribers.each do |subscriber|
|
|
335
|
+
notify_subscriber(subscriber, method, name, deep_copy(attributes))
|
|
336
|
+
end
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
def notify_subscriber(subscriber, method, ...)
|
|
340
|
+
subscriber.public_send(method, ...)
|
|
341
|
+
rescue => error
|
|
342
|
+
warn_failure(error, component: :subscriber)
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
def subscribers
|
|
346
|
+
@mutex.synchronize { @subscribers.dup }
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
def event_subscribers
|
|
350
|
+
scoped = ExecutionState[:instrumentation_subscribers] || []
|
|
351
|
+
prepended, appended = scoped.partition { |entry| entry.fetch(:prepend) }
|
|
352
|
+
prepended.map { |entry| entry.fetch(:subscriber) } + subscribers +
|
|
353
|
+
appended.map { |entry| entry.fetch(:subscriber) }
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
def deep_copy(value)
|
|
357
|
+
case value
|
|
358
|
+
when Hash
|
|
359
|
+
value.to_h { |key, item| [key, deep_copy(item)] }
|
|
360
|
+
when Array
|
|
361
|
+
value.map { |item| deep_copy(item) }
|
|
362
|
+
when String
|
|
363
|
+
value.dup
|
|
364
|
+
else
|
|
365
|
+
value
|
|
366
|
+
end
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
def context_key = :little_ghost_instrumentation_context
|
|
370
|
+
def current_key = :little_ghost_instrumentation_current
|
|
371
|
+
|
|
372
|
+
def monotonic_time
|
|
373
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
def elapsed_ms(started_at)
|
|
377
|
+
((monotonic_time - started_at) * 1_000).round(3)
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
def diagnostic_exception(error)
|
|
381
|
+
{
|
|
382
|
+
type: error.class.name,
|
|
383
|
+
message: error.message,
|
|
384
|
+
stacktrace: Array(error.backtrace).join("\n")
|
|
385
|
+
}
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
def warn_failure(error, component:)
|
|
389
|
+
return if ExecutionState[:little_ghost_instrumentation_failure_event]
|
|
390
|
+
|
|
391
|
+
failure = [component, error.class.name]
|
|
392
|
+
report = @mutex.synchronize do
|
|
393
|
+
next false if @reported_failures.key?(failure)
|
|
394
|
+
|
|
395
|
+
@reported_failures[failure] = true
|
|
396
|
+
end
|
|
397
|
+
return unless report
|
|
398
|
+
|
|
399
|
+
ExecutionState.with(little_ghost_instrumentation_failure_event: true) do
|
|
400
|
+
Events.warn(
|
|
401
|
+
"little_ghost.instrumentation.listener_failed",
|
|
402
|
+
component:,
|
|
403
|
+
error_type: error.class.name
|
|
404
|
+
)
|
|
405
|
+
end
|
|
406
|
+
rescue
|
|
407
|
+
nil
|
|
408
|
+
end
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
class << self
|
|
412
|
+
# Accesses the process-wide Bus.
|
|
413
|
+
def notifier = bus
|
|
414
|
+
|
|
415
|
+
# Replaces the process-wide bus when it has no active operations.
|
|
416
|
+
def notifier=(value)
|
|
417
|
+
raise ArgumentError, "notifier must be an instrumentation bus" unless value.is_a?(Bus)
|
|
418
|
+
|
|
419
|
+
notifier_mutex.synchronize do
|
|
420
|
+
if @bus && !@bus.equal?(value) && @bus.active?
|
|
421
|
+
raise Error, "cannot replace instrumentation notifier with active operations"
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
@bus = value
|
|
425
|
+
end
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
# Subscribes a process-wide backend.
|
|
429
|
+
def subscribe(...) = bus.subscribe(...)
|
|
430
|
+
# Unsubscribes a process-wide backend.
|
|
431
|
+
def unsubscribe(...) = bus.unsubscribe(...)
|
|
432
|
+
# Installs the process-wide diagnostic content policy.
|
|
433
|
+
def capture_content(...) = bus.capture_content(...)
|
|
434
|
+
# Publishes a point event on the process-wide bus.
|
|
435
|
+
def publish(...) = bus.publish(...)
|
|
436
|
+
# Starts a lifecycle operation on the process-wide bus.
|
|
437
|
+
def start(...) = bus.start(...)
|
|
438
|
+
# Wraps a block in a lifecycle operation.
|
|
439
|
+
def instrument(...) = bus.instrument(...)
|
|
440
|
+
# Returns the current fiber's active Handle.
|
|
441
|
+
def current = bus.current
|
|
442
|
+
# Adds attributes while the block runs.
|
|
443
|
+
def with_context(attributes, &block) = bus.with_context(attributes, &block)
|
|
444
|
+
# Copies the current instrumentation context.
|
|
445
|
+
def context = bus.context
|
|
446
|
+
# Flushes process-wide subscribers.
|
|
447
|
+
def flush(...) = bus.flush(...)
|
|
448
|
+
# Shuts down the process-wide bus.
|
|
449
|
+
def shutdown(...) = bus.shutdown(...)
|
|
450
|
+
# Gets downstream trace fields from process-wide subscribers.
|
|
451
|
+
def trace_context(...) = bus.trace_context(...)
|
|
452
|
+
|
|
453
|
+
# Temporarily subscribes a backend for the block's execution state.
|
|
454
|
+
def subscribed(subscriber, prepend: false)
|
|
455
|
+
unless subscriber.is_a?(Subscriber)
|
|
456
|
+
raise ArgumentError, "instrumentation subscriber must be a LittleGhost::Instrumentation::Subscriber"
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
subscribers = ExecutionState[:instrumentation_subscribers] || []
|
|
460
|
+
entry = {subscriber:, prepend:}
|
|
461
|
+
ExecutionState.with(instrumentation_subscribers: subscribers + [entry]) { yield }
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
private
|
|
465
|
+
|
|
466
|
+
def bus
|
|
467
|
+
@bus ||= Bus.new
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
def notifier_mutex
|
|
471
|
+
@notifier_mutex ||= Mutex.new
|
|
472
|
+
end
|
|
473
|
+
end
|
|
474
|
+
end
|
|
475
|
+
end
|