chronos-ruby 0.3.0.pre.1 → 0.5.0.pre.1

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 (40) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +23 -0
  3. data/README.md +55 -17
  4. data/contracts/event-v1.schema.json +25 -11
  5. data/contracts/rack-context-v1.schema.json +43 -0
  6. data/docs/adr/ADR-012-rack-context-isolation.md +27 -0
  7. data/docs/adr/ADR-013-legacy-rails-notifications.md +27 -0
  8. data/docs/architecture.md +17 -3
  9. data/docs/compatibility.md +6 -6
  10. data/docs/configuration.md +13 -2
  11. data/docs/data-collected.md +15 -2
  12. data/docs/modules/rack-context.md +59 -0
  13. data/docs/modules/rails-legacy.md +60 -0
  14. data/docs/modules/telemetry-events.md +9 -0
  15. data/docs/performance.md +28 -3
  16. data/docs/privacy-lgpd.md +13 -3
  17. data/docs/troubleshooting.md +18 -2
  18. data/lib/chronos/adapters/thread_local_context_store.rb +52 -0
  19. data/lib/chronos/agent.rb +97 -6
  20. data/lib/chronos/application/capture_exception.rb +2 -2
  21. data/lib/chronos/application/capture_telemetry.rb +37 -0
  22. data/lib/chronos/application/remote_configuration.rb +1 -1
  23. data/lib/chronos/configuration/snapshot.rb +53 -0
  24. data/lib/chronos/configuration/validation.rb +122 -0
  25. data/lib/chronos/configuration.rb +22 -134
  26. data/lib/chronos/core/breadcrumb.rb +126 -0
  27. data/lib/chronos/core/telemetry_event.rb +106 -0
  28. data/lib/chronos/integrations/rack/middleware.rb +156 -0
  29. data/lib/chronos/integrations/rack.rb +12 -0
  30. data/lib/chronos/integrations.rb +10 -0
  31. data/lib/chronos/ports/context_store.rb +22 -0
  32. data/lib/chronos/rails/installer.rb +70 -0
  33. data/lib/chronos/rails/notifications_subscriber.rb +203 -0
  34. data/lib/chronos/rails/railtie.rb +21 -0
  35. data/lib/chronos/rails.rb +16 -0
  36. data/lib/chronos/version.rb +1 -1
  37. data/lib/chronos.rb +46 -1
  38. data/lib/generators/chronos/install/install_generator.rb +25 -0
  39. data/lib/generators/chronos/install/templates/chronos.rb +20 -0
  40. metadata +23 -1
@@ -0,0 +1,70 @@
1
+ module Chronos
2
+ module Rails
3
+ # Installs Rails middleware and subscribers exactly once per application.
4
+ #
5
+ # @responsibility Apply feature-detected integration hooks after Rails initializers load.
6
+ # @motivation Centralize version-neutral installation outside Railtie DSL code.
7
+ # @limits It does not configure credentials or depend on private Rails APIs.
8
+ # @collaborators Rails application middleware, NotificationsSubscriber, and Chronos facade.
9
+ # @thread_safety A mutex protects the per-application installation registry.
10
+ # @compatibility Rails 4.2 through Rails 5.2 without Zeitwerk.
11
+ # @example
12
+ # Chronos::Rails::Installer.new.install(Rails.application)
13
+ # @errors Missing optional Rails capabilities return false without affecting boot.
14
+ # @performance Installation is one-time; request work is delegated to bounded integrations.
15
+ class Installer
16
+ @mutex = Mutex.new
17
+ @applications = {}
18
+
19
+ class << self
20
+ attr_reader :mutex, :applications
21
+ end
22
+
23
+ def initialize(notifier = Chronos, subscriber = nil)
24
+ @notifier = notifier
25
+ @subscriber = subscriber || NotificationsSubscriber.new(notifier)
26
+ end
27
+
28
+ def install(application)
29
+ options = @notifier.rails_integration_options(environment, console?)
30
+ return false unless options[:enabled]
31
+
32
+ self.class.mutex.synchronize do
33
+ return false if self.class.applications[application.object_id]
34
+
35
+ install_middleware(application, options)
36
+ @subscriber.install
37
+ self.class.applications[application.object_id] = true
38
+ end
39
+ true
40
+ rescue StandardError
41
+ false
42
+ end
43
+
44
+ def rails_version
45
+ defined?(::Rails) && ::Rails.respond_to?(:version) ? ::Rails.version.to_s : "unknown"
46
+ end
47
+
48
+ private
49
+
50
+ def install_middleware(application, options)
51
+ middleware = application.config.middleware
52
+ return false unless middleware.respond_to?(:use)
53
+
54
+ middleware.use(
55
+ Chronos::Integrations::Rack::Middleware,
56
+ :include_user_agent => options[:include_user_agent]
57
+ )
58
+ true
59
+ end
60
+
61
+ def environment
62
+ defined?(::Rails) && ::Rails.respond_to?(:env) ? ::Rails.env.to_s : nil
63
+ end
64
+
65
+ def console?
66
+ defined?(::Rails::Console)
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,203 @@
1
+ module Chronos
2
+ module Rails
3
+ # Converts public ActiveSupport notifications into bounded Chronos telemetry.
4
+ #
5
+ # @responsibility Subscribe once and normalize controller, view, SQL, mailer, job, and cache events.
6
+ # @motivation Support Rails 4.2 and 5.2 through feature detection and public notification APIs.
7
+ # @limits It never sends SQL text, bind values, cache keys, mail bodies, or job arguments.
8
+ # @collaborators ActiveSupport::Notifications and the Chronos facade.
9
+ # @thread_safety Subscription registry is mutex-protected; callbacks own their state.
10
+ # @compatibility ActiveSupport notification argument shapes from Rails 4.2 through 5.2.
11
+ # @example
12
+ # Chronos::Rails::NotificationsSubscriber.new.install
13
+ # @errors Subscriber failures are contained and never escape into Rails.
14
+ # @performance Each notification builds a small allowlisted hash and queues asynchronously.
15
+ class NotificationsSubscriber
16
+ EVENTS = %w(
17
+ process_action.action_controller render_template.action_view sql.active_record
18
+ deliver.action_mailer perform.active_job cache_read.active_support
19
+ cache_write.active_support cache_fetch_hit.active_support
20
+ ).freeze
21
+
22
+ @mutex = Mutex.new
23
+ @installed_buses = {}
24
+
25
+ class << self
26
+ attr_reader :mutex, :installed_buses
27
+ end
28
+
29
+ def initialize(notifier = Chronos, notifications = nil)
30
+ @notifier = notifier
31
+ @notifications = notifications || active_support_notifications
32
+ end
33
+
34
+ def install
35
+ return false unless @notifications && @notifications.respond_to?(:subscribe)
36
+
37
+ self.class.mutex.synchronize do
38
+ return false if self.class.installed_buses[@notifications.object_id]
39
+
40
+ event_names.each { |name| subscribe(name) }
41
+ self.class.installed_buses[@notifications.object_id] = true
42
+ end
43
+ true
44
+ rescue StandardError
45
+ false
46
+ end
47
+
48
+ def handle(name, arguments)
49
+ event = notification_event(name, arguments)
50
+ dispatch(name, event[:payload], event[:duration_ms])
51
+ rescue StandardError
52
+ false
53
+ end
54
+
55
+ private
56
+
57
+ def active_support_notifications
58
+ return nil unless defined?(::ActiveSupport::Notifications)
59
+
60
+ ::ActiveSupport::Notifications
61
+ end
62
+
63
+ def subscribe(name)
64
+ @notifications.subscribe(name) { |*arguments| handle(name, arguments) }
65
+ end
66
+
67
+ def event_names
68
+ return EVENTS if defined?(::ActiveJob)
69
+
70
+ EVENTS.reject { |name| name == "perform.active_job" }
71
+ end
72
+
73
+ def notification_event(name, arguments)
74
+ candidate = arguments.first
75
+ if arguments.length == 1 && candidate.respond_to?(:payload)
76
+ return {:payload => hash(candidate.payload), :duration_ms => candidate.duration.to_f}
77
+ end
78
+
79
+ started_at = arguments[1]
80
+ finished_at = arguments[2]
81
+ {
82
+ :payload => hash(arguments[4]),
83
+ :duration_ms => duration_ms(started_at, finished_at),
84
+ :name => name
85
+ }
86
+ end
87
+
88
+ def dispatch(name, payload, duration)
89
+ case name
90
+ when "process_action.action_controller" then process_action(payload, duration)
91
+ when "render_template.action_view" then render_template(payload, duration)
92
+ when "sql.active_record" then sql(payload, duration)
93
+ when "deliver.action_mailer" then mailer(payload, duration)
94
+ when "perform.active_job" then active_job(payload, duration)
95
+ else cache(name, payload, duration)
96
+ end
97
+ end
98
+
99
+ def process_action(payload, duration)
100
+ capture_controller_exception(payload)
101
+ data = {
102
+ "kind" => "controller", "controller" => value(payload, :controller),
103
+ "action" => value(payload, :action), "status" => value(payload, :status).to_i,
104
+ "method" => value(payload, :method), "path" => query_free_path(value(payload, :path)),
105
+ "route" => route(payload),
106
+ "duration_ms" => duration, "parameters" => hash(value(payload, :params))
107
+ }
108
+ @notifier.record_event("request", data)
109
+ end
110
+
111
+ def render_template(payload, duration)
112
+ data = {
113
+ "kind" => "view", "template" => safe_basename(value(payload, :identifier)),
114
+ "duration_ms" => duration
115
+ }
116
+ @notifier.record_event("request", data)
117
+ end
118
+
119
+ def sql(payload, duration)
120
+ data = {
121
+ "name" => value(payload, :name).to_s, "cached" => value(payload, :cached) == true,
122
+ "duration_ms" => duration
123
+ }
124
+ @notifier.record_event("query", data)
125
+ end
126
+
127
+ def mailer(payload, duration)
128
+ data = {
129
+ "kind" => "mailer", "mailer" => value(payload, :mailer).to_s,
130
+ "action" => value(payload, :action).to_s, "duration_ms" => duration
131
+ }
132
+ @notifier.record_event("job", data)
133
+ end
134
+
135
+ def active_job(payload, duration)
136
+ job = value(payload, :job)
137
+ data = {
138
+ "kind" => "active_job", "class" => safe_class_name(job),
139
+ "queue" => safe_job_value(job, :queue_name), "duration_ms" => duration
140
+ }
141
+ @notifier.record_event("job", data)
142
+ end
143
+
144
+ def cache(name, payload, duration)
145
+ data = {
146
+ "operation" => name.split(".").first, "store" => value(payload, :store).to_s,
147
+ "hit" => value(payload, :hit), "duration_ms" => duration
148
+ }
149
+ @notifier.record_event("cache", data)
150
+ end
151
+
152
+ def capture_controller_exception(payload)
153
+ exception = value(payload, :exception_object)
154
+ details = value(payload, :exception)
155
+ exception ||= RuntimeError.new(Array(details).last.to_s) if details
156
+ @notifier.notify_once(exception, :parameters => hash(value(payload, :params))) if exception
157
+ end
158
+
159
+ def value(payload, key)
160
+ payload.key?(key) ? payload[key] : payload[key.to_s]
161
+ end
162
+
163
+ def hash(value)
164
+ value.is_a?(Hash) ? value : {}
165
+ end
166
+
167
+ def duration_ms(started_at, finished_at)
168
+ return 0.0 unless started_at && finished_at
169
+
170
+ ((finished_at.to_f - started_at.to_f) * 1000.0).round(3)
171
+ end
172
+
173
+ def query_free_path(path)
174
+ path.to_s.split("?", 2).first
175
+ end
176
+
177
+ def route(payload)
178
+ explicit = value(payload, :route)
179
+ return explicit.to_s unless explicit.to_s.empty?
180
+
181
+ [value(payload, :controller), value(payload, :action)].compact.join("#")
182
+ end
183
+
184
+ def safe_basename(identifier)
185
+ File.basename(identifier.to_s)
186
+ rescue StandardError
187
+ "<template>"
188
+ end
189
+
190
+ def safe_class_name(object)
191
+ object ? object.class.name.to_s : ""
192
+ rescue StandardError
193
+ "Object"
194
+ end
195
+
196
+ def safe_job_value(job, method_name)
197
+ job.respond_to?(method_name) ? job.public_send(method_name).to_s : ""
198
+ rescue StandardError
199
+ ""
200
+ end
201
+ end
202
+ end
203
+ end
@@ -0,0 +1,21 @@
1
+ module Chronos
2
+ module Rails
3
+ # Hooks Chronos installation into the public Rails initializer lifecycle.
4
+ #
5
+ # @responsibility Install integrations after application configuration initializers load.
6
+ # @motivation Ensure generated Chronos configuration exists before hooks are activated.
7
+ # @limits It performs no autoloading and does not use private Rails initialization APIs.
8
+ # @collaborators Rails::Railtie and Chronos::Rails::Installer.
9
+ # @thread_safety Rails invokes the initializer during serialized application boot.
10
+ # @compatibility Rails 4.2 through Rails 5.2; no Zeitwerk requirement.
11
+ # @example
12
+ # require "chronos/rails"
13
+ # @errors Installer contains optional integration failures and allows Rails to boot.
14
+ # @performance Adds only one initializer and one-time installation work.
15
+ class Railtie < ::Rails::Railtie
16
+ initializer "chronos.install", :after => :load_config_initializers do |application|
17
+ Chronos::Rails::Installer.new.install(application)
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,16 @@
1
+ require "chronos"
2
+ require "chronos/rails/notifications_subscriber"
3
+ require "chronos/rails/installer"
4
+
5
+ require "chronos/rails/railtie" if defined?(::Rails::Railtie)
6
+
7
+ module Chronos
8
+ # Legacy Rails integration loaded explicitly after Rails is available.
9
+ #
10
+ # @responsibility Namespace Railtie, installer, and notification subscribers.
11
+ # @motivation Keep Rails and ActiveSupport out of the framework-independent core.
12
+ # @limits Version 0.5 targets public APIs present in Rails 4.2 and 5.2.
13
+ # @thread_safety Installation and subscriptions are protected against duplication.
14
+ # @compatibility Rails 4.2 through Rails 5.2 with their supported legacy Rubies.
15
+ module Rails; end
16
+ end
@@ -1,4 +1,4 @@
1
1
  module Chronos
2
2
  # Current version of the legacy Chronos Ruby agent.
3
- VERSION = "0.3.0.pre.1".freeze
3
+ VERSION = "0.5.0.pre.1".freeze
4
4
  end
data/lib/chronos.rb CHANGED
@@ -15,24 +15,32 @@ require "chronos/core/sensitive_value_filter"
15
15
  require "chronos/core/sanitizer"
16
16
  require "chronos/core/safe_serializer"
17
17
  require "chronos/core/payload_serializer"
18
+ require "chronos/core/telemetry_event"
18
19
  require "chronos/ports/transport"
20
+ require "chronos/ports/context_store"
19
21
  require "chronos/internal/safe_logger"
20
22
  require "chronos/internal/bounded_queue"
21
23
  require "chronos/internal/memory_backlog"
22
24
  require "chronos/internal/worker_pool"
23
25
  require "chronos/adapters/net_http_transport"
26
+ require "chronos/adapters/thread_local_context_store"
27
+ require "chronos/core/breadcrumb"
24
28
  require "chronos/application/retry_policy"
25
29
  require "chronos/application/circuit_breaker"
26
30
  require "chronos/application/remote_configuration"
27
31
  require "chronos/application/delivery_pipeline"
28
32
  require "chronos/application/capture_exception"
33
+ require "chronos/application/capture_telemetry"
29
34
  require "chronos/agent"
35
+ require "chronos/integrations"
36
+ require "chronos/integrations/rack"
37
+ require "chronos/integrations/rack/middleware"
30
38
 
31
39
  # Framework-independent public facade for the Chronos Ruby agent.
32
40
  #
33
41
  # @responsibility Configure the agent and expose its small lifecycle API.
34
42
  # @motivation Give applications a stable entry point while internals evolve.
35
- # @limits Version 0.3 captures Ruby exceptions manually; integrations arrive later.
43
+ # @limits Rails integration remains optional and must be loaded through chronos/rails.
36
44
  # @collaborators Configuration and Agent.
37
45
  # @thread_safety Agent replacement and lookup are protected by a mutex.
38
46
  # @compatibility Ruby 2.2.10 through Ruby 2.6.
@@ -75,6 +83,41 @@ module Chronos
75
83
  false
76
84
  end
77
85
 
86
+ def with_context(context = {})
87
+ agent = current_agent
88
+ return yield unless agent
89
+
90
+ agent.with_context(context) { yield }
91
+ end
92
+
93
+ def add_breadcrumb(attributes = {})
94
+ agent = current_agent
95
+ agent ? agent.add_breadcrumb(attributes) : false
96
+ rescue StandardError
97
+ false
98
+ end
99
+
100
+ def record_event(event_type, payload = {}, context = {})
101
+ agent = current_agent
102
+ agent ? agent.record_event(event_type, payload, context) : false
103
+ rescue StandardError
104
+ false
105
+ end
106
+
107
+ def notify_once(exception, context = {})
108
+ agent = current_agent
109
+ agent ? agent.notify_once(exception, context) : false
110
+ rescue StandardError
111
+ false
112
+ end
113
+
114
+ def rails_integration_options(environment = nil, console = false)
115
+ agent = current_agent
116
+ agent ? agent.rails_integration_options(environment, console) : {:enabled => false}
117
+ rescue StandardError
118
+ {:enabled => false}
119
+ end
120
+
78
121
  def configured?
79
122
  !current_agent.nil?
80
123
  end
@@ -104,3 +147,5 @@ module Chronos
104
147
  end
105
148
  end
106
149
  end
150
+
151
+ require "chronos/rails" if defined?(::Rails::Railtie)
@@ -0,0 +1,25 @@
1
+ require "rails/generators"
2
+
3
+ module Chronos
4
+ module Generators
5
+ # Generates the explicit Chronos initializer for legacy Rails applications.
6
+ #
7
+ # @responsibility Copy one documented initializer into config/initializers.
8
+ # @motivation Keep credentials explicit and avoid automatic environment scanning.
9
+ # @limits It does not modify routes, application classes, or Gemfiles.
10
+ # @collaborators Rails::Generators::Base file-copy API.
11
+ # @thread_safety Generator instances are used serially by the Rails command.
12
+ # @compatibility Rails generator APIs available in Rails 4.2 through Rails 5.2.
13
+ # @example
14
+ # rails generate chronos:install
15
+ # @errors Existing-file conflict handling is delegated to Rails generators.
16
+ # @performance Copies one small text file.
17
+ class InstallGenerator < ::Rails::Generators::Base
18
+ source_root File.expand_path("templates", __dir__)
19
+
20
+ def create_initializer
21
+ copy_file "chronos.rb", "config/initializers/chronos.rb"
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,20 @@
1
+ require "chronos/rails"
2
+
3
+ Chronos.configure do |config|
4
+ # Chronos reads only the environment variables explicitly selected here.
5
+ config.project_id = ENV["CHRONOS_PROJECT_ID"]
6
+ config.project_key = ENV["CHRONOS_PROJECT_KEY"]
7
+ config.host = ENV["CHRONOS_HOST"]
8
+ config.environment = Rails.env.to_s
9
+ config.service_name = ENV["CHRONOS_SERVICE_NAME"]
10
+ config.app_version = ENV["CHRONOS_APP_VERSION"]
11
+ config.logger = Rails.logger if Rails.respond_to?(:logger)
12
+
13
+ # Safe legacy defaults: test and console integrations remain disabled.
14
+ config.rails_capture_in_test = false
15
+ config.rails_capture_in_console = false
16
+ config.rails_capture_user_agent = false
17
+ end
18
+
19
+ # Safe when the Railtie already ran or will run later; installation is idempotent.
20
+ Chronos::Rails::Installer.new.install(Rails.application)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: chronos-ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0.pre.1
4
+ version: 0.5.0.pre.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Antonio Jefferson
@@ -87,6 +87,7 @@ files:
87
87
  - README.md
88
88
  - SECURITY.md
89
89
  - contracts/event-v1.schema.json
90
+ - contracts/rack-context-v1.schema.json
90
91
  - docs/adr/ADR-001-core-and-integrations.md
91
92
  - docs/adr/ADR-002-version-lines.md
92
93
  - docs/adr/ADR-003-net-http-legacy-transport.md
@@ -94,6 +95,8 @@ files:
94
95
  - docs/adr/ADR-005-sanitize-before-backlog.md
95
96
  - docs/adr/ADR-006-versioned-event-contract.md
96
97
  - docs/adr/ADR-011-bounded-resilience-and-remote-control.md
98
+ - docs/adr/ADR-012-rack-context-isolation.md
99
+ - docs/adr/ADR-013-legacy-rails-notifications.md
97
100
  - docs/architecture.md
98
101
  - docs/compatibility.md
99
102
  - docs/configuration.md
@@ -102,10 +105,13 @@ files:
102
105
  - docs/modules/async-queue.md
103
106
  - docs/modules/configuration.md
104
107
  - docs/modules/notice-pipeline.md
108
+ - docs/modules/rack-context.md
109
+ - docs/modules/rails-legacy.md
105
110
  - docs/modules/remote-configuration.md
106
111
  - docs/modules/retry-backlog.md
107
112
  - docs/modules/sanitization.md
108
113
  - docs/modules/serialization.md
114
+ - docs/modules/telemetry-events.md
109
115
  - docs/modules/transport.md
110
116
  - docs/performance.md
111
117
  - docs/privacy-lgpd.md
@@ -113,16 +119,21 @@ files:
113
119
  - lib/chronos.rb
114
120
  - lib/chronos/adapters.rb
115
121
  - lib/chronos/adapters/net_http_transport.rb
122
+ - lib/chronos/adapters/thread_local_context_store.rb
116
123
  - lib/chronos/agent.rb
117
124
  - lib/chronos/application.rb
118
125
  - lib/chronos/application/capture_exception.rb
126
+ - lib/chronos/application/capture_telemetry.rb
119
127
  - lib/chronos/application/circuit_breaker.rb
120
128
  - lib/chronos/application/delivery_pipeline.rb
121
129
  - lib/chronos/application/remote_configuration.rb
122
130
  - lib/chronos/application/retry_policy.rb
123
131
  - lib/chronos/configuration.rb
132
+ - lib/chronos/configuration/snapshot.rb
133
+ - lib/chronos/configuration/validation.rb
124
134
  - lib/chronos/core.rb
125
135
  - lib/chronos/core/backtrace_parser.rb
136
+ - lib/chronos/core/breadcrumb.rb
126
137
  - lib/chronos/core/exception_cause_collector.rb
127
138
  - lib/chronos/core/notice.rb
128
139
  - lib/chronos/core/notice_builder.rb
@@ -131,17 +142,28 @@ files:
131
142
  - lib/chronos/core/safe_serializer.rb
132
143
  - lib/chronos/core/sanitizer.rb
133
144
  - lib/chronos/core/sensitive_value_filter.rb
145
+ - lib/chronos/core/telemetry_event.rb
134
146
  - lib/chronos/errors.rb
147
+ - lib/chronos/integrations.rb
148
+ - lib/chronos/integrations/rack.rb
149
+ - lib/chronos/integrations/rack/middleware.rb
135
150
  - lib/chronos/internal.rb
136
151
  - lib/chronos/internal/bounded_queue.rb
137
152
  - lib/chronos/internal/memory_backlog.rb
138
153
  - lib/chronos/internal/safe_logger.rb
139
154
  - lib/chronos/internal/worker_pool.rb
140
155
  - lib/chronos/ports.rb
156
+ - lib/chronos/ports/context_store.rb
141
157
  - lib/chronos/ports/transport.rb
158
+ - lib/chronos/rails.rb
159
+ - lib/chronos/rails/installer.rb
160
+ - lib/chronos/rails/notifications_subscriber.rb
161
+ - lib/chronos/rails/railtie.rb
142
162
  - lib/chronos/ruby.rb
143
163
  - lib/chronos/ruby/version.rb
144
164
  - lib/chronos/version.rb
165
+ - lib/generators/chronos/install/install_generator.rb
166
+ - lib/generators/chronos/install/templates/chronos.rb
145
167
  homepage: https://github.com/antoniojefferson/chronos-ruby
146
168
  licenses:
147
169
  - MIT