Class: ActiveSanction::Doctor::Report

Inherits:
Object
  • Object
show all
Extended by:
T::Generic, T::Sig
Includes:
Enumerable
Defined in:
lib/active_sanction/doctor/report.rb

Overview

What a whole diagnostic run found, one Diagnosis per source.

report = ActiveSanction.doctor

report.ok?          # => false
report.findings     # => [Finding, ...]
report[:ofac_sdn]   # => Diagnosis
exit report.exit_code
puts report

2 sources in 18.42s: 1 with findings
ofac_sdn         WARN  3 findings
warn   remarks coverage 71.4% (was 97.3%): "Passport No." x 1,880 unrecognized
warn   individuals with a date of birth 12% (was 61%) of 11,704
info   unknown SDN_Type "syndicate"; treated as an organization (41 rows)
un_consolidated  OK

It is an object, not console output

The same split Sync::Report makes, for the same reason. The human form is what a CLI verb (#36) prints; the serialized form is what a host application alerts on, what a nightly job keeps so that next week's run has a warning class to compare against, and what the instrumentation hooks (#59) emit. A diagnostic that only existed as printed text would mean every host that wants to notice a drifting source has to scrape a log, which is precisely the state this exists to end.

The exit code is a policy, and it is the caller's

exit_code is 1 when anything failed at error, because a list that cannot be read is not a matter of taste. Whether a warn should also stop a deployment is, so it is a parameter: exit_code(on: :warn) is what a team that treats drift as a build failure passes.

Instances are frozen on construction and compare by value.

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(diagnoses:, started_at: nil, duration: 0.0) ⇒ void

Parameters:

  • diagnoses (T.untyped)
  • started_at (T.untyped) (defaults to: nil)
  • duration (T.untyped) (defaults to: 0.0)


80
81
82
83
84
85
# File 'lib/active_sanction/doctor/report.rb', line 80

def initialize(diagnoses:, started_at: nil, duration: 0.0)
  @diagnoses = T.let(diagnoses!(diagnoses), T::Array[Diagnosis])
  @started_at = T.let(time!(started_at), Time)
  @duration = T.let(duration.to_f, Float)
  freeze
end

Instance Attribute Details

#diagnoses ⇒ Array<Diagnosis> (readonly)

Returns:



60
61
62
# File 'lib/active_sanction/doctor/report.rb', line 60

def diagnoses
  @diagnoses
end

#duration ⇒ Float (readonly)

Wall-clock seconds for the whole run.

Returns:

  • (Float)


68
69
70
# File 'lib/active_sanction/doctor/report.rb', line 68

def duration
  @duration
end

#started_at ⇒ Time (readonly)

When the run began, UTC.

Returns:

  • (Time)


64
65
66
# File 'lib/active_sanction/doctor/report.rb', line 64

def started_at
  @started_at
end

Class Method Details

.from_h(hash) ⇒ T.attached_class

Parameters:

  • hash (T.untyped)

Returns:

  • (T.attached_class)

Raises:



71
72
73
74
75
76
77
# File 'lib/active_sanction/doctor/report.rb', line 71

def self.from_h(hash)
  attributes = hash.to_h.transform_keys(&:to_sym)
  unknown = attributes.keys - MEMBERS
  raise InvalidArgument, "unknown Doctor::Report attribute(s): #{unknown.join(", ")}" if unknown.any?

  T.unsafe(self).new(**attributes)
end

Instance Method Details

#==(other) ⇒ Boolean Also known as: eql?

Parameters:

  • other (T.untyped)

Returns:

  • (Boolean)


192
193
194
195
196
# File 'lib/active_sanction/doctor/report.rb', line 192

def ==(other)
  return false unless other.instance_of?(self.class)

  to_h == other.to_h
end

#[](source) ⇒ Diagnosis?

One source's diagnosis, or nil if the run did not cover it.

Parameters:

  • source (T.untyped)

Returns:



97
98
99
100
# File 'lib/active_sanction/doctor/report.rb', line 97

def [](source)
  key = source.to_sym
  diagnoses.find { |diagnosis| diagnosis.source == key }
end

#each(&block) ⇒ T.untyped

Parameters:

  • block (T.proc.params(diagnosis: Diagnosis).void, nil)

Returns:

  • (T.untyped)


88
89
90
91
92
93
# File 'lib/active_sanction/doctor/report.rb', line 88

def each(&block)
  return enum_for(:each) unless block

  diagnoses.each(&block)
  self
end

#empty? ⇒ Boolean

Returns:

  • (Boolean)


151
# File 'lib/active_sanction/doctor/report.rb', line 151

def empty? = diagnoses.empty?

#errors ⇒ Array<Finding>

Returns:



114
# File 'lib/active_sanction/doctor/report.rb', line 114

def errors = findings.select(&:error?)

#exit_code(on: :error) ⇒ Integer

What a scheduled job should exit with. 1 on any error by default, and on: :warn for a caller that wants drift to stop a build too. See the class comment.

Parameters:

  • on (T.untyped) (defaults to: :error)

Returns:

  • (Integer)


157
158
159
160
# File 'lib/active_sanction/doctor/report.rb', line 157

def exit_code(on: :error)
  level = on.to_sym
  findings.any? { |finding| finding.at_least?(level) } ? 1 : 0
end

#failed ⇒ Array<Diagnosis>

The sources that could not be diagnosed at all -- a publisher that is down, a payload that is not the format it should be. Louder than a finding, and a different question: nothing here knows whether those lists have drifted.

Returns:



136
# File 'lib/active_sanction/doctor/report.rb', line 136

def failed = diagnoses.select(&:failed?)

#failed? ⇒ Boolean

Returns:

  • (Boolean)


139
# File 'lib/active_sanction/doctor/report.rb', line 139

def failed? = diagnoses.any?(&:failed?)

#findings ⇒ Array<Finding>

Every finding across every source, most serious first, and within a severity in the order the sources were diagnosed.

Returns:



108
109
110
111
# File 'lib/active_sanction/doctor/report.rb', line 108

def findings
  diagnoses.flat_map(&:findings)
           .sort_by.with_index { |finding, at| [-Finding::SEVERITIES.index(finding.severity).to_i, at] }
end

#hash ⇒ Integer

Returns:

  • (Integer)


200
# File 'lib/active_sanction/doctor/report.rb', line 200

def hash = [self.class, to_h].hash

#infos ⇒ Array<Finding>

Returns:



120
# File 'lib/active_sanction/doctor/report.rb', line 120

def infos = findings.select(&:info?)

#inspect ⇒ String

Returns:

  • (String)


203
# File 'lib/active_sanction/doctor/report.rb', line 203

def inspect = "#<#{self.class} #{summary}>"

#ok? ⇒ Boolean

Nothing above info, anywhere. What a nightly job alerts on when it only wants one question answered.

Returns:

  • (Boolean)


125
# File 'lib/active_sanction/doctor/report.rb', line 125

def ok? = diagnoses.all?(&:ok?)

#profiles ⇒ Hash{Symbol => Profile}

The profile of each source, keyed by source -- what a nightly job keeps so that the next run has last night's warning classes and free-text coverage to compare against, which a stored snapshot cannot supply. See Doctor#baseline.

Returns:



172
173
174
175
176
177
# File 'lib/active_sanction/doctor/report.rb', line 172

def profiles
  diagnoses.each_with_object({}) do |diagnosis, all|
    profile = diagnosis.profile
    all[diagnosis.source] = profile unless profile.nil?
  end
end

#severity ⇒ Symbol?

The most serious severity anywhere in the run, or nil for a clean one.

Returns:

  • (Symbol, nil)


143
144
145
# File 'lib/active_sanction/doctor/report.rb', line 143

def severity
  Finding::SEVERITIES.reverse.find { |level| diagnoses.any? { |one| one.severity == level } }
end

#size ⇒ Integer

Returns:

  • (Integer)


148
# File 'lib/active_sanction/doctor/report.rb', line 148

def size = diagnoses.size

#sources ⇒ Array<Symbol>

Returns:

  • (Array<Symbol>)


103
# File 'lib/active_sanction/doctor/report.rb', line 103

def sources = diagnoses.map(&:source)

#summary ⇒ String

Returns:

  • (String)


180
181
182
183
184
185
186
# File 'lib/active_sanction/doctor/report.rb', line 180

def summary
  counts = { "with findings" => unhealthy.size, "unreadable" => failed.size }
           .reject { |_label, count| count.zero? }
           .map { |label, count| "#{count} #{label}" }
  "#{size} #{size == 1 ? "source" : "sources"} in #{format("%.2f", duration)}s" \
    "#{": #{counts.empty? ? "all healthy" : counts.join(", ")}"}"
end

#to_h ⇒ Hash{Symbol => T.untyped}

Returns:

  • (Hash{Symbol => T.untyped})


163
164
165
# File 'lib/active_sanction/doctor/report.rb', line 163

def to_h
  { diagnoses: diagnoses.map(&:to_h), started_at: started_at.iso8601, duration: duration }
end

#to_s ⇒ String

Returns:

  • (String)


189
# File 'lib/active_sanction/doctor/report.rb', line 189

def to_s = ([summary] + diagnoses.flat_map { |diagnosis| diagnosis.lines(width) }).join("\n")

#unhealthy ⇒ Array<Diagnosis>

The sources with something worth reading about them.

Returns:



129
# File 'lib/active_sanction/doctor/report.rb', line 129

def unhealthy = diagnoses.reject(&:ok?)

#warnings ⇒ Array<Finding>

Returns:



117
# File 'lib/active_sanction/doctor/report.rb', line 117

def warnings = findings.select(&:warn?)