crontinel 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 9ebf0a7c7a77045bc19c5162da2eba4785aaf2c406a8e2ed6a11b74669434391
4
+ data.tar.gz: 5050c359b5bd59db3216c2e1259a6d2af0a9dddf563b4579c76472e3bfb7844c
5
+ SHA512:
6
+ metadata.gz: e91c8ec54ceb8a9e0e4c2f2aa5e79d0221ce380ef74f1046769d7550af606a48e174d71aee02c8ad3ff26baa45d3859918c14d37da5b7b01cb1077756ac3fff2
7
+ data.tar.gz: 888df4ef9e92f808bd3ddd278113818dc02d47d4903995409fdc195cb543a5fba9570109971011c72c34b09d13de4e4f561deff2a6ef376d65bb9579142cbfa0
data/CHANGELOG.md ADDED
@@ -0,0 +1,12 @@
1
+ # Changelog
2
+
3
+ All notable changes will be documented in this file.
4
+
5
+ ## [0.1.0] - 2026-04-18
6
+
7
+ - Initial release
8
+ - Task tracking: `task_started`, `task_finished`, `task_failed`
9
+ - Worker heartbeat support
10
+ - Faraday-based HTTP client
11
+ - Minitest test suite
12
+ - Rubocop linting
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Harun R Rayhan
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,118 @@
1
+ # Crontinel Ruby
2
+
3
+ Ruby SDK for [Crontinel](https://crontinel.com) — open-source monitoring for cron jobs, background workers, and scheduled tasks.
4
+
5
+ Unlike generic uptime tools, Crontinel knows when a job started but crashed silently, when a queue worker stopped processing, or when a cron fired but did nothing.
6
+
7
+ ## Installation
8
+
9
+ Add to your `Gemfile`:
10
+
11
+ ```ruby
12
+ gem "crontinel", "~> 0.1"
13
+ ```
14
+
15
+ Or install directly:
16
+
17
+ ```bash
18
+ gem install crontinel
19
+ ```
20
+
21
+ ## Quick Start
22
+
23
+ ```ruby
24
+ require "crontinel"
25
+
26
+ # Configure with your API key
27
+ client = Crontinel.client(api_key: "your_api_key_here")
28
+
29
+ # Record a cron job starting
30
+ client.task_started(name: "send-daily-summary")
31
+
32
+ # Do your work...
33
+ result = send_daily_summary
34
+
35
+ # Record success
36
+ client.task_finished(name: "send-daily-summary", duration_ms: 520)
37
+ ```
38
+
39
+ ### With error handling
40
+
41
+ ```ruby
42
+ client = Crontinel.client(api_key: ENV["CRONTINEL_API_KEY"])
43
+
44
+ begin
45
+ client.task_started(name: "process-invoices")
46
+ process_invoices
47
+ client.task_finished(name: "process-invoices", duration_ms: 3200)
48
+ rescue => e
49
+ client.task_failed(name: "process-invoices", error: e.message, duration_ms: 150)
50
+ raise
51
+ end
52
+ ```
53
+
54
+ ### Worker heartbeat
55
+
56
+ For queue workers (Sidekiq, etc.):
57
+
58
+ ```ruby
59
+ worker = Crontinel.client(api_key: ENV["CRONTINEL_API_KEY"])
60
+
61
+ # In your worker loop or at intervals
62
+ worker.worker_heartbeat(
63
+ name: "email-worker",
64
+ status: "running",
65
+ jobs_processed: 142,
66
+ jobs_failed: 2,
67
+ memory_mb: 128
68
+ )
69
+ ```
70
+
71
+ ## Configuration
72
+
73
+ ```ruby
74
+ client = Crontinel.client do |config|
75
+ config.api_key = ENV["CRONTINEL_API_KEY"]
76
+ config.endpoint = "https://app.crontinel.com/api/v1" # optional, default works for hosted
77
+ config.timeout = 10 # seconds, default: 10
78
+ config.open_timeout = 5 # seconds, default: 5
79
+ end
80
+ ```
81
+
82
+ ## API
83
+
84
+ ### `Crontinel.client(api_key:, endpoint: nil)`
85
+
86
+ Create a new Crontinel client.
87
+
88
+ ### `#task_started(name:, output: nil)`
89
+
90
+ Record that a task began execution.
91
+
92
+ ### `#task_finished(name:, output: nil, duration_ms: nil)`
93
+
94
+ Record that a task completed successfully.
95
+
96
+ ### `#task_failed(name:, error: nil, output: nil, duration_ms: nil)`
97
+
98
+ Record that a task failed.
99
+
100
+ ### `#worker_heartbeat(name:, status:, jobs_processed: nil, jobs_failed: nil, memory_mb: nil)`
101
+
102
+ Send a heartbeat for a queue worker.
103
+
104
+ ### `#task_runs(name:, limit: 10)`
105
+
106
+ Get recent task runs. Returns an array of `Crontinel::TaskRun` objects.
107
+
108
+ ### `#health_check`
109
+
110
+ Returns `true` if Crontinel is reachable, `false` otherwise.
111
+
112
+ ## Supported Ruby Versions
113
+
114
+ Ruby 2.7+
115
+
116
+ ## License
117
+
118
+ MIT © Harun R Rayhan
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Crontinel
4
+ VERSION = "0.1.0"
5
+ end
data/lib/crontinel.rb ADDED
@@ -0,0 +1,284 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "json"
5
+ require "time"
6
+ require "crontinel/version"
7
+
8
+ module Crontinel
9
+ class Error < StandardError; end
10
+ class ConfigurationError < Error; end
11
+ class NetworkError < Error; end
12
+
13
+ # Configuration for the Crontinel client
14
+ class Config
15
+ attr_accessor :api_key, :endpoint, :timeout, :open_timeout
16
+
17
+ def initialize
18
+ @api_key = nil
19
+ @endpoint = "https://app.crontinel.com"
20
+ @timeout = 10
21
+ @open_timeout = 5
22
+ end
23
+
24
+ def validate!
25
+ raise ConfigurationError, "api_key is required" if api_key.nil? || api_key.to_s.strip.empty?
26
+ end
27
+ end
28
+
29
+ # Represents a single scheduled task / cron job run
30
+ class TaskRun
31
+ attr_reader :id, :name, :started_at, :finished_at, :status, :duration_ms, :output
32
+
33
+ def initialize(attrs = {})
34
+ @id = attrs["id"]
35
+ @name = attrs["command"] || attrs["name"]
36
+ @started_at = attrs["started_at"] ? Time.parse(attrs["started_at"]) : nil
37
+ @finished_at = attrs["finished_at"] ? Time.parse(attrs["finished_at"]) : nil
38
+ @status = attrs["last_status"] || attrs["status"] || "unknown"
39
+ @duration_ms = attrs["duration_ms"]
40
+ @output = attrs["output"]
41
+ end
42
+
43
+ def success?
44
+ @status == "completed" || @status == "success"
45
+ end
46
+
47
+ def failed?
48
+ @status == "failed"
49
+ end
50
+
51
+ def running?
52
+ @status == "running"
53
+ end
54
+ end
55
+
56
+ # Represents a worker's current state
57
+ class WorkerState
58
+ attr_reader :name, :status, :jobs_processed, :jobs_failed, :memory_mb
59
+
60
+ def initialize(attrs = {})
61
+ @name = attrs["name"]
62
+ @status = attrs["status"] || "unknown"
63
+ @jobs_processed = attrs["processed"] || attrs["jobs_processed"] || 0
64
+ @jobs_failed = attrs["failed"] || attrs["jobs_failed"] || 0
65
+ @memory_mb = attrs["memory_mb"]
66
+ end
67
+
68
+ def alive?
69
+ @status == "running" || @status == "active"
70
+ end
71
+ end
72
+
73
+ # Main Crontinel client
74
+ class Client
75
+ attr_reader :config
76
+
77
+ NETWORK_ERRORS = [
78
+ Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout,
79
+ SocketError, Errno::ECONNREFUSED, Errno::ECONNRESET
80
+ ].freeze
81
+
82
+ def initialize(api_key: nil, endpoint: nil)
83
+ @config = Config.new
84
+ @config.api_key = api_key if api_key
85
+ @config.endpoint = endpoint if endpoint
86
+ yield @config if block_given?
87
+ @config.validate!
88
+ end
89
+
90
+ # ── Event reporting (uses MCP notify/*) ───────────────────
91
+
92
+ # Record a scheduled task completing successfully
93
+ def task_finished(name:, output: nil, duration_ms: nil)
94
+ mcp_call("notify/schedule_run", {
95
+ command: name,
96
+ exit_code: 0,
97
+ duration_ms: duration_ms,
98
+ output: output,
99
+ ran_at: Time.now.utc.iso8601(3),
100
+ app: "ruby",
101
+ })
102
+ end
103
+
104
+ # Record a scheduled task failing
105
+ def task_failed(name:, error: nil, output: nil, duration_ms: nil)
106
+ mcp_call("notify/schedule_run", {
107
+ command: name,
108
+ exit_code: error ? 1 : 1,
109
+ duration_ms: duration_ms,
110
+ output: output || error,
111
+ ran_at: Time.now.utc.iso8601(3),
112
+ app: "ruby",
113
+ })
114
+ end
115
+
116
+ # Record a queue worker heartbeat / processed batch
117
+ def worker_heartbeat(name:, status: "running", jobs_processed: nil, jobs_failed: nil, memory_mb: nil)
118
+ mcp_call("notify/queue_processed", {
119
+ queue: name,
120
+ processed: jobs_processed || 0,
121
+ failed: jobs_failed || 0,
122
+ ran_at: Time.now.utc.iso8601(3),
123
+ app: "ruby",
124
+ }.compact)
125
+ end
126
+
127
+ # Send a custom event
128
+ def event(key:, message:, state: "info", metadata: {})
129
+ mcp_call("notify/event", {
130
+ key: key,
131
+ message: message,
132
+ state: state,
133
+ metadata: metadata,
134
+ ran_at: Time.now.utc.iso8601(3),
135
+ app: "ruby",
136
+ })
137
+ end
138
+
139
+ # Send a Horizon snapshot
140
+ def horizon_snapshot(supervisors:, failed_jobs_per_minute: 0, paused: false)
141
+ mcp_call("notify/horizon_snapshot", {
142
+ supervisors: supervisors,
143
+ failed_jobs_per_minute: failed_jobs_per_minute,
144
+ paused: paused,
145
+ ran_at: Time.now.utc.iso8601(3),
146
+ app: "ruby",
147
+ })
148
+ end
149
+
150
+ # ── Query methods (uses MCP tools/call) ───────────────────
151
+
152
+ # List all scheduled jobs
153
+ def scheduled_jobs
154
+ result = mcp_call("tools/call", { name: "list_scheduled_jobs", arguments: {} })
155
+ (result["content"] || []).flat_map { |c| JSON.parse(c["text"]) rescue [] }.map { |r| TaskRun.new(r) }
156
+ end
157
+
158
+ # Get status for a specific cron command
159
+ def cron_status(command:)
160
+ result = mcp_call("tools/call", { name: "get_cron_status", arguments: { command: command } })
161
+ (result["content"] || []).flat_map { |c| JSON.parse(c["text"]) rescue [] }.first
162
+ end
163
+
164
+ # List recent alerts
165
+ def recent_alerts(hours: 24)
166
+ result = mcp_call("tools/call", { name: "list_recent_alerts", arguments: { hours: hours } })
167
+ (result["content"] || []).flat_map { |c| JSON.parse(c["text"]) rescue [] }
168
+ end
169
+
170
+ # Check if Crontinel is reachable (direct HTTP, not MCP)
171
+ def health_check
172
+ get("/health")
173
+ true
174
+ rescue NetworkError, JSON::ParserError
175
+ false
176
+ end
177
+
178
+ # ── Backward-compatible aliases ───────────────────────────
179
+ def task_started(name:, output: nil)
180
+ task_finished(name: name, output: output)
181
+ end
182
+
183
+ def task_runs(name:, limit: 10)
184
+ scheduled_jobs.select { |r| r.name&.include?(name) }.first(limit)
185
+ end
186
+
187
+ def worker_state(name:)
188
+ WorkerState.new("name" => name, "status" => "unknown")
189
+ end
190
+
191
+ private
192
+
193
+ # Unified MCP JSON-RPC call against /api/mcp
194
+ def mcp_call(method, params = {})
195
+ uri = URI("#{@config.endpoint}/api/mcp")
196
+
197
+ body = {
198
+ jsonrpc: "2.0",
199
+ id: (Time.now.to_f * 1000).to_i,
200
+ method: method,
201
+ params: params,
202
+ }
203
+
204
+ req = Net::HTTP::Post.new(uri)
205
+ req["Authorization"] = "Bearer #{@config.api_key}"
206
+ req["Content-Type"] = "application/json"
207
+ req.body = JSON.generate(body)
208
+
209
+ http = Net::HTTP.new(uri.host, uri.port)
210
+ http.use_ssl = uri.scheme == "https"
211
+ http.open_timeout = @config.open_timeout
212
+ http.read_timeout = @config.timeout
213
+
214
+ response = http.start { http.request(req) }
215
+
216
+ case response
217
+ when Net::HTTPSuccess
218
+ data = JSON.parse(response.body)
219
+ if data["error"]
220
+ raise NetworkError, "Crontinel RPC error: #{data["error"]["code"]}: #{data["error"]["message"]}"
221
+ end
222
+ data["result"]
223
+ else
224
+ raise NetworkError, "Crontinel API error: #{response.code} #{response.message}"
225
+ end
226
+ rescue *NETWORK_ERRORS => e
227
+ raise NetworkError, "Failed to connect to Crontinel: #{e.message}"
228
+ end
229
+
230
+ # Direct HTTP GET (for /health)
231
+ def get(path)
232
+ uri = URI("#{@config.endpoint}#{path}")
233
+ req = Net::HTTP::Get.new(uri)
234
+ req["Authorization"] = "Bearer #{@config.api_key}"
235
+
236
+ http = Net::HTTP.new(uri.host, uri.port)
237
+ http.use_ssl = uri.scheme == "https"
238
+ http.open_timeout = @config.open_timeout
239
+ http.read_timeout = @config.timeout
240
+
241
+ response = http.start { http.request(req) }
242
+
243
+ case response
244
+ when Net::HTTPSuccess
245
+ begin
246
+ JSON.parse(response.body)
247
+ rescue JSON::ParserError
248
+ response.body
249
+ end
250
+ else
251
+ raise NetworkError, "Crontinel API error: #{response.code} #{response.message}"
252
+ end
253
+ rescue *NETWORK_ERRORS => e
254
+ raise NetworkError, "Failed to connect to Crontinel: #{e.message}"
255
+ end
256
+ end
257
+
258
+ class << self
259
+ def client(api_key: nil, endpoint: nil, &block)
260
+ Client.new(api_key: api_key, endpoint: endpoint, &block)
261
+ end
262
+
263
+ # Rails-style configure block (used by crontinel-rails railtie)
264
+ def configure
265
+ yield config
266
+ config
267
+ end
268
+ alias_method :setup, :configure
269
+
270
+ def config
271
+ @config ||= Configuration.new
272
+ end
273
+ end
274
+
275
+ # Configuration object for module-level setup
276
+ class Configuration
277
+ attr_accessor :api_key, :endpoint
278
+
279
+ def initialize
280
+ @api_key = ENV.fetch("CRONTINEL_API_KEY", nil)
281
+ @endpoint = ENV.fetch("CRONTINEL_API_URL", "https://app.crontinel.com")
282
+ end
283
+ end
284
+ end
metadata ADDED
@@ -0,0 +1,55 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: crontinel
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Harun R Rayhan
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-06-30 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description: |
14
+ Crontinel monitors your cron jobs, background workers, and scheduled tasks.
15
+ Unlike generic uptime tools, Crontinel knows when a job started but crashed silently,
16
+ when a queue worker stopped processing, or when a cron fired but did nothing.
17
+ email:
18
+ - me@harunray.com
19
+ executables: []
20
+ extensions: []
21
+ extra_rdoc_files: []
22
+ files:
23
+ - CHANGELOG.md
24
+ - LICENSE.txt
25
+ - README.md
26
+ - lib/crontinel.rb
27
+ - lib/crontinel/version.rb
28
+ homepage: https://crontinel.com
29
+ licenses:
30
+ - MIT
31
+ metadata:
32
+ homepage_uri: https://crontinel.com
33
+ source_code_uri: https://github.com/crontinel/ruby
34
+ changelog_uri: https://github.com/crontinel/ruby/blob/main/CHANGELOG.md
35
+ bug_tracker_uri: https://github.com/crontinel/ruby/issues
36
+ post_install_message:
37
+ rdoc_options: []
38
+ require_paths:
39
+ - lib
40
+ required_ruby_version: !ruby/object:Gem::Requirement
41
+ requirements:
42
+ - - ">="
43
+ - !ruby/object:Gem::Version
44
+ version: '2.7'
45
+ required_rubygems_version: !ruby/object:Gem::Requirement
46
+ requirements:
47
+ - - ">="
48
+ - !ruby/object:Gem::Version
49
+ version: '0'
50
+ requirements: []
51
+ rubygems_version: 3.0.3.1
52
+ signing_key:
53
+ specification_version: 4
54
+ summary: Crontinel monitoring SDK for Ruby — track cron jobs and background workers
55
+ test_files: []