sillydev 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.
@@ -0,0 +1,227 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "version"
4
+ require_relative "monitoring/client"
5
+
6
+ module SillyDev
7
+ # Opt-in application monitoring for Silly Development.
8
+ #
9
+ # Nothing is sent, no thread is started, no hook is installed and no environment variable is read
10
+ # until you call SillyDev::Monitoring.init with a monitoring key (or with SILLYDEV_MONITORING_KEY
11
+ # set):
12
+ #
13
+ # require "sillydev/monitoring"
14
+ #
15
+ # SillyDev::Monitoring.init # reads SILLYDEV_MONITORING_KEY; does nothing without a key
16
+ # SillyDev::Monitoring.increment("orders.created", tags: { plan: "free" })
17
+ #
18
+ # Until then every method here does nothing and never raises. require "sillydev" does not load
19
+ # this file. Docs: https://sillydev.co.uk/docs/monitoring
20
+ module Monitoring
21
+ KEY_RE = /\Asdevm_([1-9][0-9]*)_/
22
+
23
+ @current = nil
24
+ @init_lock = Mutex.new
25
+ @collectors = []
26
+ @hooks_installed = false
27
+
28
+ class << self
29
+ # Called with the client once per flush window, on its thread (the Discord integration adds
30
+ # one for its gauges).
31
+ attr_reader :collectors
32
+
33
+ # "sdevm_<N>_..." gives "https://ingest<N>.sillydev.co.uk" (nil for anything else).
34
+ def endpoint_for_key(key)
35
+ match = KEY_RE.match(key.to_s)
36
+ match ? "https://ingest#{match[1]}.sillydev.co.uk" : nil
37
+ end
38
+
39
+ # Starts monitoring. Without a key (argument or SILLYDEV_MONITORING_KEY) nothing happens.
40
+ #
41
+ # Options left as nil fall back to SILLYDEV_MONITORING_KEY, SILLYDEV_MONITORING_URL,
42
+ # SILLYDEV_MONITORING_SERVICE, SILLYDEV_MONITORING_ENVIRONMENT and SILLYDEV_MONITORING_RELEASE.
43
+ # SILLYDEV_MONITORING_DISABLED=1 (or enabled: false) turns it off. Calling init again shuts the
44
+ # previous client down and starts a new one. Returns the client; client.enabled? says whether
45
+ # it is sending.
46
+ def init(key = nil, endpoint: nil, service: nil, environment: nil, release: nil, runtime_metrics: true,
47
+ capture_unhandled: true, sample_rate: 1.0, before_send: nil, flush_interval: 10.0,
48
+ max_buffer_bytes: 1_000_000, slow_threshold_ms: 1000, logger: nil, debug: false, enabled: true)
49
+ @init_lock.synchronize do
50
+ previous = @current
51
+ @current = nil
52
+ previous&.shutdown(2.0)
53
+
54
+ key ||= env("SILLYDEV_MONITORING_KEY")
55
+ disabled = %w[1 true yes on].include?(env("SILLYDEV_MONITORING_DISABLED").to_s.downcase)
56
+ endpoint ||= env("SILLYDEV_MONITORING_URL")
57
+ active = enabled && !disabled && key ? true : false
58
+ if active && endpoint.nil?
59
+ endpoint = endpoint_for_key(key)
60
+ if endpoint.nil?
61
+ warn "sillydev.monitoring: the key is not a server key (sdevm_<n>_...) and no endpoint was given; " \
62
+ "monitoring is off"
63
+ active = false
64
+ end
65
+ end
66
+
67
+ client = Client.new(
68
+ key, endpoint: endpoint, service: service || env("SILLYDEV_MONITORING_SERVICE"),
69
+ environment: environment || env("SILLYDEV_MONITORING_ENVIRONMENT"),
70
+ release: release || env("SILLYDEV_MONITORING_RELEASE"), runtime_metrics: runtime_metrics,
71
+ capture_unhandled: capture_unhandled, sample_rate: sample_rate, before_send: before_send,
72
+ flush_interval: flush_interval, max_buffer_bytes: max_buffer_bytes,
73
+ slow_threshold_ms: slow_threshold_ms, logger: logger, debug: debug, active: active
74
+ )
75
+ @current = client
76
+ if client.active?
77
+ install_process_hooks
78
+ client.start
79
+ end
80
+ client
81
+ end
82
+ end
83
+
84
+ # The client made by the last init, if any.
85
+ def client
86
+ @current
87
+ end
88
+
89
+ # True while a client with a key is running and has not been stopped.
90
+ def enabled?
91
+ client = @current
92
+ client ? client.enabled? : false
93
+ end
94
+
95
+ # Adds value to a counter.
96
+ def increment(name, value = 1, tags: nil)
97
+ @current&.increment(name, value, tags)
98
+ nil
99
+ end
100
+
101
+ # Records the latest value of a gauge.
102
+ def gauge(name, value, tags: nil)
103
+ @current&.gauge(name, value, tags)
104
+ nil
105
+ end
106
+
107
+ # Records a duration in milliseconds in a histogram.
108
+ def timing(name, ms, tags: nil)
109
+ @current&.timing(name, ms, tags)
110
+ nil
111
+ end
112
+
113
+ # Times the block into the name histogram (and as a slow operation over the threshold), and
114
+ # returns the block's value.
115
+ def time(name, tags: nil)
116
+ start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
117
+ yield
118
+ ensure
119
+ finish_timer(name, start, tags) if start
120
+ end
121
+
122
+ # Records something that happened (a deploy, a sign-up). Subject to sample_rate.
123
+ def event(name, attributes = nil)
124
+ @current&.event(name, attributes)
125
+ nil
126
+ end
127
+
128
+ # Reports an exception (the one being handled, $!, when none is given).
129
+ def capture_exception(exception = $!, tags: nil, context: nil, fingerprint: nil, handled: true)
130
+ @current&.capture_exception(exception, tags: tags, context: context, fingerprint: fingerprint, handled: handled)
131
+ nil
132
+ end
133
+
134
+ # Reports a message as an error without an exception.
135
+ def capture_message(message, level: "error", tags: nil, context: nil)
136
+ @current&.capture_message(message, level: level, tags: tags, context: context)
137
+ nil
138
+ end
139
+
140
+ # Reports a slow operation (kind is "http", "discord", "task" or "custom").
141
+ def record_slow(name, duration_ms, kind: "custom", tags: nil, attributes: nil)
142
+ @current&.record_slow(name, duration_ms, kind: kind, tags: tags, attributes: attributes)
143
+ nil
144
+ end
145
+
146
+ # Marks this instance alive now (sent straight away by the background thread).
147
+ def heartbeat
148
+ @current&.heartbeat
149
+ nil
150
+ end
151
+
152
+ # Sends everything buffered now. True when nothing is left to send.
153
+ def flush(timeout = 2.0)
154
+ client = @current
155
+ client ? client.flush(timeout) : false
156
+ end
157
+
158
+ # Flushes and stops. Later calls do nothing until init is called again.
159
+ def shutdown(timeout = 2.0)
160
+ client = @init_lock.synchronize do
161
+ c = @current
162
+ @current = nil
163
+ c
164
+ end
165
+ client&.shutdown(timeout)
166
+ nil
167
+ end
168
+
169
+ private
170
+
171
+ def env(name)
172
+ value = ENV.fetch(name, nil)
173
+ value.nil? || value.strip.empty? ? nil : value.strip
174
+ end
175
+
176
+ def finish_timer(name, start, tags)
177
+ client = @current
178
+ return unless client&.enabled?
179
+
180
+ ms = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000.0
181
+ client.timing(name, ms, tags)
182
+ client.maybe_slow(name, ms, "task", tags)
183
+ end
184
+
185
+ def install_process_hooks
186
+ return if @hooks_installed
187
+
188
+ @hooks_installed = true
189
+ at_exit { Monitoring.send(:at_exit_hook, $!) }
190
+ Process.singleton_class.prepend(ForkHook) if Process.respond_to?(:_fork)
191
+ end
192
+
193
+ # Reports the exception that is ending the program (unless it is a normal exit or Ctrl+C),
194
+ # then flushes and stops.
195
+ def at_exit_hook(error)
196
+ client = @current
197
+ return if client.nil?
198
+
199
+ if error && client.capture_unhandled? && !error.is_a?(SystemExit) && !error.is_a?(Interrupt) &&
200
+ !error.is_a?(SignalException)
201
+ client.capture_exception(error, handled: false)
202
+ end
203
+ client.shutdown(2.0)
204
+ rescue StandardError
205
+ nil
206
+ end
207
+
208
+ def after_fork
209
+ @init_lock = Mutex.new
210
+ client = @current
211
+ client.after_fork if client&.enabled?
212
+ rescue StandardError
213
+ nil
214
+ end
215
+ end
216
+
217
+ # Restarts the client's thread in a child process (Ruby 3.1 and later call Process._fork for
218
+ # every fork).
219
+ module ForkHook
220
+ def _fork
221
+ pid = super
222
+ Monitoring.send(:after_fork) if pid.zero?
223
+ pid
224
+ end
225
+ end
226
+ end
227
+ end