search-engine-for-typesense 1.0.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 (139) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +148 -0
  4. data/app/search_engine/search_engine/app_info.rb +11 -0
  5. data/app/search_engine/search_engine/index_partition_job.rb +170 -0
  6. data/lib/generators/search_engine/install/install_generator.rb +20 -0
  7. data/lib/generators/search_engine/install/templates/initializer.rb.tt +230 -0
  8. data/lib/generators/search_engine/model/model_generator.rb +86 -0
  9. data/lib/generators/search_engine/model/templates/model.rb.tt +12 -0
  10. data/lib/search-engine-for-typesense.rb +12 -0
  11. data/lib/search_engine/active_record_syncable.rb +247 -0
  12. data/lib/search_engine/admin/stopwords.rb +125 -0
  13. data/lib/search_engine/admin/synonyms.rb +125 -0
  14. data/lib/search_engine/admin.rb +12 -0
  15. data/lib/search_engine/ast/and.rb +52 -0
  16. data/lib/search_engine/ast/binary_op.rb +75 -0
  17. data/lib/search_engine/ast/eq.rb +19 -0
  18. data/lib/search_engine/ast/group.rb +18 -0
  19. data/lib/search_engine/ast/gt.rb +12 -0
  20. data/lib/search_engine/ast/gte.rb +12 -0
  21. data/lib/search_engine/ast/in.rb +28 -0
  22. data/lib/search_engine/ast/lt.rb +12 -0
  23. data/lib/search_engine/ast/lte.rb +12 -0
  24. data/lib/search_engine/ast/matches.rb +55 -0
  25. data/lib/search_engine/ast/node.rb +176 -0
  26. data/lib/search_engine/ast/not_eq.rb +13 -0
  27. data/lib/search_engine/ast/not_in.rb +24 -0
  28. data/lib/search_engine/ast/or.rb +52 -0
  29. data/lib/search_engine/ast/prefix.rb +51 -0
  30. data/lib/search_engine/ast/raw.rb +41 -0
  31. data/lib/search_engine/ast/unary_op.rb +43 -0
  32. data/lib/search_engine/ast.rb +101 -0
  33. data/lib/search_engine/base/creation.rb +727 -0
  34. data/lib/search_engine/base/deletion.rb +80 -0
  35. data/lib/search_engine/base/display_coercions.rb +36 -0
  36. data/lib/search_engine/base/hydration.rb +312 -0
  37. data/lib/search_engine/base/index_maintenance/cleanup.rb +202 -0
  38. data/lib/search_engine/base/index_maintenance/lifecycle.rb +251 -0
  39. data/lib/search_engine/base/index_maintenance/schema.rb +117 -0
  40. data/lib/search_engine/base/index_maintenance.rb +459 -0
  41. data/lib/search_engine/base/indexing_dsl.rb +255 -0
  42. data/lib/search_engine/base/joins.rb +479 -0
  43. data/lib/search_engine/base/model_dsl.rb +472 -0
  44. data/lib/search_engine/base/presets.rb +43 -0
  45. data/lib/search_engine/base/pretty_printer.rb +315 -0
  46. data/lib/search_engine/base/relation_delegation.rb +42 -0
  47. data/lib/search_engine/base/scopes.rb +113 -0
  48. data/lib/search_engine/base/updating.rb +92 -0
  49. data/lib/search_engine/base.rb +38 -0
  50. data/lib/search_engine/bulk.rb +284 -0
  51. data/lib/search_engine/cache.rb +33 -0
  52. data/lib/search_engine/cascade.rb +531 -0
  53. data/lib/search_engine/cli/doctor.rb +631 -0
  54. data/lib/search_engine/cli/support.rb +217 -0
  55. data/lib/search_engine/cli.rb +222 -0
  56. data/lib/search_engine/client/http_adapter.rb +63 -0
  57. data/lib/search_engine/client/request_builder.rb +92 -0
  58. data/lib/search_engine/client/services/base.rb +74 -0
  59. data/lib/search_engine/client/services/collections.rb +161 -0
  60. data/lib/search_engine/client/services/documents.rb +214 -0
  61. data/lib/search_engine/client/services/operations.rb +152 -0
  62. data/lib/search_engine/client/services/search.rb +190 -0
  63. data/lib/search_engine/client/services.rb +29 -0
  64. data/lib/search_engine/client.rb +765 -0
  65. data/lib/search_engine/client_options.rb +20 -0
  66. data/lib/search_engine/collection_resolver.rb +191 -0
  67. data/lib/search_engine/collections_graph.rb +330 -0
  68. data/lib/search_engine/compiled_params.rb +143 -0
  69. data/lib/search_engine/compiler.rb +383 -0
  70. data/lib/search_engine/config/observability.rb +27 -0
  71. data/lib/search_engine/config/presets.rb +92 -0
  72. data/lib/search_engine/config/selection.rb +16 -0
  73. data/lib/search_engine/config/typesense.rb +48 -0
  74. data/lib/search_engine/config/validators.rb +97 -0
  75. data/lib/search_engine/config.rb +917 -0
  76. data/lib/search_engine/console_helpers.rb +130 -0
  77. data/lib/search_engine/deletion.rb +103 -0
  78. data/lib/search_engine/dispatcher.rb +125 -0
  79. data/lib/search_engine/dsl/parser.rb +582 -0
  80. data/lib/search_engine/engine.rb +167 -0
  81. data/lib/search_engine/errors.rb +290 -0
  82. data/lib/search_engine/filters/sanitizer.rb +189 -0
  83. data/lib/search_engine/hydration/materializers.rb +808 -0
  84. data/lib/search_engine/hydration/selection_context.rb +96 -0
  85. data/lib/search_engine/indexer/batch_planner.rb +76 -0
  86. data/lib/search_engine/indexer/bulk_import.rb +626 -0
  87. data/lib/search_engine/indexer/import_dispatcher.rb +198 -0
  88. data/lib/search_engine/indexer/retry_policy.rb +103 -0
  89. data/lib/search_engine/indexer.rb +747 -0
  90. data/lib/search_engine/instrumentation.rb +308 -0
  91. data/lib/search_engine/joins/guard.rb +202 -0
  92. data/lib/search_engine/joins/resolver.rb +95 -0
  93. data/lib/search_engine/logging/color.rb +78 -0
  94. data/lib/search_engine/logging/format_helpers.rb +92 -0
  95. data/lib/search_engine/logging/partition_progress.rb +53 -0
  96. data/lib/search_engine/logging_subscriber.rb +388 -0
  97. data/lib/search_engine/mapper.rb +785 -0
  98. data/lib/search_engine/multi.rb +286 -0
  99. data/lib/search_engine/multi_result.rb +186 -0
  100. data/lib/search_engine/notifications/compact_logger.rb +675 -0
  101. data/lib/search_engine/observability.rb +162 -0
  102. data/lib/search_engine/operations.rb +58 -0
  103. data/lib/search_engine/otel.rb +227 -0
  104. data/lib/search_engine/partitioner.rb +128 -0
  105. data/lib/search_engine/ranking_plan.rb +118 -0
  106. data/lib/search_engine/registry.rb +158 -0
  107. data/lib/search_engine/relation/compiler.rb +711 -0
  108. data/lib/search_engine/relation/deletion.rb +37 -0
  109. data/lib/search_engine/relation/dsl/filters.rb +624 -0
  110. data/lib/search_engine/relation/dsl/selection.rb +240 -0
  111. data/lib/search_engine/relation/dsl.rb +903 -0
  112. data/lib/search_engine/relation/dx/dry_run.rb +59 -0
  113. data/lib/search_engine/relation/dx/friendly_where.rb +24 -0
  114. data/lib/search_engine/relation/dx.rb +231 -0
  115. data/lib/search_engine/relation/materializers.rb +118 -0
  116. data/lib/search_engine/relation/options.rb +138 -0
  117. data/lib/search_engine/relation/state.rb +274 -0
  118. data/lib/search_engine/relation/updating.rb +44 -0
  119. data/lib/search_engine/relation.rb +623 -0
  120. data/lib/search_engine/result.rb +664 -0
  121. data/lib/search_engine/schema.rb +1083 -0
  122. data/lib/search_engine/sources/active_record_source.rb +185 -0
  123. data/lib/search_engine/sources/base.rb +62 -0
  124. data/lib/search_engine/sources/lambda_source.rb +55 -0
  125. data/lib/search_engine/sources/sql_source.rb +196 -0
  126. data/lib/search_engine/sources.rb +71 -0
  127. data/lib/search_engine/stale_rules.rb +160 -0
  128. data/lib/search_engine/test/minitest_assertions.rb +57 -0
  129. data/lib/search_engine/test/offline_client.rb +134 -0
  130. data/lib/search_engine/test/rspec_matchers.rb +77 -0
  131. data/lib/search_engine/test/stub_client.rb +201 -0
  132. data/lib/search_engine/test.rb +66 -0
  133. data/lib/search_engine/test_autoload.rb +8 -0
  134. data/lib/search_engine/update.rb +35 -0
  135. data/lib/search_engine/version.rb +7 -0
  136. data/lib/search_engine.rb +332 -0
  137. data/lib/tasks/search_engine.rake +501 -0
  138. data/lib/tasks/search_engine_doctor.rake +16 -0
  139. metadata +225 -0
@@ -0,0 +1,631 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'search_engine/cli/support'
5
+
6
+ module SearchEngine
7
+ module Cli
8
+ # Orchestrates diagnostics checks and renders output for the doctor task.
9
+ #
10
+ # Public API: SearchEngine::Cli::Doctor.run
11
+ #
12
+ # Supports FORMAT env var (table/json) and redaction-aware details.
13
+ #
14
+ # @since M8
15
+ # @see https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/cli#doctor
16
+ module Doctor
17
+ DOCS_BASE = 'https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/'
18
+
19
+ module_function
20
+
21
+ # Build a link to the docs site (pass slug without .md, anchors allowed)
22
+ def wiki(slug)
23
+ return nil if slug.nil? || slug.to_s.strip.empty?
24
+
25
+ DOCS_BASE + slug.to_s.tr('_', '-')
26
+ end
27
+
28
+ class << self
29
+ # Run all checks and print output to STDOUT.
30
+ # Returns exit code (0 success, 1 failure).
31
+ # @return [Integer]
32
+ # @since M8
33
+ # @see https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/cli#doctor
34
+ def run
35
+ puts 'Executing doctor checks...'
36
+
37
+ runner = Runner.new
38
+ result = runner.execute
39
+
40
+ if runner.json?
41
+ puts(JSON.generate(result))
42
+ else
43
+ puts(Renderers::Table.render(result, verbose: runner.verbose?))
44
+ end
45
+
46
+ result[:ok] ? 0 : 1
47
+ end
48
+ end
49
+
50
+ # --- Internals -----------------------------------------------------------------
51
+
52
+ # Lightweight struct-like result builder keeping stable key ordering in JSON
53
+ module Builder
54
+ module_function
55
+
56
+ def new_summary
57
+ { passed: 0, warned: 0, failed: 0, duration_ms_total: 0.0 }
58
+ end
59
+
60
+ def new_result
61
+ { ok: true, summary: new_summary, checks: [] }
62
+ end
63
+
64
+ def result_for_check(name:, ok:, severity:, duration_ms:, details:, hint:, doc:, error_class:, error_message:)
65
+ {
66
+ name: name.to_s,
67
+ ok: ok ? true : false,
68
+ severity: severity&.to_sym,
69
+ duration_ms: duration_ms.to_f.round(1),
70
+ details: details,
71
+ hint: hint,
72
+ doc: doc,
73
+ error_class: error_class,
74
+ error_message: error_message && SearchEngine::Observability.truncate_message(error_message, 200)
75
+ }
76
+ end
77
+ end
78
+
79
+ # Runner executes all doctor checks and aggregates a summary.
80
+ class Runner
81
+ def initialize(env = ENV)
82
+ @env = env
83
+ @started_ms = monotonic_ms
84
+ end
85
+
86
+ def execute
87
+ results = Builder.new_result
88
+
89
+ checks = [
90
+ method(:check_config_presence),
91
+ method(:check_connectivity_health),
92
+ method(:check_api_key_validity),
93
+ method(:check_alias_resolution),
94
+ method(:check_dry_run_single),
95
+ method(:check_dry_run_multi),
96
+ method(:check_logging_mode),
97
+ method(:check_opentelemetry)
98
+ ]
99
+
100
+ checks.each do |chk|
101
+ res = safely_time(chk)
102
+ bump_summary!(results[:summary], res)
103
+ results[:checks] << res
104
+ end
105
+
106
+ results[:ok] = results[:summary][:failed].zero?
107
+ results[:summary][:duration_ms_total] = (monotonic_ms - @started_ms).round(1)
108
+
109
+ results
110
+ end
111
+
112
+ # --- Flags -----------------------------------------------------------
113
+
114
+ def json?
115
+ SearchEngine::Cli::Support.json_output?
116
+ end
117
+
118
+ def verbose?
119
+ SearchEngine::Cli.boolean_env?('VERBOSE')
120
+ end
121
+
122
+ # --- Checks ----------------------------------------------------------
123
+
124
+ def check_config_presence
125
+ started = monotonic_ms
126
+ cfg = SearchEngine.config
127
+
128
+ missing = []
129
+ missing << 'host' if cfg.host.to_s.strip.empty?
130
+ missing << 'port' unless cfg.port.is_a?(Integer) && cfg.port.positive?
131
+ missing << 'protocol' unless %w[http https].include?(cfg.protocol.to_s.strip.presence)
132
+ missing << 'api_key' if cfg.api_key.to_s.strip.empty?
133
+ missing << 'timeout_ms' unless cfg.timeout_ms.is_a?(Integer)
134
+ missing << 'open_timeout_ms' unless cfg.open_timeout_ms.is_a?(Integer)
135
+
136
+ ok = missing.empty?
137
+ hint = unless ok
138
+ 'Set TYPESENSE_* envs or configure in an initializer. ' \
139
+ 'See https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/installation#configuration'
140
+ end
141
+
142
+ details = cfg.to_h_redacted
143
+ duration = monotonic_ms - started
144
+ Builder.result_for_check(
145
+ name: 'config_presence',
146
+ ok: ok,
147
+ severity: ok ? :info : :error,
148
+ duration_ms: duration,
149
+ details: details,
150
+ hint: hint,
151
+ doc: Doctor.wiki('installation#configuration'),
152
+ error_class: nil,
153
+ error_message: nil
154
+ )
155
+ rescue StandardError => error
156
+ failure(
157
+ 'config_presence',
158
+ started,
159
+ error,
160
+ hint: 'Unexpected error reading configuration',
161
+ doc: 'https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/configuration'
162
+ )
163
+ end
164
+
165
+ def check_connectivity_health
166
+ started = monotonic_ms
167
+ client = client_with_overrides
168
+ health = client.health
169
+ ok = !(health && (health[:ok] == true || health['ok'] == true)).nil?
170
+ details = { response: redacted_value(health) }
171
+ hint = ok ? nil : 'Verify host/port/protocol and network reachability to Typesense.'
172
+
173
+ Builder.result_for_check(
174
+ name: 'health_check',
175
+ ok: ok,
176
+ severity: ok ? :info : :error,
177
+ duration_ms: monotonic_ms - started,
178
+ details: details,
179
+ hint: hint,
180
+ doc: Doctor.wiki('configuration#typesense-connection'),
181
+ error_class: nil,
182
+ error_message: nil
183
+ )
184
+ rescue SearchEngine::Errors::Error => error
185
+ failure(
186
+ 'health_check',
187
+ started,
188
+ error,
189
+ hint: 'Check host/port/protocol and ingress/firewall settings.',
190
+ doc: Doctor.wiki('cli#doctor')
191
+ )
192
+ end
193
+
194
+ def check_api_key_validity
195
+ started = monotonic_ms
196
+ client = client_with_overrides
197
+ list = client.list_collections
198
+ count = Array(list).size
199
+ details = { collections_count: count }
200
+ Builder.result_for_check(
201
+ name: 'api_key_valid',
202
+ ok: true,
203
+ severity: :info,
204
+ duration_ms: monotonic_ms - started,
205
+ details: details,
206
+ hint: nil,
207
+ doc: Doctor.wiki('configuration#typesense-connection'),
208
+ error_class: nil,
209
+ error_message: nil
210
+ )
211
+ rescue SearchEngine::Errors::Api => error
212
+ status = error.respond_to?(:status) ? error.status.to_i : nil
213
+ if [401, 403].include?(status)
214
+ Builder.result_for_check(
215
+ name: 'api_key_valid',
216
+ ok: false,
217
+ severity: :error,
218
+ duration_ms: monotonic_ms - started,
219
+ details: { http_status: status },
220
+ hint: 'Your Typesense API key is invalid or lacks permissions. Verify TYPESENSE_API_KEY.',
221
+ doc: Doctor.wiki('configuration#typesense-connection'),
222
+ error_class: error.class.name,
223
+ error_message: error.message
224
+ )
225
+ else
226
+ failure(
227
+ 'api_key_valid',
228
+ started,
229
+ error,
230
+ hint: 'Unexpected API error while verifying key.',
231
+ doc: Doctor.wiki('client#errors')
232
+ )
233
+ end
234
+ rescue SearchEngine::Errors::Error => error
235
+ failure(
236
+ 'api_key_valid',
237
+ started,
238
+ error,
239
+ hint: 'Connectivity problem while verifying key.',
240
+ doc: Doctor.wiki('client#errors')
241
+ )
242
+ end
243
+
244
+ def check_alias_resolution
245
+ started = monotonic_ms
246
+ client = client_with_overrides
247
+ mapping = SearchEngine::Registry.mapping
248
+ if mapping.empty?
249
+ return Builder.result_for_check(
250
+ name: 'alias_check',
251
+ ok: true,
252
+ severity: :info,
253
+ duration_ms: monotonic_ms - started,
254
+ details: { note: 'no registered collections' },
255
+ hint: 'Define at least one model inheriting from SearchEngine::Base and declare collection name.',
256
+ doc: Doctor.wiki('schema'),
257
+ error_class: nil,
258
+ error_message: nil
259
+ )
260
+ end
261
+
262
+ missing = []
263
+ resolved = {}
264
+ mapping.each_key do |logical|
265
+ target = client.resolve_alias(logical)
266
+ if target.nil?
267
+ missing << logical
268
+ else
269
+ resolved[logical] = target
270
+ end
271
+ end
272
+
273
+ ok = missing.empty?
274
+ hint = if ok
275
+ nil
276
+ else
277
+ 'Run schema lifecycle to create physical collections and swap alias. ' \
278
+ 'See https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/schema#lifecycle'
279
+ end
280
+ details = { resolved: resolved, missing: missing }
281
+ Builder.result_for_check(
282
+ name: 'alias_check',
283
+ ok: ok,
284
+ severity: ok ? :info : :error,
285
+ duration_ms: monotonic_ms - started,
286
+ details: details,
287
+ hint: hint,
288
+ doc: Doctor.wiki('schema#lifecycle'),
289
+ error_class: nil,
290
+ error_message: nil
291
+ )
292
+ rescue SearchEngine::Errors::Error => error
293
+ failure(
294
+ 'alias_check',
295
+ started,
296
+ error,
297
+ hint: 'Failed to check aliases due to API/connectivity error.',
298
+ doc: Doctor.wiki('schema')
299
+ )
300
+ end
301
+
302
+ def check_dry_run_single
303
+ started = monotonic_ms
304
+ klass = first_registered_class
305
+ if klass.nil?
306
+ return Builder.result_for_check(
307
+ name: 'dry_run_single',
308
+ ok: true,
309
+ severity: :info,
310
+ duration_ms: monotonic_ms - started,
311
+ details: { note: 'no registered collections' },
312
+ hint: 'Add a model inheriting from SearchEngine::Base to preview compile output.',
313
+ doc: Doctor.wiki('dx'),
314
+ error_class: nil,
315
+ error_message: nil
316
+ )
317
+ end
318
+
319
+ rel = klass.all
320
+ preview = rel.dry_run!
321
+ details = {
322
+ url: preview[:url],
323
+ url_opts: SearchEngine::Observability.filtered_url_opts(preview[:url_opts] || {}),
324
+ body_preview: SearchEngine::Cli::Support.parse_json_or_string(preview[:body])
325
+ }
326
+ Builder.result_for_check(
327
+ name: 'dry_run_single',
328
+ ok: true,
329
+ severity: :info,
330
+ duration_ms: monotonic_ms - started,
331
+ details: details,
332
+ hint: nil,
333
+ doc: Doctor.wiki('dx'),
334
+ error_class: nil,
335
+ error_message: nil
336
+ )
337
+ rescue StandardError => error
338
+ failure(
339
+ 'dry_run_single',
340
+ started,
341
+ error,
342
+ hint: 'Compile failed. Check DSL, selection, and joins configuration.',
343
+ doc: Doctor.wiki('query_dsl')
344
+ )
345
+ end
346
+
347
+ def check_dry_run_multi
348
+ started = monotonic_ms
349
+ klass = first_registered_class
350
+ if klass.nil?
351
+ return Builder.result_for_check(
352
+ name: 'dry_run_multi',
353
+ ok: true,
354
+ severity: :info,
355
+ duration_ms: monotonic_ms - started,
356
+ details: { note: 'no registered collections' },
357
+ hint: 'Add at least one model to preview multi-search compile.',
358
+ doc: Doctor.wiki('multi_search'),
359
+ error_class: nil,
360
+ error_message: nil
361
+ )
362
+ end
363
+
364
+ m = SearchEngine::Multi.new
365
+ m.add(:a, klass.all)
366
+ m.add(:b, klass.all)
367
+ payloads = m.to_payloads(common: {})
368
+ red = payloads.map { |p| SearchEngine::Observability.redact(p) }
369
+ details = { searches_count: red.size, payloads_preview: red }
370
+ Builder.result_for_check(
371
+ name: 'dry_run_multi',
372
+ ok: true,
373
+ severity: :info,
374
+ duration_ms: monotonic_ms - started,
375
+ details: details,
376
+ hint: nil,
377
+ doc: Doctor.wiki('multi_search'),
378
+ error_class: nil,
379
+ error_message: nil
380
+ )
381
+ rescue StandardError => error
382
+ failure(
383
+ 'dry_run_multi',
384
+ started,
385
+ error,
386
+ hint: 'Multi-search compile failed. Verify relations.',
387
+ doc: Doctor.wiki('multi_search')
388
+ )
389
+ end
390
+
391
+ def check_logging_mode
392
+ started = monotonic_ms
393
+ log_cfg = SearchEngine.config.logging
394
+ details = {
395
+ mode: (log_cfg.respond_to?(:mode) ? log_cfg.mode : nil),
396
+ level: (log_cfg.respond_to?(:level) ? log_cfg.level : nil),
397
+ sample: (log_cfg.respond_to?(:sample) ? log_cfg.sample : nil),
398
+ logger_present: !(log_cfg.respond_to?(:logger) ? log_cfg.logger : SearchEngine.config.logger).nil?
399
+ }
400
+
401
+ Builder.result_for_check(
402
+ name: 'logging_mode',
403
+ ok: true,
404
+ severity: :info,
405
+ duration_ms: monotonic_ms - started,
406
+ details: details,
407
+ hint: nil,
408
+ doc: Doctor.wiki('observability'),
409
+ error_class: nil,
410
+ error_message: nil
411
+ )
412
+ rescue StandardError => error
413
+ failure(
414
+ 'logging_mode',
415
+ started,
416
+ error,
417
+ hint: 'Unable to read logging configuration.',
418
+ doc: Doctor.wiki('observability')
419
+ )
420
+ end
421
+
422
+ def check_opentelemetry
423
+ started = monotonic_ms
424
+ installed = SearchEngine::Otel.installed?
425
+ enabled = begin
426
+ SearchEngine::Otel.enabled?
427
+ rescue StandardError
428
+ false
429
+ end
430
+ svc = begin
431
+ SearchEngine.config.opentelemetry.service_name
432
+ rescue StandardError
433
+ nil
434
+ end
435
+
436
+ if installed && !enabled
437
+ Builder.result_for_check(
438
+ name: 'otel_status',
439
+ ok: true,
440
+ severity: :warning,
441
+ duration_ms: monotonic_ms - started,
442
+ details: { installed: installed, enabled: enabled, service_name: svc },
443
+ hint: 'OpenTelemetry installed but disabled. Enable via ' \
444
+ 'SearchEngine.config.opentelemetry.enabled = true.',
445
+ doc: Doctor.wiki('observability#opentelemetry'),
446
+ error_class: nil,
447
+ error_message: nil
448
+ )
449
+ else
450
+ Builder.result_for_check(
451
+ name: 'otel_status',
452
+ ok: true,
453
+ severity: :info,
454
+ duration_ms: monotonic_ms - started,
455
+ details: { installed: installed, enabled: enabled, service_name: svc },
456
+ hint: nil,
457
+ doc: Doctor.wiki('observability#opentelemetry'),
458
+ error_class: nil,
459
+ error_message: nil
460
+ )
461
+ end
462
+ rescue StandardError => error
463
+ failure(
464
+ 'otel_status',
465
+ started,
466
+ error,
467
+ hint: 'Unable to determine OpenTelemetry status.',
468
+ doc: Doctor.wiki('observability')
469
+ )
470
+ end
471
+
472
+ # --- Helpers ---------------------------------------------------------
473
+
474
+ def client_with_overrides
475
+ base = SearchEngine.config
476
+ cfg = SearchEngine::Config.new
477
+ cfg.host = override_or(base.host, ENV['TYPESENSE_HOST'])
478
+ cfg.port = override_or(base.port, env_int('TYPESENSE_PORT'))
479
+ cfg.protocol = base.protocol.to_s.strip || '' # nil is allowed
480
+ cfg.api_key = base.api_key
481
+
482
+ timeout_s = env_int('TYPESENSE_TIMEOUT')
483
+ cfg.timeout_ms = (timeout_s ? Integer(timeout_s) * 1000 : base.timeout_ms)
484
+ cfg.open_timeout_ms = base.open_timeout_ms
485
+ cfg.retries = base.retries
486
+ cfg.logger = base.logger
487
+
488
+ SearchEngine.client(config: cfg)
489
+ end
490
+
491
+ def env_int(name)
492
+ val = ENV[name]
493
+ return nil if val.nil? || val.to_s.strip.empty?
494
+
495
+ Integer(val)
496
+ rescue ArgumentError, TypeError
497
+ nil
498
+ end
499
+
500
+ def override_or(base_value, override)
501
+ return base_value if override.nil? || override.to_s.strip.empty?
502
+
503
+ override
504
+ end
505
+
506
+ def redacted_value(value)
507
+ case value
508
+ when Hash, Array
509
+ SearchEngine::Observability.redact(value)
510
+ else
511
+ value
512
+ end
513
+ end
514
+
515
+ def failure(name, started_ms, error, hint:, doc: nil)
516
+ Builder.result_for_check(
517
+ name: name,
518
+ ok: false,
519
+ severity: :error,
520
+ duration_ms: (monotonic_ms - started_ms),
521
+ details: {},
522
+ hint: hint,
523
+ doc: doc,
524
+ error_class: error.class.name,
525
+ error_message: error.message
526
+ )
527
+ end
528
+
529
+ def bump_summary!(summary, check)
530
+ if check[:ok]
531
+ if check[:severity] == :warning
532
+ summary[:warned] += 1
533
+ else
534
+ summary[:passed] += 1
535
+ end
536
+ else
537
+ summary[:failed] += 1
538
+ end
539
+ summary[:duration_ms_total] = (summary[:duration_ms_total] + check[:duration_ms].to_f).round(1)
540
+ end
541
+
542
+ def first_registered_class
543
+ pair = SearchEngine::Registry.mapping.first
544
+ pair&.last
545
+ end
546
+
547
+ def monotonic_ms
548
+ SearchEngine::Instrumentation.monotonic_ms
549
+ end
550
+
551
+ # Time a check and handle unexpected errors uniformly.
552
+ def safely_time(callable)
553
+ started = monotonic_ms
554
+ callable.call
555
+ rescue StandardError => error
556
+ puts "error: #{error.backtrace.join("\n")}"
557
+ puts '--------------------------------'
558
+ puts '--------------------------------'
559
+ puts '--------------------------------'
560
+ failure(callable.name, started, error, hint: 'Unexpected error', doc: nil)
561
+ end
562
+ end
563
+
564
+ module Renderers
565
+ # Table renderer for human-friendly output of doctor results.
566
+ module Table
567
+ module_function
568
+
569
+ def render(result, verbose: false)
570
+ rows = result[:checks].map { |c| to_row(c, verbose: verbose) }
571
+ header = %w[NAME STATUS DURATION HINT DOC]
572
+ "#{render_table([header] + rows)}\n\n#{summary_line(result)}"
573
+ end
574
+
575
+ def to_row(check, verbose: false)
576
+ name = check[:name]
577
+ status = status_str(check)
578
+ dur = format('%.1fms', check[:duration_ms].to_f)
579
+ hint = truncate(check[:hint].to_s, verbose: verbose)
580
+ doc = (check[:doc] || '').to_s
581
+ [name, status, dur, hint, doc]
582
+ end
583
+
584
+ def status_str(check)
585
+ return 'FAIL' unless check[:ok]
586
+ return 'WARN' if check[:severity] == :warning
587
+
588
+ 'OK'
589
+ end
590
+
591
+ def truncate(text, verbose: false, max: 80)
592
+ return text if verbose || text.length <= max
593
+
594
+ "#{text[0, max]}..."
595
+ end
596
+
597
+ def render_table(rows)
598
+ widths = column_widths(rows)
599
+ lines = rows.map.with_index do |row, idx|
600
+ line = row.each_with_index.map { |cell, i| pad(cell.to_s, widths[i]) }.join(' | ')
601
+ idx.zero? ? "#{line}\n#{separator(widths)}" : line
602
+ end
603
+ lines.join("\n")
604
+ end
605
+
606
+ def column_widths(rows)
607
+ cols = rows.first.size
608
+ (0...cols).map do |i|
609
+ rows.map { |r| r[i].to_s.length }.max
610
+ end
611
+ end
612
+
613
+ def pad(str, width)
614
+ str.ljust(width)
615
+ end
616
+
617
+ def separator(widths)
618
+ widths.map { |w| '-' * w }.join('-+-')
619
+ end
620
+
621
+ def summary_line(result)
622
+ s = result[:summary]
623
+ ok = result[:ok]
624
+ "Summary: passed=#{s[:passed]} warned=#{s[:warned]} failed=#{s[:failed]} " \
625
+ "exit_code=#{ok ? 0 : 1}"
626
+ end
627
+ end
628
+ end
629
+ end
630
+ end
631
+ end