nightona 0.191.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.
Files changed (43) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/.rubocop.yml +22 -0
  4. data/.ruby-version +1 -0
  5. data/CODE_OF_CONDUCT.md +132 -0
  6. data/LICENSE +190 -0
  7. data/README.md +184 -0
  8. data/Rakefile +12 -0
  9. data/lib/nightona/code_interpreter.rb +359 -0
  10. data/lib/nightona/common/charts.rb +124 -0
  11. data/lib/nightona/common/code_interpreter.rb +56 -0
  12. data/lib/nightona/common/code_language.rb +14 -0
  13. data/lib/nightona/common/file_system.rb +26 -0
  14. data/lib/nightona/common/git.rb +19 -0
  15. data/lib/nightona/common/image.rb +500 -0
  16. data/lib/nightona/common/nightona.rb +230 -0
  17. data/lib/nightona/common/process.rb +149 -0
  18. data/lib/nightona/common/pty.rb +309 -0
  19. data/lib/nightona/common/resources.rb +39 -0
  20. data/lib/nightona/common/response.rb +83 -0
  21. data/lib/nightona/common/snapshot.rb +124 -0
  22. data/lib/nightona/computer_use.rb +919 -0
  23. data/lib/nightona/config.rb +116 -0
  24. data/lib/nightona/file_system.rb +451 -0
  25. data/lib/nightona/file_transfer.rb +383 -0
  26. data/lib/nightona/git.rb +334 -0
  27. data/lib/nightona/lsp_server.rb +139 -0
  28. data/lib/nightona/nightona.rb +336 -0
  29. data/lib/nightona/object_storage.rb +172 -0
  30. data/lib/nightona/otel.rb +183 -0
  31. data/lib/nightona/process.rb +550 -0
  32. data/lib/nightona/sandbox.rb +751 -0
  33. data/lib/nightona/sdk/version.rb +10 -0
  34. data/lib/nightona/sdk.rb +56 -0
  35. data/lib/nightona/snapshot_service.rb +238 -0
  36. data/lib/nightona/util.rb +80 -0
  37. data/lib/nightona/volume.rb +46 -0
  38. data/lib/nightona/volume_service.rb +61 -0
  39. data/lib/nightona.rb +10 -0
  40. data/project.json +100 -0
  41. data/scripts/generate-docs.rb +402 -0
  42. data/sig/nightona/sdk.rbs +6 -0
  43. metadata +242 -0
@@ -0,0 +1,336 @@
1
+ # Copyright Daytona Platforms Inc.
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ # frozen_string_literal: true
5
+
6
+ require 'json'
7
+ require 'uri'
8
+
9
+ module Nightona
10
+ class Nightona
11
+ include Instrumentation
12
+
13
+ # @return [Nightona::Config]
14
+ attr_reader :config
15
+
16
+ # @return [NightonaApiClient]
17
+ attr_reader :api_client
18
+
19
+ # @return [NightonaApiClient::SandboxApi]
20
+ attr_reader :sandbox_api
21
+
22
+ # @return [Nightona::VolumeService]
23
+ attr_reader :volume
24
+
25
+ # @return [NightonaApiClient::ObjectStorageApi]
26
+ attr_reader :object_storage_api
27
+
28
+ # @return [NightonaApiClient::SnapshotsApi]
29
+ attr_reader :snapshots_api
30
+
31
+ # @return [Nightona::SnapshotService]
32
+ attr_reader :snapshot
33
+
34
+ # @param config [Nightona::Config] Configuration options. Defaults to Nightona::Config.new
35
+ def initialize(config = Config.new)
36
+ @config = config
37
+ ensure_access_token_defined
38
+
39
+ otel_enabled = config.otel_enabled ||
40
+ config._experimental&.dig('otel_enabled') ||
41
+ config.read_env('NIGHTONA_OTEL_ENABLED') == 'true' ||
42
+ config.read_env('NIGHTONA_EXPERIMENTAL_OTEL_ENABLED') == 'true'
43
+ @otel_state = (::Nightona.init_otel(Sdk::VERSION) if otel_enabled)
44
+
45
+ @api_client = build_api_client
46
+ @sandbox_api = NightonaApiClient::SandboxApi.new(api_client)
47
+ @config_api = NightonaApiClient::ConfigApi.new(api_client)
48
+ @volume = VolumeService.new(NightonaApiClient::VolumesApi.new(api_client), otel_state:)
49
+ @object_storage_api = NightonaApiClient::ObjectStorageApi.new(api_client)
50
+ @snapshots_api = NightonaApiClient::SnapshotsApi.new(api_client)
51
+ @snapshot = SnapshotService.new(snapshots_api:, object_storage_api:, default_region_id: config.target,
52
+ otel_state:)
53
+ end
54
+
55
+ # Shuts down OTel providers, flushing any pending telemetry data.
56
+ #
57
+ # @return [void]
58
+ def close
59
+ ::Nightona.shutdown_otel(@otel_state)
60
+ @otel_state = nil
61
+ end
62
+
63
+ # Creates a sandbox with the specified parameters
64
+ #
65
+ # @param params [Nightona::CreateSandboxFromSnapshotParams, Nightona::CreateSandboxFromImageParams, Nil] Sandbox creation parameters
66
+ # @return [Nightona::Sandbox] The created sandbox
67
+ # @raise [Nightona::Sdk::Error] If auto_stop_interval or auto_archive_interval is negative
68
+ def create(params = nil, on_snapshot_create_logs: nil)
69
+ if params.nil?
70
+ params = CreateSandboxFromSnapshotParams.new(language: CodeLanguage::PYTHON)
71
+ elsif params.language.nil?
72
+ params.language = CodeLanguage::PYTHON
73
+ end
74
+
75
+ unless CodeLanguage::ALL.include?(params.language.to_s.to_sym)
76
+ raise ArgumentError,
77
+ "Invalid #{CODE_TOOLBOX_LANGUAGE_LABEL}: #{params.language}. Supported languages: #{CodeLanguage::ALL.join(', ')}"
78
+ end
79
+
80
+ _create(params, on_snapshot_create_logs:)
81
+ end
82
+
83
+ # Deletes a Sandbox.
84
+ #
85
+ # @param sandbox [Nightona::Sandbox]
86
+ # @return [void]
87
+ def delete(sandbox) = sandbox.delete
88
+
89
+ # Gets a Sandbox by its ID.
90
+ #
91
+ # @param id [String]
92
+ # @return [Nightona::Sandbox]
93
+ def get(id)
94
+ sandbox_dto = sandbox_api.get_sandbox(id)
95
+ to_sandbox(sandbox_dto:)
96
+ end
97
+
98
+ # Iterates over Sandboxes matching the given query.
99
+ #
100
+ # @param query [Nightona::ListSandboxesQuery, nil] Optional filters, sorting, and per-page size.
101
+ # @return [Enumerator<Nightona::Sandbox>]
102
+ # @raise [Nightona::Sdk::Error]
103
+ #
104
+ # @example
105
+ # nightona.list(Nightona::ListSandboxesQuery.new(labels: { 'env' => 'dev' })).each do |sandbox|
106
+ # puts sandbox.id
107
+ # end
108
+ def list(query = nil)
109
+ q = query || ListSandboxesQuery.new
110
+
111
+ Enumerator.new do |yielder|
112
+ cursor = nil
113
+ first_page = true
114
+ while first_page || cursor
115
+ first_page = false
116
+ response = fetch_sandbox_page(q, cursor)
117
+ response.items.each do |sandbox_dto|
118
+ yielder << to_sandbox(sandbox_dto: sandbox_dto)
119
+ end
120
+ cursor = response.next_cursor
121
+ break if cursor.nil? || (cursor.respond_to?(:empty?) && cursor.empty?)
122
+ end
123
+ end
124
+ end
125
+
126
+ # Starts a Sandbox and waits for it to be ready.
127
+ #
128
+ # @param sandbox [Nightona::Sandbox]
129
+ # @param timeout [Numeric] Maximum wait time in seconds (defaults to 60 s).
130
+ # @return [void]
131
+ def start(sandbox, timeout = Sandbox::DEFAULT_TIMEOUT) = sandbox.start(timeout)
132
+
133
+ # Stops a Sandbox and waits for it to be stopped.
134
+ #
135
+ # @param sandbox [Nightona::Sandbox]
136
+ # @param timeout [Numeric] Maximum wait time in seconds (defaults to 60 s).
137
+ # @return [void]
138
+ def stop(sandbox, timeout = Sandbox::DEFAULT_TIMEOUT) = sandbox.stop(timeout)
139
+
140
+ instrument :create, :delete, :get, :start, :stop, component: 'Nightona'
141
+
142
+ private
143
+
144
+ # @return [Nightona::OtelState, nil]
145
+ attr_reader :otel_state
146
+
147
+ # Fetches a single page of sandboxes. Each call produces one OTel span
148
+ # ("Nightona.list_fetch_page") so that paginated iteration emits N spans
149
+ # for N pages.
150
+ #
151
+ # @param q [Nightona::ListSandboxesQuery]
152
+ # @param cursor [String, nil]
153
+ # @return [NightonaApiClient::ListSandboxesResponse]
154
+ def fetch_sandbox_page(q, cursor)
155
+ opts = {
156
+ cursor: cursor,
157
+ limit: q.limit,
158
+ id: q.id,
159
+ name: q.name,
160
+ labels: q.labels ? JSON.dump(q.labels) : nil,
161
+ states: q.states,
162
+ snapshots: q.snapshots,
163
+ region_ids: q.targets,
164
+ min_cpu: q.min_cpu,
165
+ max_cpu: q.max_cpu,
166
+ min_memory_gi_b: q.min_memory_gib,
167
+ max_memory_gi_b: q.max_memory_gib,
168
+ min_disk_gi_b: q.min_disk_gib,
169
+ max_disk_gi_b: q.max_disk_gib,
170
+ is_public: q.is_public,
171
+ is_recoverable: q.is_recoverable,
172
+ created_at_after: q.created_at_after,
173
+ created_at_before: q.created_at_before,
174
+ last_event_after: q.last_activity_after,
175
+ last_event_before: q.last_activity_before,
176
+ sort: q.sort,
177
+ order: q.order
178
+ }.compact
179
+
180
+ sandbox_api.list_sandboxes(opts)
181
+ end
182
+
183
+ instrument :fetch_sandbox_page, component: 'Nightona.list'
184
+
185
+ # Creates a sandbox with the specified parameters
186
+ #
187
+ # @param params [Nightona::CreateSandboxFromSnapshotParams, Nightona::CreateSandboxFromImageParams] Sandbox creation parameters
188
+ # @param timeout [Numeric] Maximum wait time in seconds (defaults to 60 s).
189
+ # @param on_snapshot_create_logs [Proc]
190
+ # @return [Nightona::Sandbox] The created sandbox
191
+ # @raise [Nightona::Sdk::Error] If auto_stop_interval or auto_archive_interval is negative
192
+ def _create(params, timeout: 60, on_snapshot_create_logs: nil)
193
+ raise Sdk::Error, 'Timeout must be a non-negative number' if timeout.negative?
194
+
195
+ start_time = Time.now
196
+
197
+ raise Sdk::Error, 'auto_stop_interval must be a non-negative integer' if params.auto_stop_interval&.negative?
198
+
199
+ if params.auto_archive_interval&.negative?
200
+ raise Sdk::Error, 'auto_archive_interval must be a non-negative integer'
201
+ end
202
+
203
+ labels = params.labels&.dup || {}
204
+ labels[CODE_TOOLBOX_LANGUAGE_LABEL] = params.language.to_s if params.language
205
+
206
+ create_sandbox = NightonaApiClient::CreateSandbox.new(
207
+ user: params.os_user,
208
+ env: params.env_vars || {},
209
+ labels: labels,
210
+ public: params.public,
211
+ target: config.target,
212
+ auto_stop_interval: params.auto_stop_interval,
213
+ auto_archive_interval: params.auto_archive_interval,
214
+ auto_delete_interval: params.auto_delete_interval,
215
+ volumes: params.volumes,
216
+ network_block_all: params.network_block_all,
217
+ network_allow_list: params.network_allow_list,
218
+ domain_allow_list: params.domain_allow_list,
219
+ linked_sandbox: params.linked_sandbox
220
+ )
221
+
222
+ create_sandbox.snapshot = params.snapshot if params.respond_to?(:snapshot)
223
+
224
+ if params.respond_to?(:image) && params.image.is_a?(String)
225
+ create_sandbox.build_info = NightonaApiClient::CreateBuildInfo.new(
226
+ dockerfile_content: Image.base(params.image).dockerfile
227
+ )
228
+ elsif params.respond_to?(:image) && params.image.is_a?(Image)
229
+ create_sandbox.build_info = NightonaApiClient::CreateBuildInfo.new(
230
+ context_hashes: SnapshotService.process_image_context(object_storage_api, params.image),
231
+ dockerfile_content: params.image.dockerfile
232
+ )
233
+ end
234
+
235
+ if params.respond_to?(:resources)
236
+ create_sandbox.cpu = params.resources&.cpu
237
+ create_sandbox.memory = params.resources&.memory
238
+ create_sandbox.disk = params.resources&.disk
239
+ create_sandbox.gpu = params.resources&.gpu
240
+ if params.resources&.gpu_type
241
+ create_sandbox.gpu_type =
242
+ params.resources.gpu_type.is_a?(Array) ? params.resources.gpu_type : [params.resources.gpu_type]
243
+ end
244
+ end
245
+
246
+ response = sandbox_api.create_sandbox(create_sandbox)
247
+
248
+ if response.state == NightonaApiClient::SandboxState::PENDING_BUILD && on_snapshot_create_logs
249
+ # Wait for state to change from PENDING_BUILD before fetching logs
250
+ while response.state == NightonaApiClient::SandboxState::PENDING_BUILD
251
+ sleep(1)
252
+ response = sandbox_api.get_sandbox(response.id)
253
+ end
254
+
255
+ # Get build logs URL from API
256
+ build_logs_response = sandbox_api.get_build_logs_url(response.id)
257
+ uri = URI.parse("#{build_logs_response.url}?follow=true")
258
+
259
+ headers = {}
260
+ sandbox_api.api_client.update_params_for_auth!(headers, nil, ['bearer'])
261
+ Util.stream_async(uri:, headers:, on_chunk: on_snapshot_create_logs)
262
+ end
263
+
264
+ sandbox = to_sandbox(sandbox_dto: response)
265
+
266
+ if sandbox.state != NightonaApiClient::SandboxState::STARTED
267
+ sandbox.wait_for_sandbox_start([0.001, timeout - (Time.now - start_time)].max)
268
+ end
269
+
270
+ sandbox
271
+ end
272
+
273
+ # @return [void]
274
+ # @raise [Nightona::Sdk::Error]
275
+ def ensure_access_token_defined
276
+ return if config.api_key
277
+
278
+ unless config.jwt_token
279
+ raise Sdk::Error,
280
+ 'Authentication credentials not found. Set NIGHTONA_API_KEY, or both NIGHTONA_JWT_TOKEN and ' \
281
+ 'NIGHTONA_ORGANIZATION_ID. These can also be provided via Nightona::Config.'
282
+ end
283
+ return if config.organization_id
284
+
285
+ raise Sdk::Error,
286
+ 'NIGHTONA_ORGANIZATION_ID is required when authenticating with NIGHTONA_JWT_TOKEN. ' \
287
+ 'It can also be provided via Nightona::Config.'
288
+ end
289
+
290
+ # @return [NightonaApiClient::ApiClient]
291
+ def build_api_client
292
+ NightonaApiClient::ApiClient.new(api_client_config).tap do |client|
293
+ client.default_headers[HEADER_SOURCE] = SOURCE_RUBY
294
+ client.default_headers[HEADER_SDK_VERSION] = Sdk::VERSION
295
+ client.default_headers[HEADER_ORGANIZATION_ID] = config.organization_id if config.jwt_token
296
+ client.user_agent = "sdk-ruby/#{Sdk::VERSION}"
297
+ end
298
+ end
299
+
300
+ # @return [NightonaApiClient::Configuration]
301
+ def api_client_config
302
+ NightonaApiClient::Configuration.new.configure do |api_config|
303
+ uri = URI(config.api_url)
304
+ api_config.scheme = uri.scheme
305
+ api_config.host = uri.authority # Includes hostname:port
306
+ api_config.base_path = uri.path
307
+
308
+ api_config.access_token_getter = proc { config.api_key || config.jwt_token }
309
+ api_config
310
+ end
311
+ end
312
+
313
+ # @param sandbox_dto [NightonaApiClient::Sandbox, NightonaApiClient::SandboxListItem]
314
+ # @return [Nightona::Sandbox]
315
+ def to_sandbox(sandbox_dto:)
316
+ Sandbox.new(
317
+ sandbox_dto:,
318
+ config:,
319
+ sandbox_api:,
320
+ otel_state: @otel_state
321
+ )
322
+ end
323
+
324
+ SOURCE_RUBY = 'sdk-ruby'
325
+ private_constant :SOURCE_RUBY
326
+
327
+ HEADER_SOURCE = 'X-Nightona-Source'
328
+ private_constant :HEADER_SOURCE
329
+
330
+ HEADER_SDK_VERSION = 'X-Nightona-SDK-Version'
331
+ private_constant :HEADER_SDK_VERSION
332
+
333
+ HEADER_ORGANIZATION_ID = 'X-Nightona-Organization-ID'
334
+ private_constant :HEADER_ORGANIZATION_ID
335
+ end
336
+ end
@@ -0,0 +1,172 @@
1
+ # Copyright Daytona Platforms Inc.
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ # frozen_string_literal: true
5
+
6
+ require 'digest'
7
+ require 'fileutils'
8
+ require 'pathname'
9
+ require 'tempfile'
10
+ require 'zlib'
11
+ require 'aws-sdk-s3'
12
+
13
+ module Nightona
14
+ class ObjectStorage
15
+ # @return [String] The name of the S3 bucket used for object storage
16
+ attr_reader :bucket_name
17
+
18
+ # @return [Aws::S3::Client] The S3 client
19
+ attr_reader :s3_client
20
+
21
+ # Initialize ObjectStorage with S3-compatible credentials
22
+ #
23
+ # @param endpoint_url [String] The endpoint URL for the object storage service
24
+ # @param aws_access_key_id [String] The access key ID for the object storage service
25
+ # @param aws_secret_access_key [String] The secret access key for the object storage service
26
+ # @param aws_session_token [String] The session token for the object storage service
27
+ # @param bucket_name [String] The name of the bucket to use (defaults to "nightona-volume-builds")
28
+ # @param region [String] AWS region (defaults to us-east-1)
29
+ def initialize(endpoint_url:, aws_access_key_id:, aws_secret_access_key:, aws_session_token:, # rubocop:disable Metrics/ParameterLists
30
+ bucket_name: DEFAULT_BUCKET_NAME, region: DEFAULT_REGION)
31
+ @bucket_name = bucket_name
32
+ @s3_client = Aws::S3::Client.new(
33
+ region:,
34
+ endpoint: endpoint_url,
35
+ access_key_id: aws_access_key_id,
36
+ secret_access_key: aws_secret_access_key,
37
+ session_token: aws_session_token
38
+ )
39
+ end
40
+
41
+ # Uploads a file to the object storage service
42
+ #
43
+ # @param path [String] The path to the file to upload
44
+ # @param organization_id [String] The organization ID to use
45
+ # @param archive_base_path [String, nil] The base path to use for the archive
46
+ # @return [String] The hash of the uploaded file
47
+ # @raise [Errno::ENOENT] If the path does not exist
48
+ def upload(path, organization_id, archive_base_path = nil)
49
+ raise Errno::ENOENT, "Path does not exist: #{path}" unless File.exist?(path)
50
+
51
+ path_hash = compute_hash_for_path_md5(path, archive_base_path)
52
+ s3_key = "#{organization_id}/#{path_hash}/context.tar"
53
+
54
+ return path_hash if file_exists_in_s3(s3_key)
55
+
56
+ upload_as_tar(s3_key, path, archive_base_path)
57
+
58
+ path_hash
59
+ end
60
+
61
+ # Compute the base path for an archive. Returns normalized path without the root
62
+ # (drive letter or leading slash).
63
+ #
64
+ # @param path_str [String] The path to compute the base path for
65
+ # @return [String] The base path for the given path
66
+ def self.compute_archive_base_path(path_str)
67
+ normalized_path = File.basename(path_str)
68
+
69
+ # Remove drive letter for Windows paths (e.g., C:)
70
+ path_without_drive = normalized_path.gsub(/^[A-Za-z]:/, '')
71
+
72
+ # Remove leading separators (both / and \)
73
+ path_without_drive.gsub(%r{^[/\\]+}, '')
74
+ end
75
+
76
+ private
77
+
78
+ # Computes the MD5 hash for a given path
79
+ #
80
+ # @param path_str [String] The path to compute the hash for
81
+ # @param archive_base_path [String, nil] The base path to use for the archive
82
+ # @return [String] The MD5 hash for the given path
83
+ def compute_hash_for_path_md5(path_str, archive_base_path = nil) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
84
+ md5_hasher = Digest::MD5.new
85
+ abs_path_str = File.expand_path(path_str)
86
+
87
+ archive_base_path = self.class.compute_archive_base_path(path_str) if archive_base_path.nil?
88
+ md5_hasher.update(archive_base_path)
89
+
90
+ if File.file?(abs_path_str)
91
+ File.open(abs_path_str, 'rb') do |f|
92
+ while (chunk = f.read(8192))
93
+ md5_hasher.update(chunk)
94
+ end
95
+ end
96
+ else
97
+ Dir.glob(File.join(abs_path_str, '**', '*')).each do |file_path|
98
+ next unless File.file?(file_path)
99
+
100
+ rel_path = Pathname.new(file_path).relative_path_from(Pathname.new(abs_path_str)).to_s
101
+
102
+ md5_hasher.update(rel_path)
103
+
104
+ File.open(file_path, 'rb') do |f|
105
+ while (chunk = f.read(8192))
106
+ md5_hasher.update(chunk)
107
+ end
108
+ end
109
+ end
110
+
111
+ # Handle empty directories
112
+ Dir
113
+ .glob(File.join(abs_path_str, '**', '*'))
114
+ .select { |path| File.directory?(path) && Dir.empty?(path) }
115
+ .each do |empty_dir|
116
+ rel_dir = Pathname.new(empty_dir).relative_path_from(Pathname.new(abs_path_str)).to_s
117
+ md5_hasher.update(rel_dir)
118
+ end
119
+ end
120
+
121
+ md5_hasher.hexdigest
122
+ end
123
+
124
+ # Checks whether a specific object exists at the given path
125
+ #
126
+ # @param file_path [String] Full object path, e.g. "org/abcd123/context.tar"
127
+ # @return [Boolean] True if the object exists, False otherwise
128
+ def file_exists_in_s3(file_path)
129
+ s3_client.head_object(bucket: bucket_name, key: file_path)
130
+ true
131
+ rescue Aws::S3::Errors::NotFound
132
+ false
133
+ rescue StandardError => e
134
+ Sdk.logger.debug("Error checking file existence: #{e.message}")
135
+ false
136
+ end
137
+
138
+ # Uploads a file to the object storage service as a tar
139
+ #
140
+ # @param s3_key [String] The key to upload the file to
141
+ # @param source_path [String] The path to the file to upload
142
+ # @param archive_base_path [String, nil] The base path to use for the archive
143
+ def upload_as_tar(s3_key, source_path, archive_base_path = nil) # rubocop:disable Metrics/MethodLength
144
+ source_path = File.expand_path(source_path)
145
+
146
+ self.class.compute_archive_base_path(source_path) if archive_base_path.nil?
147
+
148
+ temp_file = Tempfile.new(['context', '.tar'])
149
+
150
+ begin
151
+ system('tar', '-cf', temp_file.path, '-C', File.dirname(source_path), File.basename(source_path))
152
+
153
+ File.open(temp_file.path, 'rb') do |file|
154
+ s3_client.put_object(
155
+ bucket: bucket_name,
156
+ key: s3_key,
157
+ body: file
158
+ )
159
+ end
160
+ ensure
161
+ temp_file.close
162
+ temp_file.unlink
163
+ end
164
+ end
165
+
166
+ DEFAULT_BUCKET_NAME = 'nightona-volume-builds'
167
+ private_constant :DEFAULT_BUCKET_NAME
168
+
169
+ DEFAULT_REGION = 'us-east-1'
170
+ private_constant :DEFAULT_REGION
171
+ end
172
+ end
@@ -0,0 +1,183 @@
1
+ # Copyright Daytona Platforms Inc.
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ # frozen_string_literal: true
5
+
6
+ module Nightona
7
+ # Holds OTel provider state for the SDK.
8
+ class OtelState
9
+ attr_reader :tracer_provider
10
+ attr_reader :meter_provider
11
+ attr_reader :tracer
12
+ attr_reader :meter
13
+
14
+ def initialize(tracer_provider:, meter_provider:, tracer:, meter:)
15
+ @tracer_provider = tracer_provider
16
+ @meter_provider = meter_provider
17
+ @tracer = tracer
18
+ @meter = meter
19
+ @histograms = {}
20
+ @histograms_mutex = Mutex.new
21
+ end
22
+
23
+ # Returns a cached histogram for the given metric name.
24
+ def histogram(name)
25
+ @histograms_mutex.synchronize do
26
+ @histograms[name] ||= meter.create_histogram(name, unit: 'ms')
27
+ end
28
+ end
29
+
30
+ def shutdown
31
+ tracer_provider.shutdown
32
+ meter_provider.shutdown
33
+ end
34
+ end
35
+
36
+ # Initializes OTel providers, sets globals, installs Typhoeus propagation.
37
+ # OTel gems are required lazily so they are never loaded when disabled.
38
+ #
39
+ # @param sdk_version [String]
40
+ # @return [OtelState]
41
+ def self.init_otel(sdk_version) # rubocop:disable Metrics/MethodLength
42
+ require 'opentelemetry-sdk'
43
+ require 'opentelemetry-metrics-sdk'
44
+ require 'opentelemetry-exporter-otlp'
45
+ require 'opentelemetry-exporter-otlp-metrics'
46
+
47
+ resource = OpenTelemetry::SDK::Resources::Resource.create(
48
+ 'service.name' => 'nightona-ruby-sdk',
49
+ 'service.version' => sdk_version
50
+ )
51
+
52
+ tracer_provider = OpenTelemetry::SDK::Trace::TracerProvider.new(resource:)
53
+ tracer_provider.add_span_processor(
54
+ OpenTelemetry::SDK::Trace::Export::BatchSpanProcessor.new(
55
+ OpenTelemetry::Exporter::OTLP::Exporter.new
56
+ )
57
+ )
58
+ OpenTelemetry.tracer_provider = tracer_provider
59
+
60
+ meter_provider = OpenTelemetry::SDK::Metrics::MeterProvider.new(resource:)
61
+ meter_provider.add_metric_reader(
62
+ OpenTelemetry::SDK::Metrics::Export::PeriodicMetricReader.new(
63
+ exporter: OpenTelemetry::Exporter::OTLP::Metrics::MetricsExporter.new
64
+ )
65
+ )
66
+ OpenTelemetry.meter_provider = meter_provider
67
+
68
+ tracer = tracer_provider.tracer('nightona-ruby-sdk', sdk_version)
69
+ meter = meter_provider.meter('nightona-ruby-sdk')
70
+
71
+ # Install Typhoeus trace-context propagation
72
+ install_typhoeus_propagation
73
+
74
+ OtelState.new(tracer_provider:, meter_provider:, tracer:, meter:)
75
+ end
76
+
77
+ # Flushes and shuts down OTel providers.
78
+ def self.shutdown_otel(state)
79
+ state&.shutdown
80
+ end
81
+
82
+ # Wraps a block with OTel span creation and duration histogram recording.
83
+ # When otel_state is nil (OTel disabled), calls the block directly.
84
+ #
85
+ # @param otel_state [OtelState, nil]
86
+ # @param component [String]
87
+ # @param method_name [String]
88
+ # @return [Object] The block's return value
89
+ def self.with_instrumentation(otel_state, component, method_name, &block) # rubocop:disable Metrics/MethodLength
90
+ return block.call unless otel_state
91
+
92
+ span_name = "#{component}.#{method_name}"
93
+ metric_name = "#{to_snake_case(span_name)}_duration"
94
+ status = 'success'
95
+
96
+ otel_state.tracer.in_span(
97
+ span_name,
98
+ attributes: { 'component' => component, 'method' => method_name }
99
+ ) do |_span|
100
+ start_time = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
101
+ begin
102
+ block.call
103
+ rescue StandardError => e
104
+ status = 'error'
105
+ raise e
106
+ ensure
107
+ duration_ms = (::Process.clock_gettime(::Process::CLOCK_MONOTONIC) - start_time) * 1000.0
108
+ otel_state.histogram(metric_name).record(
109
+ duration_ms,
110
+ attributes: { 'component' => component, 'method' => method_name, 'status' => status }
111
+ )
112
+ end
113
+ end
114
+ end
115
+
116
+ # Converts "ClassName.method_name" to "class_name_method_name".
117
+ def self.to_snake_case(str)
118
+ result = +''
119
+ str.each_char.with_index do |char, i|
120
+ if char == '.'
121
+ result << '_'
122
+ elsif char =~ /[A-Z]/ && i > 0 && str[i - 1] != '.'
123
+ result << '_' << char.downcase
124
+ else
125
+ result << char.downcase
126
+ end
127
+ end
128
+ result
129
+ end
130
+
131
+ # Installs Typhoeus.before callback for W3C trace-context propagation.
132
+ def self.install_typhoeus_propagation
133
+ return unless defined?(Typhoeus)
134
+
135
+ Typhoeus.before do |request|
136
+ headers = request.options[:headers] ||= {}
137
+ OpenTelemetry.propagation.inject(headers)
138
+ true
139
+ end
140
+ end
141
+
142
+ # Mixin that provides the `instrument` class macro for wrapping methods
143
+ # with OTel spans and metrics.
144
+ module Instrumentation
145
+ def self.included(base)
146
+ base.extend(ClassMethods)
147
+ end
148
+
149
+ module ClassMethods
150
+ # Instruments the listed methods with OTel tracing/metrics.
151
+ # Must be called after all target methods are defined.
152
+ #
153
+ # @param method_names [Array<Symbol>] methods to instrument
154
+ # @param component [String] component name for span/metric attributes
155
+ def instrument(*method_names, component:) # rubocop:disable Metrics/MethodLength
156
+ method_names.each do |method_name|
157
+ original = instance_method(method_name)
158
+
159
+ # Detect original visibility
160
+ visibility = if private_method_defined?(method_name, false)
161
+ :private
162
+ elsif protected_method_defined?(method_name, false)
163
+ :protected
164
+ else
165
+ :public
166
+ end
167
+
168
+ define_method(method_name) do |*args, **kwargs, &blk|
169
+ ::Nightona.with_instrumentation(otel_state, component, method_name.to_s) do
170
+ original.bind_call(self, *args, **kwargs, &blk)
171
+ end
172
+ end
173
+
174
+ # Restore visibility
175
+ case visibility
176
+ when :private then private method_name
177
+ when :protected then protected method_name
178
+ end
179
+ end
180
+ end
181
+ end
182
+ end
183
+ end