daytona 0.196.0 → 0.197.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 95de6205d02f5efc95082860ba7b1cc47d7aaa4a80add3b2799d7b62ece1d67c
4
- data.tar.gz: 11c927bf961766c0eae71d6196a538688844913d06de0746497510b09137293e
3
+ metadata.gz: e838c81b167b038d7935b94479648df5ebecb486e34465986baaeed2da7a0a5c
4
+ data.tar.gz: 1dce18d22149936bf16af4beda9dcf52bb1de63a0b358c7e965f2dc663bd5933
5
5
  SHA512:
6
- metadata.gz: 6f30debf54313e1bd30c6392b7ec2fd6c316306aad88eb3e1cd58fa0a51326f4036468c76fb2fe9388c41139aa40f0efecaa680d31eab6c16736f2f9dd9c250c
7
- data.tar.gz: cf1ac1f0075f85966d2c9a00eb3c02d3c0e7080c8d7773fe9e5004c5e91bfd5e11670da7946ad15d5fcea13db6772d6c5918935d02f1f4a0cef61e010ae2f695
6
+ metadata.gz: e55a677df194a0dd60144888407f1186a03b26de14d9c3b4faf6d8611859c5d7ad059b73a1a3ce9cb41f22300c69b029139659817a27a7622e351073e00cb432
7
+ data.tar.gz: e62f6b6366d05fa9bd5343752064c1e50cdd4e0a93e101d7ec09791773e81feda4df1a856646c9961b09bdef2d96b2b7c7320afdca1e1f5189434f83556036a4
@@ -28,6 +28,11 @@ module Daytona
28
28
  # @return [Integer, nil] Auto-stop interval in minutes
29
29
  attr_accessor :auto_stop_interval
30
30
 
31
+ # @return [Integer, nil] Auto-pause interval in minutes (nil = server default: 60 for
32
+ # non-ephemeral pause-supporting sandbox classes with auto-stop disabled; 0 = disabled).
33
+ # Not allowed for ephemeral sandboxes
34
+ attr_accessor :auto_pause_interval
35
+
31
36
  # @return [Integer, nil] Auto-archive interval in minutes
32
37
  attr_accessor :auto_archive_interval
33
38
 
@@ -69,6 +74,7 @@ module Daytona
69
74
  # @param public [Boolean, nil] Whether the Sandbox should be public
70
75
  # @param timeout [Float, nil] Timeout in seconds for Sandbox to be created and started
71
76
  # @param auto_stop_interval [Integer, nil] Auto-stop interval in minutes
77
+ # @param auto_pause_interval [Integer, nil] Auto-pause interval in minutes
72
78
  # @param auto_archive_interval [Integer, nil] Auto-archive interval in minutes
73
79
  # @param auto_delete_interval [Integer, nil] Auto-delete interval in minutes
74
80
  # @param volumes [Array<DaytonaApiClient::SandboxVolume>, nil] List of volumes mounts to attach to the Sandbox
@@ -87,6 +93,7 @@ module Daytona
87
93
  public: nil,
88
94
  timeout: nil,
89
95
  auto_stop_interval: nil,
96
+ auto_pause_interval: nil,
90
97
  auto_archive_interval: nil,
91
98
  auto_delete_interval: nil,
92
99
  volumes: nil,
@@ -104,6 +111,7 @@ module Daytona
104
111
  @public = public
105
112
  @timeout = timeout
106
113
  @auto_stop_interval = auto_stop_interval
114
+ @auto_pause_interval = auto_pause_interval
107
115
  @auto_archive_interval = auto_archive_interval
108
116
  @auto_delete_interval = auto_delete_interval
109
117
  @volumes = volumes
@@ -130,6 +138,7 @@ module Daytona
130
138
  public:,
131
139
  timeout:,
132
140
  auto_stop_interval:,
141
+ auto_pause_interval:,
133
142
  auto_archive_interval:,
134
143
  auto_delete_interval:,
135
144
  volumes:,
@@ -148,12 +157,15 @@ module Daytona
148
157
  #
149
158
  # @return [void]
150
159
  def handle_ephemeral_auto_delete_conflict
151
- return unless ephemeral && auto_delete_interval && !auto_delete_interval.zero?
160
+ return unless ephemeral
161
+
162
+ if auto_delete_interval && !auto_delete_interval.zero?
163
+ warn(
164
+ "'ephemeral' and 'auto_delete_interval' cannot be used together. " \
165
+ 'If ephemeral is true, auto_delete_interval will be ignored and set to 0.'
166
+ )
167
+ end
152
168
 
153
- warn(
154
- "'ephemeral' and 'auto_delete_interval' cannot be used together. " \
155
- 'If ephemeral is true, auto_delete_interval will be ignored and set to 0.'
156
- )
157
169
  @auto_delete_interval = 0
158
170
  end
159
171
  end
@@ -178,6 +190,7 @@ module Daytona
178
190
  # @param public [Boolean, nil] Whether the Sandbox should be public
179
191
  # @param timeout [Float, nil] Timeout in seconds for Sandbox to be created and started
180
192
  # @param auto_stop_interval [Integer, nil] Auto-stop interval in minutes
193
+ # @param auto_pause_interval [Integer, nil] Auto-pause interval in minutes
181
194
  # @param auto_archive_interval [Integer, nil] Auto-archive interval in minutes
182
195
  # @param auto_delete_interval [Integer, nil] Auto-delete interval in minutes
183
196
  # @param volumes [Array<DaytonaApiClient::SandboxVolume>, nil] List of volumes mounts to attach to the Sandbox
@@ -219,6 +232,7 @@ module Daytona
219
232
  # @param public [Boolean, nil] Whether the Sandbox should be public
220
233
  # @param timeout [Float, nil] Timeout in seconds for Sandbox to be created and started
221
234
  # @param auto_stop_interval [Integer, nil] Auto-stop interval in minutes
235
+ # @param auto_pause_interval [Integer, nil] Auto-pause interval in minutes
222
236
  # @param auto_archive_interval [Integer, nil] Auto-archive interval in minutes
223
237
  # @param auto_delete_interval [Integer, nil] Auto-delete interval in minutes
224
238
  # @param volumes [Array<DaytonaApiClient::SandboxVolume>, nil] List of volumes mounts to attach to the Sandbox
@@ -7,6 +7,10 @@ require 'json'
7
7
  require 'observer'
8
8
 
9
9
  module Daytona
10
+ # Capability token advertised on PTY WebSocket connects so the daemon sends the
11
+ # "exited" control message; clients that don't send it only get the close frame.
12
+ PTY_EXIT_CONTROL_SUBPROTOCOL = 'X-Daytona-Pty-Exit-Control'
13
+
10
14
  class PtySize
11
15
  # @return [Integer] Number of terminal rows (height)
12
16
  attr_reader :rows
@@ -68,6 +72,7 @@ module Daytona
68
72
  @handle_kill = handle_kill
69
73
  @exit_code = nil
70
74
  @error = nil
75
+ @exited = false
71
76
  @logger = Sdk.logger
72
77
 
73
78
  @status = Status::INIT
@@ -77,21 +82,23 @@ module Daytona
77
82
  # Check if connected to the PTY session
78
83
  #
79
84
  # @return [Boolean] true if connected, false otherwise
80
- def connected? = websocket.open?
85
+ # A PTY that has exited is not usable even while the socket briefly stays
86
+ # open before the close frame, so treat it as disconnected.
87
+ def connected? = websocket.open? && !@exited
81
88
 
82
89
  # Wait for the PTY connection to be established
83
90
  #
84
91
  # @param timeout [Float] Maximum time in seconds to wait for connection. Defaults to 10.0
85
92
  # @return [void]
86
93
  # @raise [Daytona::Sdk::Error] If connection timeout is exceeded
87
- def wait_for_connection(timeout: DEFAULT_TIMEOUT)
88
- return if status == Status::CONNECTED
94
+ def wait_for_connection(timeout: DEFAULT_TIMEOUT) # rubocop:disable Metrics/CyclomaticComplexity
95
+ return if status == Status::CONNECTED || @exited
89
96
 
90
97
  start_time = Time.now
91
98
 
92
- sleep(SLEEP_INTERVAL) until status == Status::CONNECTED || (Time.now - start_time) > timeout
99
+ sleep(SLEEP_INTERVAL) until status == Status::CONNECTED || @exited || (Time.now - start_time) > timeout
93
100
 
94
- raise Sdk::Error, 'PTY connection timeout' unless status == Status::CONNECTED
101
+ raise Sdk::Error, 'PTY connection timeout' unless status == Status::CONNECTED || @exited
95
102
  end
96
103
 
97
104
  # Send input to the PTY session
@@ -127,8 +134,9 @@ module Daytona
127
134
  #
128
135
  # @param on_data [Proc, nil] Optional callback to handle output data
129
136
  # @return [Daytona::PtyResult] Result containing exit code and error information
130
- def wait(timeout: nil, &on_data)
137
+ def wait(timeout: nil, &on_data) # rubocop:disable Metrics/CyclomaticComplexity,Metrics/AbcSize
131
138
  timeout ||= Float::INFINITY
139
+ return PtyResult.new(exit_code:, error:) if @exited
132
140
  return unless status == Status::CONNECTED
133
141
 
134
142
  start_time = Time.now
@@ -239,11 +247,17 @@ module Daytona
239
247
 
240
248
  # @param data [WebSocket::Frame::Data]
241
249
  # @return [void]
242
- def process_websocket_control_message(data) # rubocop:disable Metrics/MethodLength
250
+ def process_websocket_control_message(data) # rubocop:disable Metrics/MethodLength,Metrics/AbcSize
243
251
  case data[:status]
244
252
  when WebSocketControlStatus::CONNECTED
245
253
  logger.debug('[control] connected')
246
254
  @status = Status::CONNECTED
255
+ when WebSocketControlStatus::EXITED
256
+ logger.debug("[control] exited: code=#{data[:exitCode]}")
257
+ @exit_code = data[:exitCode]
258
+ @error = data[:exitReason] if data[:exitReason]
259
+ @exited = true
260
+ @status = Status::CLOSED
247
261
  when WebSocketControlStatus::ERROR
248
262
  logger.debug("[control] error: #{error.inspect}")
249
263
  @status = Status::ERROR
@@ -302,6 +316,7 @@ module Daytona
302
316
  module WebSocketControlStatus
303
317
  ALL = [
304
318
  CONNECTED = 'connected',
319
+ EXITED = 'exited',
305
320
  ERROR = 'error'
306
321
  ].freeze
307
322
  end
@@ -48,6 +48,9 @@ module Daytona
48
48
  @api_client = build_api_client
49
49
  @sandbox_api = DaytonaApiClient::SandboxApi.new(api_client)
50
50
  @config_api = DaytonaApiClient::ConfigApi.new(api_client)
51
+ @analytics_api_url = nil
52
+ @analytics_api_url_fetched = false
53
+ @analytics_api_url_mutex = Mutex.new
51
54
  @volume = VolumeService.new(DaytonaApiClient::VolumesApi.new(api_client), otel_state:)
52
55
  @secret = SecretService.new(DaytonaApiClient::SecretApi.new(api_client), otel_state:)
53
56
  @object_storage_api = DaytonaApiClient::ObjectStorageApi.new(api_client)
@@ -68,7 +71,8 @@ module Daytona
68
71
  #
69
72
  # @param params [Daytona::CreateSandboxFromSnapshotParams, Daytona::CreateSandboxFromImageParams, Nil] Sandbox creation parameters
70
73
  # @return [Daytona::Sandbox] The created sandbox
71
- # @raise [Daytona::Sdk::Error] If auto_stop_interval or auto_archive_interval is negative
74
+ # @raise [Daytona::Sdk::Error] If auto_stop_interval, auto_pause_interval, or auto_archive_interval is negative,
75
+ # or if auto_stop_interval and auto_pause_interval are both non-zero
72
76
  def create(params = nil, on_snapshot_create_logs: nil)
73
77
  if params.nil?
74
78
  params = CreateSandboxFromSnapshotParams.new(language: CodeLanguage::PYTHON)
@@ -148,6 +152,18 @@ module Daytona
148
152
  # @return [Daytona::OtelState, nil]
149
153
  attr_reader :otel_state
150
154
 
155
+ # Resolves the deployment's Analytics API URL via /config, cached (including a nil
156
+ # result) for the client's lifetime. Errors are not cached, so failed lookups retry.
157
+ def analytics_api_url
158
+ @analytics_api_url_mutex.synchronize do
159
+ unless @analytics_api_url_fetched
160
+ @analytics_api_url = @config_api.config_controller_get_config.analytics_api_url
161
+ @analytics_api_url_fetched = true
162
+ end
163
+ @analytics_api_url
164
+ end
165
+ end
166
+
151
167
  # Fetches a single page of sandboxes. Each call produces one OTel span
152
168
  # ("Daytona.list_fetch_page") so that paginated iteration emits N spans
153
169
  # for N pages.
@@ -192,7 +208,8 @@ module Daytona
192
208
  # @param timeout [Numeric] Maximum wait time in seconds (defaults to 60 s).
193
209
  # @param on_snapshot_create_logs [Proc]
194
210
  # @return [Daytona::Sandbox] The created sandbox
195
- # @raise [Daytona::Sdk::Error] If auto_stop_interval or auto_archive_interval is negative
211
+ # @raise [Daytona::Sdk::Error] If auto_stop_interval, auto_pause_interval, or auto_archive_interval is negative,
212
+ # or if auto_stop_interval and auto_pause_interval are both non-zero
196
213
  def _create(params, timeout: 60, on_snapshot_create_logs: nil)
197
214
  raise Sdk::Error, 'Timeout must be a non-negative number' if timeout.negative?
198
215
 
@@ -200,6 +217,17 @@ module Daytona
200
217
 
201
218
  raise Sdk::Error, 'auto_stop_interval must be a non-negative integer' if params.auto_stop_interval&.negative?
202
219
 
220
+ raise Sdk::Error, 'auto_pause_interval must be a non-negative integer' if params.auto_pause_interval&.negative?
221
+
222
+ if params.auto_stop_interval&.positive? && params.auto_pause_interval&.positive?
223
+ raise Sdk::Error,
224
+ 'auto_stop_interval and auto_pause_interval are mutually exclusive. Set at most one of them to a non-zero value'
225
+ end
226
+
227
+ if params.auto_pause_interval&.positive? && params.auto_delete_interval&.zero?
228
+ raise Sdk::Error, 'Ephemeral sandboxes cannot have auto-pause enabled. Set auto_pause_interval to 0'
229
+ end
230
+
203
231
  if params.auto_archive_interval&.negative?
204
232
  raise Sdk::Error, 'auto_archive_interval must be a non-negative integer'
205
233
  end
@@ -214,6 +242,7 @@ module Daytona
214
242
  public: params.public,
215
243
  target: config.target,
216
244
  auto_stop_interval: params.auto_stop_interval,
245
+ auto_pause_interval: params.auto_pause_interval,
217
246
  auto_archive_interval: params.auto_archive_interval,
218
247
  auto_delete_interval: params.auto_delete_interval,
219
248
  volumes: params.volumes,
@@ -322,7 +351,8 @@ module Daytona
322
351
  sandbox_dto:,
323
352
  config:,
324
353
  sandbox_api:,
325
- otel_state: @otel_state
354
+ otel_state: @otel_state,
355
+ analytics_api_url_provider: method(:analytics_api_url)
326
356
  )
327
357
  end
328
358
 
@@ -3,6 +3,8 @@
3
3
 
4
4
  # frozen_string_literal: true
5
5
 
6
+ require 'base64'
7
+ require 'json'
6
8
  require 'uri'
7
9
 
8
10
  module Daytona
@@ -410,19 +412,32 @@ module Daytona
410
412
  # pty_handle.disconnect
411
413
  #
412
414
  # @raise [Daytona::Sdk::Error] If the PTY session creation fails or the session ID is already in use.
413
- def create_pty_session(id:, cwd: nil, envs: nil, pty_size: nil) # rubocop:disable Metrics/MethodLength
414
- response = toolbox_api.create_pty_session(
415
- DaytonaToolboxApiClient::PtyCreateRequest.new(
416
- id:,
417
- cwd:,
418
- envs:,
419
- cols: pty_size&.cols,
420
- rows: pty_size&.rows,
421
- lazy_start: true
422
- )
423
- )
415
+ def create_pty_session(id:, cwd: nil, envs: nil, pty_size: nil) # rubocop:disable Metrics/MethodLength,Metrics/AbcSize
416
+ cols = pty_size&.cols || 80
417
+ rows = pty_size&.rows || 24
418
+
419
+ preview_link = get_preview_link.call(WS_PORT)
420
+ url = URI.parse(preview_link.url)
421
+ url.scheme = url.scheme == 'https' ? 'wss' : 'ws'
422
+ url.path = '/process/pty/create-connect'
424
423
 
425
- connect_pty_session(response.session_id)
424
+ params = { id:, cols:, rows: }
425
+ params[:cwd] = cwd if cwd
426
+ url.query = URI.encode_www_form(params)
427
+
428
+ headers = toolbox_api.api_client.default_headers.dup.merge(
429
+ 'X-Daytona-Preview-Token' => preview_link.token
430
+ )
431
+ protocols = [headers['Sec-WebSocket-Protocol'], PTY_EXIT_CONTROL_SUBPROTOCOL].compact
432
+ protocols << "X-Daytona-Pty-Envs~#{Base64.urlsafe_encode64(envs.to_json, padding: false)}" if envs
433
+ headers['Sec-WebSocket-Protocol'] = protocols.join(', ')
434
+
435
+ PtyHandle.new(
436
+ WebSocket::Client::Simple.connect(url.to_s, headers:),
437
+ session_id: id,
438
+ handle_resize: ->(pty_size_arg) { resize_pty_session(id, pty_size_arg) },
439
+ handle_kill: -> { delete_pty_session(id) }
440
+ ).tap(&:wait_for_connection)
426
441
  end
427
442
 
428
443
  # Connects to an existing PTY session in the Sandbox.
@@ -448,13 +463,14 @@ module Daytona
448
463
  url.scheme = url.scheme == 'https' ? 'wss' : 'ws'
449
464
  url.path = "/process/pty/#{session_id}/connect"
450
465
 
466
+ headers = toolbox_api.api_client.default_headers.dup.merge(
467
+ 'X-Daytona-Preview-Token' => preview_link.token
468
+ )
469
+ headers['Sec-WebSocket-Protocol'] =
470
+ [headers['Sec-WebSocket-Protocol'], PTY_EXIT_CONTROL_SUBPROTOCOL].compact.join(', ')
471
+
451
472
  handle = nil
452
- WebSocket::Client::Simple.connect(
453
- url.to_s,
454
- headers: toolbox_api.api_client.default_headers.dup.merge(
455
- 'X-Daytona-Preview-Token' => preview_link.token
456
- )
457
- ) do |client|
473
+ WebSocket::Client::Simple.connect(url.to_s, headers:) do |client|
458
474
  handle = PtyHandle.new(
459
475
  client,
460
476
  session_id:,
@@ -3,14 +3,33 @@
3
3
 
4
4
  # frozen_string_literal: true
5
5
 
6
+ require 'time'
6
7
  require 'timeout'
7
8
 
8
9
  module Daytona
10
+ # A single point-in-time sample of historical Sandbox resource usage.
11
+ SandboxMetrics = Data.define(
12
+ :cpu_count, :cpu_used_pct, :disk_total, :disk_used,
13
+ :mem_total, :mem_used, :mem_cache, :timestamp
14
+ )
15
+
9
16
  class Sandbox # rubocop:disable Metrics/ClassLength
10
17
  include Instrumentation
11
18
 
12
19
  DEFAULT_TIMEOUT = 60
13
20
 
21
+ SANDBOX_METRIC_FIELD_BY_NAME = {
22
+ 'daytona.sandbox.cpu.utilization' => :cpu_used_pct,
23
+ 'daytona.sandbox.cpu.limit' => :cpu_count,
24
+ 'daytona.sandbox.memory.usage' => :mem_used,
25
+ 'daytona.sandbox.memory.limit' => :mem_total,
26
+ 'daytona.sandbox.memory.cache' => :mem_cache,
27
+ 'daytona.sandbox.filesystem.usage' => :disk_used,
28
+ 'daytona.sandbox.filesystem.total' => :disk_total
29
+ }.freeze
30
+
31
+ SANDBOX_METRIC_NAMES = SANDBOX_METRIC_FIELD_BY_NAME.keys.freeze
32
+
14
33
  # @return [String] The ID of the sandbox
15
34
  attr_reader :id
16
35
 
@@ -79,6 +98,9 @@ module Daytona
79
98
  # @return [Float] Auto-stop interval in minutes (0 means disabled)
80
99
  attr_reader :auto_stop_interval
81
100
 
101
+ # @return [Float] Auto-pause interval in minutes (0 means disabled)
102
+ attr_reader :auto_pause_interval
103
+
82
104
  # @return [Float] Auto-archive interval in minutes
83
105
  attr_reader :auto_archive_interval
84
106
 
@@ -132,11 +154,12 @@ module Daytona
132
154
  # @params sandbox_api [DaytonaApiClient::SandboxApi]
133
155
  # @params sandbox_dto [DaytonaApiClient::Sandbox, DaytonaApiClient::SandboxListItem]
134
156
  # @params otel_state [Daytona::OtelState, nil]
135
- def initialize(sandbox_dto:, config:, sandbox_api:, otel_state: nil) # rubocop:disable Metrics/MethodLength
157
+ def initialize(sandbox_dto:, config:, sandbox_api:, otel_state: nil, analytics_api_url_provider: nil) # rubocop:disable Metrics/MethodLength
136
158
  process_response(sandbox_dto)
137
159
  @config = config
138
160
  @sandbox_api = sandbox_api
139
161
  @otel_state = otel_state
162
+ @analytics_api_url_provider = analytics_api_url_provider
140
163
 
141
164
  # Create toolbox API clients with dynamic configuration
142
165
  toolbox_api_config = build_toolbox_api_config
@@ -160,6 +183,7 @@ module Daytona
160
183
  interpreter_api = DaytonaToolboxApiClient::InterpreterApi.new(create_authenticated_client.call)
161
184
  info_api = DaytonaToolboxApiClient::InfoApi.new(create_authenticated_client.call)
162
185
  server_api = DaytonaToolboxApiClient::ServerApi.new(create_authenticated_client.call)
186
+ system_api = DaytonaToolboxApiClient::SystemApi.new(create_authenticated_client.call)
163
187
 
164
188
  @process = Process.new(
165
189
  sandbox_id: id,
@@ -180,6 +204,7 @@ module Daytona
180
204
  @lsp_api = lsp_api
181
205
  @info_api = info_api
182
206
  @server_api = server_api
207
+ @system_api = system_api
183
208
  end
184
209
 
185
210
  # Archives the sandbox, making it inactive and preserving its state. When sandboxes are
@@ -278,6 +303,21 @@ module Daytona
278
303
  @auto_stop_interval = interval
279
304
  end
280
305
 
306
+ # Sets the auto-pause interval for the Sandbox.
307
+ # The Sandbox will automatically pause after being idle (no new events) for the specified interval.
308
+ # Events include any state changes or interactions with the Sandbox through the SDK.
309
+ # Interactions using Sandbox Previews are not included.
310
+ #
311
+ # @param interval [Integer]
312
+ # @return [Integer]
313
+ # @raise [Daytona:Sdk::Error]
314
+ def auto_pause_interval=(interval)
315
+ raise Sdk::Error, 'Auto-pause interval must be a non-negative integer' if interval.negative?
316
+
317
+ sandbox_api.set_auto_pause_interval(id, interval)
318
+ @auto_pause_interval = interval
319
+ end
320
+
281
321
  # Creates an SSH access token for the sandbox.
282
322
  #
283
323
  # @param expires_in_minutes [Integer] TThe number of minutes the SSH access token will be valid for
@@ -338,6 +378,42 @@ module Daytona
338
378
  raise Sdk::Error, "Failed to update environment: #{e.message}"
339
379
  end
340
380
 
381
+ # Gets the most recent resource usage sample directly from the Sandbox daemon.
382
+ #
383
+ # Unlike #get_metrics, which returns aggregated historical samples, this returns the
384
+ # single current reading without going through the telemetry backend.
385
+ #
386
+ # @return [Daytona::SandboxMetrics]
387
+ #
388
+ # @example
389
+ # m = sandbox.get_metrics_latest
390
+ # puts "CPU used: #{m.cpu_used_pct}%"
391
+ def get_metrics_latest
392
+ sandbox_metrics_from_system_metrics(@system_api.get_system_metrics)
393
+ rescue StandardError => e
394
+ raise Sdk::Error, "Failed to get system metrics: #{e.message}"
395
+ end
396
+
397
+ # Gets historical time-series resource usage metrics for the Sandbox.
398
+ #
399
+ # @param start_time [Time, nil] Start of the range. Defaults to the Sandbox creation time.
400
+ # @param end_time [Time, nil] End of the range. Defaults to the current time.
401
+ # @return [Array<Daytona::SandboxMetrics>] Time-ordered usage samples.
402
+ #
403
+ # @example
404
+ # sandbox.get_metrics.each { |m| puts "#{m.timestamp}: #{m.cpu_used_pct}%" }
405
+ def get_metrics(start_time = nil, end_time = nil)
406
+ from, to = metrics_range(start_time, end_time)
407
+ analytics_api_url = fetch_analytics_api_url
408
+ if analytics_api_url && !analytics_api_url.empty?
409
+ pivot_sandbox_metric_points(fetch_analytics_metrics(analytics_api_url, from, to))
410
+ else
411
+ pivot_sandbox_metrics(fetch_proxy_metrics(from, to))
412
+ end
413
+ rescue StandardError => e
414
+ raise Sdk::Error, "Failed to get sandbox metrics: #{e.message}"
415
+ end
416
+
341
417
  # Sets labels for the Sandbox.
342
418
  #
343
419
  # @param labels [Hash<String, String>]
@@ -568,7 +644,8 @@ module Daytona
568
644
  config:,
569
645
  sandbox_api:,
570
646
  code_toolbox:,
571
- otel_state:
647
+ otel_state:,
648
+ analytics_api_url_provider: @analytics_api_url_provider
572
649
  )
573
650
  forked.send(:wait_for_states, operation: OPERATION_START,
574
651
  target_states: [DaytonaApiClient::SandboxState::STARTED])
@@ -609,9 +686,10 @@ module Daytona
609
686
  ) { wait_for_pause_complete }
610
687
  end
611
688
 
612
- instrument :archive, :auto_archive_interval=, :auto_delete_interval=, :auto_stop_interval=,
689
+ instrument :archive, :auto_archive_interval=, :auto_delete_interval=, :auto_pause_interval=, :auto_stop_interval=,
613
690
  :update_network_settings, :update_secrets, :update_env,
614
- :create_ssh_access, :delete, :get_user_home_dir, :get_work_dir, :labels=,
691
+ :create_ssh_access, :delete, :get_user_home_dir, :get_work_dir, :get_metrics, :get_metrics_latest,
692
+ :labels=,
615
693
  :preview_url, :create_signed_preview_url, :expire_signed_preview_url,
616
694
  :refresh, :refresh_activity, :revoke_ssh_access, :start, :recover, :stop,
617
695
  :create_lsp_server, :validate_ssh_access, :wait_for_sandbox_start,
@@ -621,6 +699,82 @@ module Daytona
621
699
 
622
700
  private
623
701
 
702
+ def pivot_sandbox_metrics(series)
703
+ buckets = {}
704
+ Array(series).each do |s|
705
+ Array(s.data_points).each { |point| add_metric_point(buckets, s.metric_name, point.timestamp, point.value) }
706
+ end
707
+ build_sandbox_metrics(buckets)
708
+ end
709
+
710
+ def add_metric_point(buckets, name, timestamp, value)
711
+ field = SANDBOX_METRIC_FIELD_BY_NAME[name]
712
+ return if field.nil? || timestamp.nil? || timestamp.to_s.empty? || value.nil?
713
+
714
+ (buckets[timestamp] ||= {})[field] = value
715
+ end
716
+
717
+ def build_sandbox_metrics(buckets)
718
+ buckets.keys.sort.map { |ts| build_sandbox_metric(ts, buckets[ts]) }
719
+ end
720
+
721
+ def build_sandbox_metric(timestamp, values)
722
+ SandboxMetrics.new(
723
+ cpu_count: values.fetch(:cpu_count, 0).to_i,
724
+ cpu_used_pct: values.fetch(:cpu_used_pct, 0).to_f,
725
+ disk_total: values.fetch(:disk_total, 0).to_i,
726
+ disk_used: values.fetch(:disk_used, 0).to_i,
727
+ mem_total: values.fetch(:mem_total, 0).to_i,
728
+ mem_used: values.fetch(:mem_used, 0).to_i,
729
+ mem_cache: values.fetch(:mem_cache, 0).to_i,
730
+ timestamp: Time.parse(timestamp)
731
+ )
732
+ end
733
+
734
+ def pivot_sandbox_metric_points(points)
735
+ buckets = {}
736
+ Array(points).each { |point| add_metric_point(buckets, point.metric_name, point.timestamp, point.value) }
737
+ build_sandbox_metrics(buckets)
738
+ end
739
+
740
+ def sandbox_metrics_from_system_metrics(system_metrics)
741
+ values = SANDBOX_METRIC_FIELD_BY_NAME.values.to_h { |field| [field, system_metrics.send(field)] }
742
+ build_sandbox_metric(system_metrics.timestamp || Time.now.iso8601, values)
743
+ end
744
+
745
+ def metrics_range(start_time, end_time)
746
+ to = end_time || Time.now
747
+ from = start_time || (created_at ? Time.parse(created_at) : to)
748
+ [from, to]
749
+ end
750
+
751
+ def fetch_analytics_api_url
752
+ return @analytics_api_url_provider.call if @analytics_api_url_provider
753
+
754
+ DaytonaApiClient::ConfigApi.new(sandbox_api.api_client).config_controller_get_config.analytics_api_url
755
+ end
756
+
757
+ def fetch_proxy_metrics(from, to)
758
+ sandbox_api.get_sandbox_metrics(id, from, to, metric_names: SANDBOX_METRIC_NAMES).series
759
+ end
760
+
761
+ def fetch_analytics_metrics(analytics_api_url, from, to)
762
+ analytics_telemetry_api(analytics_api_url).organization_organization_id_sandbox_sandbox_id_telemetry_metrics_get(
763
+ organization_id, id, from.utc.iso8601, to.utc.iso8601, metric_names: SANDBOX_METRIC_NAMES.join(',')
764
+ )
765
+ end
766
+
767
+ def analytics_telemetry_api(analytics_api_url) # rubocop:disable Metrics/AbcSize
768
+ uri = URI(analytics_api_url)
769
+ config = DaytonaAnalyticsApiClient::Configuration.new
770
+ config.scheme = uri.scheme
771
+ config.host = uri.authority
772
+ config.base_path = uri.path
773
+ config.api_key['Authorization'] = sandbox_api.api_client.config.access_token_getter&.call
774
+ config.api_key_prefix['Authorization'] = 'Bearer'
775
+ DaytonaAnalyticsApiClient::TelemetryApi.new(DaytonaAnalyticsApiClient::ApiClient.new(config))
776
+ end
777
+
624
778
  # @return [Daytona::OtelState, nil]
625
779
  attr_reader :otel_state
626
780
 
@@ -661,6 +815,7 @@ module Daytona
661
815
  @error_reason = sandbox_dto.error_reason
662
816
  @backup_state = sandbox_dto.backup_state
663
817
  @auto_stop_interval = sandbox_dto.auto_stop_interval
818
+ @auto_pause_interval = sandbox_dto.auto_pause_interval
664
819
  @auto_archive_interval = sandbox_dto.auto_archive_interval
665
820
  @auto_delete_interval = sandbox_dto.auto_delete_interval
666
821
  @created_at = sandbox_dto.created_at
@@ -5,6 +5,6 @@
5
5
 
6
6
  module Daytona
7
7
  module Sdk
8
- VERSION = '0.196.0'
8
+ VERSION = '0.197.0'
9
9
  end
10
10
  end
data/lib/daytona/sdk.rb CHANGED
@@ -7,6 +7,7 @@ require 'logger'
7
7
 
8
8
  require 'daytona_api_client'
9
9
  require 'daytona_toolbox_api_client'
10
+ require 'daytona_analytics_api_client'
10
11
  require 'toml'
11
12
  require 'websocket-client-simple'
12
13
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: daytona
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.196.0
4
+ version: 0.197.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Daytona Platforms Inc.
@@ -85,28 +85,28 @@ dependencies:
85
85
  requirements:
86
86
  - - '='
87
87
  - !ruby/object:Gem::Version
88
- version: 0.196.0
88
+ version: 0.197.0
89
89
  type: :runtime
90
90
  prerelease: false
91
91
  version_requirements: !ruby/object:Gem::Requirement
92
92
  requirements:
93
93
  - - '='
94
94
  - !ruby/object:Gem::Version
95
- version: 0.196.0
95
+ version: 0.197.0
96
96
  - !ruby/object:Gem::Dependency
97
97
  name: daytona_toolbox_api_client
98
98
  requirement: !ruby/object:Gem::Requirement
99
99
  requirements:
100
100
  - - '='
101
101
  - !ruby/object:Gem::Version
102
- version: 0.196.0
102
+ version: 0.197.0
103
103
  type: :runtime
104
104
  prerelease: false
105
105
  version_requirements: !ruby/object:Gem::Requirement
106
106
  requirements:
107
107
  - - '='
108
108
  - !ruby/object:Gem::Version
109
- version: 0.196.0
109
+ version: 0.197.0
110
110
  - !ruby/object:Gem::Dependency
111
111
  name: dotenv
112
112
  requirement: !ruby/object:Gem::Requirement