closeyourit-ruby 0.12.1 → 0.13.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e8e0fc4eed9289e8fbb3ac2b93e6bcf71c950200b24483be93ca9b03cbb5edf5
4
- data.tar.gz: bec0fc92de3a19b85caa500e41953726a1693ca8f757cd98022b8b5287e83f76
3
+ metadata.gz: c46a549b542395326541c9def86cc019d5cd9620fef9c8f4584c90543ca1b179
4
+ data.tar.gz: 5ac2011a99d7be1d09a751334832154cf1d23765fe8527333adecda9862f50c1
5
5
  SHA512:
6
- metadata.gz: f39ecd3584260bf7257fb8efc7fcf12222a2adec2fa2d1ba7ff3a59e368ae6c6e52921d5c83dda516c588946d718c4407aa934d6f495cfd9269621226fd58555
7
- data.tar.gz: a634e8af76d32b12d1ffe210e73d3070c8059d15a55953caed9406d2c8db7fbeaac0f34bf800ee049bf9b4cdcb737d0239f3968978b970bb1175f42dfca3f0c5
6
+ metadata.gz: 95b4271a533b85cb3d9cf0bf718f08b07a87ce180cd00c03fcc82c30cf24047bc240d1ad572be09bac04402b00ecb728e87250905d28666d443fd22d65111981
7
+ data.tar.gz: 9210728ff21f26c59b49a31007a068f1f3dd334214c2f67f5eb7aa9ce7b418ecdec3f6c1c9cc6b2935e950cded2252ebc417183c3c4518b7d8a4ca130d9a9a44
data/README.md CHANGED
@@ -401,6 +401,40 @@ ActiveJob dentro Sidekiq è osservato una sola volta quando il subscriber Active
401
401
  Sampling, disabilitazione o perdita del transport impediscono di considerare questi log un censimento.
402
402
  Il raggruppamento backend v1 separa framework, nome, coda e misura; i gruppi legacy restano intatti.
403
403
 
404
+ ## Open Dashboard (opt-in)
405
+
406
+ Il servizio mostra i suoi numeri (utenti, abbonamenti, incassi…) a chi ha la chiave, su
407
+ `GET /open-dashboard`. Lo standard completo: contratto `contracts/open-dashboard/v1`.
408
+
409
+ ```ruby
410
+ # config/initializers/open_dashboard.rb
411
+ CloseYourIt::OpenDashboard.configure do |od|
412
+ od.token = ENV["OPEN_DASHBOARD_TOKEN"] # without it the route does not exist
413
+ od.name = "scontino"
414
+ od.app = "scontino-rails"
415
+ od.url = "https://scontino.it"
416
+ od.locale = "it"
417
+
418
+ od.metric "users.registered", label: "Utenti registrati", group: "Utenti", type: :count do |since|
419
+ { value: User.count, previous: User.where(created_at: ...since).count }
420
+ end
421
+
422
+ od.metric "revenue.mrr", label: "Incasso mensile ricorrente", group: "Incassi",
423
+ type: :money, currency: "EUR" do
424
+ Subscription.active.sum(:amount_cents)
425
+ end
426
+ end
427
+ ```
428
+
429
+ - `since` è l'inizio del periodo di confronto (`period:`, default `"30d"`; anche `1d`, `7d`, `90d`, `365d`).
430
+ - Il blocco restituisce un numero, oppure `{ value:, previous: }`.
431
+ - Tipi: `count` e `money` (interi; `money` in centesimi con `currency`), `percent` (0–100), `duration` (secondi).
432
+ - `better: :down` per i numeri che vanno bene quando scendono.
433
+ - Una metrica che solleva arriva come «non disponibile» e l'errore finisce in CloseYourIt; le altre restano.
434
+ - I blocchi girano a ogni richiesta e chi legge aspetta al massimo 10 secondi: i numeri lenti vanno precalcolati.
435
+ - Una dichiarazione sbagliata (chiave doppia, tipo sconosciuto, `money` senza valuta) blocca l'avvio.
436
+ - Solo totali: mai dati di singole persone.
437
+
404
438
  ## Privacy & PII
405
439
 
406
440
  Privacy-by-default (`send_pii = false`). In sintesi:
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CloseYourIt
4
+ module OpenDashboard
5
+ # Raised at boot when the service declares a metric the standard does not allow.
6
+ class ConfigurationError < StandardError; end
7
+
8
+ # One metric of the Open Dashboard standard. Rules are checked when the service boots, so a bad
9
+ # declaration fails the deploy instead of producing an invalid response in production.
10
+ class Metric
11
+ TYPES = %w[count money percent duration].freeze
12
+ INTEGER_TYPES = %w[count money].freeze
13
+ PERIODS = { "1d" => 1, "7d" => 7, "30d" => 30, "90d" => 90, "365d" => 365 }.freeze
14
+ BETTER = %w[up down].freeze
15
+ KEY_FORMAT = /\A[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+\z/
16
+ CURRENCY_FORMAT = /\A[A-Z]{3}\z/
17
+ UNAVAILABLE = "unavailable"
18
+ SECONDS_PER_DAY = 86_400
19
+
20
+ attr_reader :key
21
+
22
+ def initialize(key, label:, group:, type:, currency: nil, period: "30d", better: nil, &block)
23
+ @key = key.to_s
24
+ @label = label.to_s
25
+ @group = group.to_s
26
+ @type = type.to_s
27
+ @currency = currency
28
+ @period = period.to_s
29
+ @better = better&.to_s
30
+ @block = block
31
+ validate!
32
+ end
33
+
34
+ # The block gets the start of the comparison period. A failure never breaks the other metrics:
35
+ # it is reported to CloseYourIt and the metric goes out as unavailable.
36
+ def to_h(now)
37
+ since = now - (PERIODS.fetch(@period) * SECONDS_PER_DAY)
38
+ identity.merge(values(@block.call(since)))
39
+ rescue StandardError => e
40
+ CloseYourIt.capture_exception(e, handled: true)
41
+ identity.merge(value: nil, error: UNAVAILABLE)
42
+ end
43
+
44
+ private
45
+
46
+ def identity
47
+ { key: @key, label: @label, group: @group, type: @type, currency: @currency, better: @better }.compact
48
+ end
49
+
50
+ def values(result)
51
+ return { value: number(result) } unless result.is_a?(Hash)
52
+
53
+ out = { value: number(result.fetch(:value)) }
54
+ out.merge!(previous: number(result[:previous]), period: @period) unless result[:previous].nil?
55
+ out
56
+ end
57
+
58
+ # Returns the value to emit: an Integer for count/money, a Float for percent/duration.
59
+ def number(value)
60
+ reject("value must be numeric") unless value.is_a?(Numeric)
61
+ reject("value must be finite") unless !value.respond_to?(:finite?) || value.finite?
62
+
63
+ normalised = INTEGER_TYPES.include?(@type) ? whole(value) : Float(value)
64
+ check_range(normalised)
65
+ normalised
66
+ end
67
+
68
+ def whole(value)
69
+ reject("#{@type} must be an integer") unless value == value.to_i
70
+
71
+ value.to_i
72
+ end
73
+
74
+ def check_range(value)
75
+ reject("count must not be negative") if @type == "count" && value.negative?
76
+ reject("percent must be between 0 and 100") if @type == "percent" && !value.between?(0, 100)
77
+ reject("duration must not be negative") if @type == "duration" && value.negative?
78
+ end
79
+
80
+ def reject(message)
81
+ raise TypeError, "open_dashboard metric #{@key}: #{message}"
82
+ end
83
+
84
+ def validate!
85
+ fail_with("key must be lowercase and dotted, e.g. users.registered") unless KEY_FORMAT.match?(@key)
86
+ fail_with("label is blank") if @label.strip.empty?
87
+ fail_with("group is blank") if @group.strip.empty?
88
+ fail_with("type must be one of #{TYPES.join(', ')}") unless TYPES.include?(@type)
89
+ validate_currency!
90
+ fail_with("period must be one of #{PERIODS.keys.join(', ')}") unless PERIODS.key?(@period)
91
+ fail_with("better must be up or down") if @better && !BETTER.include?(@better)
92
+ fail_with("a block computing the value is required") unless @block
93
+ end
94
+
95
+ def validate_currency!
96
+ if @type == "money"
97
+ fail_with("money needs an ISO 4217 currency, e.g. EUR") unless CURRENCY_FORMAT.match?(@currency.to_s)
98
+ elsif @currency
99
+ fail_with("currency is allowed only on money")
100
+ end
101
+ end
102
+
103
+ def fail_with(message)
104
+ raise ConfigurationError, "open_dashboard metric #{@key}: #{message}"
105
+ end
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "json"
5
+ require "openssl"
6
+
7
+ module CloseYourIt
8
+ module OpenDashboard
9
+ # Pure Rack: answers GET /open-dashboard and passes everything else to the app. Without a token
10
+ # the request also goes to the app, so the route simply does not exist (404 from the app).
11
+ class Middleware
12
+ JSON_HEADERS = { "content-type" => "application/json", "cache-control" => "no-store" }.freeze
13
+ BEARER = "Bearer "
14
+
15
+ def initialize(app)
16
+ @app = app
17
+ end
18
+
19
+ def call(env)
20
+ settings = OpenDashboard.settings
21
+ return @app.call(env) unless dashboard_request?(env) && settings&.enabled?
22
+ return [ 401, { "cache-control" => "no-store" }, [] ] unless authorized?(env["HTTP_AUTHORIZATION"], settings.token)
23
+
24
+ [ 200, JSON_HEADERS.dup, [ JSON.generate(Report.new(settings).to_h) ] ]
25
+ end
26
+
27
+ private
28
+
29
+ def dashboard_request?(env)
30
+ env["REQUEST_METHOD"] == "GET" && env["PATH_INFO"] == PATH
31
+ end
32
+
33
+ # Hashing both sides gives equal lengths, so the comparison time does not leak the token length.
34
+ def authorized?(header, token)
35
+ header = header.to_s
36
+ return false unless header.start_with?(BEARER)
37
+
38
+ OpenSSL.fixed_length_secure_compare(
39
+ Digest::SHA256.digest(header.delete_prefix(BEARER)),
40
+ Digest::SHA256.digest(token.to_s)
41
+ )
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module CloseYourIt
6
+ module OpenDashboard
7
+ # Builds the response body. Service identity comes from the same ENV as GET /version, so the
8
+ # dashboard and the deploy smoke test always agree on which build is live.
9
+ class Report
10
+ UNKNOWN = "unknown"
11
+
12
+ def initialize(settings, now: Time.now.utc)
13
+ @settings = settings
14
+ @now = now
15
+ end
16
+
17
+ def to_h
18
+ {
19
+ open_dashboard: SPEC_VERSION,
20
+ service: service,
21
+ locale: @settings.locale,
22
+ generated_at: @now.getutc.iso8601,
23
+ metrics: @settings.metrics.map { |metric| metric.to_h(@now) }
24
+ }
25
+ end
26
+
27
+ private
28
+
29
+ def service
30
+ {
31
+ name: @settings.name,
32
+ app: @settings.app,
33
+ version: present(ENV["APP_GIT_TAG"]) || UNKNOWN,
34
+ sha: present(ENV["APP_GIT_SHA"])&.slice(0, 7) || UNKNOWN,
35
+ environment: CloseYourIt.configuration.environment.to_s,
36
+ url: present(@settings.url)
37
+ }.compact
38
+ end
39
+
40
+ def present(value)
41
+ value.to_s.strip.empty? ? nil : value.to_s
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CloseYourIt
4
+ module OpenDashboard
5
+ # What the service declares in config/initializers/open_dashboard.rb.
6
+ class Settings
7
+ LOCALE_FORMAT = /\A[a-z]{2}(-[A-Z]{2})?\z/
8
+
9
+ attr_accessor :token, :name, :app, :url, :locale
10
+ attr_reader :metrics
11
+
12
+ def initialize
13
+ @metrics = []
14
+ end
15
+
16
+ def metric(key, **options, &block)
17
+ if @metrics.any? { |existing| existing.key == key.to_s }
18
+ raise ConfigurationError, "open_dashboard metric #{key}: duplicated key"
19
+ end
20
+
21
+ @metrics << Metric.new(key, **options, &block)
22
+ end
23
+
24
+ # No token means the feature is off: the route does not exist.
25
+ def enabled? = !token.to_s.strip.empty?
26
+
27
+ def validate!
28
+ %i[name app].each do |field|
29
+ raise ConfigurationError, "open_dashboard: #{field} is required" if public_send(field).to_s.strip.empty?
30
+ end
31
+ raise ConfigurationError, "open_dashboard: locale must look like it or en-US" unless LOCALE_FORMAT.match?(locale.to_s)
32
+ raise ConfigurationError, "open_dashboard: at most #{MAX_METRICS} metrics" if @metrics.size > MAX_METRICS
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CloseYourIt
4
+ # Open Dashboard standard: GET /open-dashboard returns who the service is and its business
5
+ # numbers, behind a per-service token. Contract: contracts/open-dashboard/v1.
6
+ module OpenDashboard
7
+ SPEC_VERSION = "1.0"
8
+ PATH = "/open-dashboard"
9
+ MAX_METRICS = 50
10
+
11
+ class << self
12
+ # Kept separate from CloseYourIt.init: init rebuilds its configuration on every call, and the
13
+ # dashboard declaration must not disappear when telemetry is reconfigured.
14
+ def configure
15
+ candidate = Settings.new
16
+ yield candidate
17
+ candidate.validate!
18
+ @settings = candidate
19
+ end
20
+
21
+ attr_reader :settings
22
+
23
+ def reset!
24
+ @settings = nil
25
+ end
26
+ end
27
+ end
28
+ end
29
+
30
+ require_relative "open_dashboard/metric"
31
+ require_relative "open_dashboard/settings"
32
+ require_relative "open_dashboard/report"
33
+ require_relative "open_dashboard/middleware"
@@ -28,6 +28,16 @@ module CloseYourIt
28
28
  )
29
29
  end
30
30
 
31
+ # Open Dashboard (GET /open-dashboard). Inside CaptureExceptions, so a crash while building the
32
+ # report is reported like any other request error. Inert until the app calls
33
+ # CloseYourIt::OpenDashboard.configure with a token.
34
+ initializer "closeyourit.open_dashboard" do |app|
35
+ app.config.middleware.insert_after(
36
+ CloseYourIt::Rails::CaptureExceptions,
37
+ CloseYourIt::OpenDashboard::Middleware
38
+ )
39
+ end
40
+
31
41
  initializer "closeyourit.subscribe_slow_queries" do
32
42
  subscriber = CloseYourIt::Subscribers::SlowQuery.new
33
43
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module CloseYourIt
4
- VERSION = "0.12.1"
4
+ VERSION = "0.13.0"
5
5
  end
@@ -39,6 +39,7 @@ require_relative "closeyourit/rails/active_job_extension"
39
39
  require_relative "closeyourit/rails/error_subscriber"
40
40
  require_relative "closeyourit/sidekiq/error_handler"
41
41
  require_relative "closeyourit/sidekiq/job_metrics_middleware"
42
+ require_relative "closeyourit/open_dashboard"
42
43
 
43
44
  # CloseYourIt — telemetry client (errors + slow query/method statistics)
44
45
  # that sends events to the CloseYourIt ingest endpoint.
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: closeyourit-ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.12.1
4
+ version: 0.13.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alessio Bussolari
@@ -67,6 +67,11 @@ files:
67
67
  - lib/closeyourit/log_buffer.rb
68
68
  - lib/closeyourit/log_device.rb
69
69
  - lib/closeyourit/monitor.rb
70
+ - lib/closeyourit/open_dashboard.rb
71
+ - lib/closeyourit/open_dashboard/metric.rb
72
+ - lib/closeyourit/open_dashboard/middleware.rb
73
+ - lib/closeyourit/open_dashboard/report.rb
74
+ - lib/closeyourit/open_dashboard/settings.rb
70
75
  - lib/closeyourit/performance/request_profile.rb
71
76
  - lib/closeyourit/performance/rollup.rb
72
77
  - lib/closeyourit/rails/active_job_extension.rb