startback 0.17.3 → 0.18.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: 47e8ac3b588f01947b8f6850a40ae2777bf3578b8fea2c38d923f9619f7ddf0e
4
- data.tar.gz: d09a7168a291df2a59b199be072969fad30a1c7ef33d6c78058dfb358fbafd5f
3
+ metadata.gz: 7ae59b533182869f37a6a720314be714a3c348d294071724c7f6f4931385d1d5
4
+ data.tar.gz: de7630071838aacc2c2a916b8aeade7e39b9e580617b8dd89f56995dba995d68
5
5
  SHA512:
6
- metadata.gz: dddbf9e33df9565c4ec445d107e9decc83c23a7538aba0eac4babd5104c0000ad89a49d657060ac400a9e79418e1c705c2dfb4b3b620b530e932f04977ce98f0
7
- data.tar.gz: 6fe5a230ac86dc2cd2b8bee70b54df7c3be840f9afd8d871d54d1a31cffa3a1b10bccc0de003201f169436a96b14b32bcd58f2a6aaffddd4a0d67b4a132398db
6
+ metadata.gz: d2804d9ed39c41c8ad5d3a45b7b6e53d021fe3f20fbe30fa7ed5f08e8450ee23b69870879a044b5f971cd07fff821177233bbce71ae4edb7c4acc81bc9a7910c
7
+ data.tar.gz: 9e42c4b69121a35d01aafbd42c08d0fbde53b5cbca308036b24bd6a80a840f455925f33abae6d27abfd5df9f7f69222db961db0cb1e724a417320f1c83481ffb
@@ -0,0 +1,35 @@
1
+ module Startback
2
+ class Context
3
+ attr_accessor :tracer
4
+
5
+ def tracer
6
+ @tracer ||= Audit::Tracer.empty.on_span(
7
+ Audit::TraceLogger.new(logger)
8
+ )
9
+ end
10
+
11
+ def trace_span(attributes = {}, &block)
12
+ @tracer = tracer.new_trace unless tracer.attached?
13
+ tracer.fork(attributes, &block)
14
+ end
15
+
16
+ h_dump do |h|
17
+ next unless tracer.attached?
18
+
19
+ last_span = tracer.last_span!
20
+ h.merge!("tracing" => {
21
+ "trace_id" => last_span.trace_id,
22
+ "span_id" => last_span.span_id,
23
+ "parent_id" => last_span.parent_id,
24
+ })
25
+ end
26
+
27
+ h_factory do |c, h|
28
+ next unless h['tracing']
29
+
30
+ trace_id = h['tracing']['trace_id']
31
+ span_id = h['tracing']['span_id']
32
+ c.tracer = c.tracer.attach_to(trace_id, span_id)
33
+ end
34
+ end # class Context
35
+ end # module Startback
@@ -0,0 +1 @@
1
+ require_relative 'ext/context'
@@ -0,0 +1,30 @@
1
+ module Startback
2
+ module Audit
3
+ class Middleware
4
+
5
+ def initialize(app)
6
+ @app = app
7
+ end
8
+
9
+ def call(env)
10
+ context = ::Startback::Context::Middleware.context(env)
11
+
12
+ # attach to the existing trace if any
13
+ trace_id = env['HTTP_X_TRACE_ID']
14
+ span_id = env['HTTP_X_SPAN_ID']
15
+ context.tracer = context.tracer.attach_to(trace_id, span_id) if trace_id && span_id
16
+
17
+ # trace it!
18
+ context.trace_span({
19
+ :type => :request_handler,
20
+ :method => env['REQUEST_METHOD'],
21
+ :path => env['PATH_INFO'],
22
+ :qs => env['QUERY_STRING']
23
+ }) do
24
+ @app.call(env)
25
+ end
26
+ end
27
+
28
+ end # class Middleware
29
+ end # module Audit
30
+ end # module Startback
@@ -0,0 +1,31 @@
1
+ module Startback
2
+ module Audit
3
+ class NullTracer
4
+
5
+ def attached?
6
+ false
7
+ end
8
+
9
+ def last_span!
10
+ nil
11
+ end
12
+
13
+ def new_trace(*args)
14
+ self
15
+ end
16
+
17
+ def attach_to(*args)
18
+ self
19
+ end
20
+
21
+ def fork(*args)
22
+ yield
23
+ end
24
+
25
+ def on_span(listener = nil, &block)
26
+ self
27
+ end
28
+
29
+ end # class NullTracer
30
+ end # module Audit
31
+ end # module Startback
@@ -0,0 +1,19 @@
1
+ require 'startback/audit'
2
+
3
+ module Startback
4
+ module Audit
5
+ class OperationTracer
6
+ include Startback::Audit::Shared
7
+
8
+ def call(runner, op, &block)
9
+ op.context.trace_span({
10
+ type: :operation,
11
+ op: op_name(op),
12
+ data: op_data(op),
13
+ context: op_context(op)
14
+ }, &block)
15
+ end
16
+
17
+ end # class OperationTracer
18
+ end # module Audit
19
+ end # module Startback
@@ -12,6 +12,24 @@ module Startback
12
12
  end
13
13
  end
14
14
 
15
+ def op_context(op)
16
+ op.respond_to?(:context, false) ? op.context.to_h : {}
17
+ end
18
+
19
+ def op_data(op)
20
+ if op.respond_to?(:op_data, false)
21
+ op.op_data
22
+ elsif op.respond_to?(:to_trail, false)
23
+ op.to_trail
24
+ elsif op.respond_to?(:input, false)
25
+ op.input
26
+ elsif op.respond_to?(:request, false)
27
+ op.request
28
+ elsif op.is_a?(Operation::MultiOperation)
29
+ op.ops.map{ |sub_op| op_to_trail(sub_op) }
30
+ end
31
+ end
32
+
15
33
  end # module Shared
16
34
  end # module Audit
17
35
  end # module Startback
@@ -0,0 +1,66 @@
1
+ module Startback
2
+ module Audit
3
+ class Span
4
+
5
+ def initialize(trace_id, parent_id, attributes = {}, span_id = SecureRandom.uuid)
6
+ @trace_id, @parent_id = trace_id, parent_id
7
+ @span_id = span_id
8
+ @attributes = attributes
9
+ @status = 'unknown'
10
+ @at = (Time.now.to_f*1000).to_i
11
+ @timing = nil
12
+ @error = nil
13
+ end
14
+ attr_reader :trace_id, :parent_id, :span_id, :status, :attributes, :timing, :error
15
+
16
+ def finished?
17
+ @status != 'unknown'
18
+ end
19
+
20
+ def success?
21
+ @status == 'success'
22
+ end
23
+
24
+ def error?
25
+ @status == 'error'
26
+ end
27
+
28
+ def fork(attributes = {})
29
+ Span.new(@trace_id, @span_id, attributes)
30
+ end
31
+
32
+ def finish(timing, error = nil)
33
+ @timing = timing
34
+ @status = error ? 'error' : 'success'
35
+ @error = error
36
+ self
37
+ end
38
+
39
+ def to_h
40
+ {
41
+ :spanId => span_id,
42
+ :traceId => trace_id,
43
+ :parentId => parent_id,
44
+ :status => status,
45
+ :timing => timing_to_h,
46
+ :attributes => attributes,
47
+ :error => error,
48
+ }.compact
49
+ end
50
+
51
+ def timing_to_h
52
+ {
53
+ at: @at,
54
+ total: @timing&.total,
55
+ real: @timing&.real,
56
+ }.compact
57
+ end
58
+ private :timing_to_h
59
+
60
+ def to_json(*args, &block)
61
+ to_h.to_json(*args, &block)
62
+ end
63
+
64
+ end # class Span
65
+ end # module Audit
66
+ end # module Startback
@@ -0,0 +1,30 @@
1
+ module Startback
2
+ module Audit
3
+ class TraceLogger
4
+
5
+ def initialize(logger = default_logger)
6
+ @logger = logger || default_logger
7
+ @logger.formatter ||= Support::LogFormatter.new if @logger.respond_to?(:formatter=)
8
+ end
9
+
10
+ def call(span)
11
+ if !span.finished?
12
+ @logger.debug(span.to_h)
13
+ elsif span.success?
14
+ @logger.info(span.to_h)
15
+ elsif span&.error.is_a?(Startback::Errors::BadRequestError)
16
+ @logger.warn(span.to_h)
17
+ else
18
+ @logger.error(span.to_h)
19
+ end
20
+ end
21
+
22
+ private
23
+
24
+ def default_logger
25
+ ::Logger.new(STDOUT, 'daily')
26
+ end
27
+
28
+ end # class TraceLogger
29
+ end # module Audit
30
+ end # module Startback
@@ -0,0 +1,85 @@
1
+ require 'securerandom'
2
+ require 'benchmark'
3
+
4
+ module Startback
5
+ module Audit
6
+ class Tracer
7
+
8
+ def initialize(stack = [], listeners = [], redactor = default_redactor)
9
+ @stack = stack
10
+ @listeners = listeners
11
+ @redactor = redactor
12
+ end
13
+ attr_reader :stack
14
+
15
+ def self.empty
16
+ Tracer.new
17
+ end
18
+
19
+ def attached?
20
+ !@stack.empty?
21
+ end
22
+
23
+ def last_span!
24
+ error!("Trace not attached") unless attached?
25
+
26
+ @stack.last
27
+ end
28
+
29
+ def new_trace(attributes = {})
30
+ error!("Trace already attached") if attached?
31
+
32
+ attach_to(SecureRandom.uuid, SecureRandom.uuid, attributes)
33
+ end
34
+
35
+ def attach_to(trace_id, span_id, attributes = {}, parent_id = nil)
36
+ error!("Trace already attached") if attached?
37
+
38
+ initial_span = Span.new(trace_id, parent_id, attributes, span_id)
39
+ initial_stack = [ initial_span ]
40
+ Tracer.new(initial_stack, @listeners, @redactor)
41
+ end
42
+
43
+ def fork(attributes = {}, &block)
44
+ span = last_span!.fork(@redactor.redact(attributes))
45
+ @stack << span
46
+ propagate_to_listeners(span)
47
+ result, error = nil, nil
48
+ timing = Benchmark.measure do
49
+ result, error = exec_block_with_error_handling(block)
50
+ end
51
+ span = @stack.pop.finish(timing, error)
52
+ propagate_to_listeners(span)
53
+ error ? raise(error) : result
54
+ end
55
+
56
+ def on_span(listener = nil, &block)
57
+ @listeners << (listener || block)
58
+ self
59
+ end
60
+
61
+ private
62
+
63
+ def default_redactor
64
+ Support::Redactor.new
65
+ end
66
+
67
+ def exec_block_with_error_handling(block)
68
+ [ block.call, nil ]
69
+ rescue => ex
70
+ [ nil, ex ]
71
+ end
72
+
73
+ def error!(msg)
74
+ raise Startback::Errors::InternalServerError, msg
75
+ end
76
+
77
+ def propagate_to_listeners(span)
78
+ @listeners.each do |listener|
79
+ listener.call(span)
80
+ end
81
+ end
82
+
83
+ end # class Tracer
84
+ end # module Audit
85
+ end # module Startback
@@ -1,3 +1,9 @@
1
+ require_relative 'audit/ext'
1
2
  require_relative 'audit/shared'
2
- require_relative 'audit/trailer'
3
3
  require_relative 'audit/prometheus'
4
+ require_relative 'audit/span'
5
+ require_relative 'audit/null_tracer'
6
+ require_relative 'audit/tracer'
7
+ require_relative 'audit/operation_tracer'
8
+ require_relative 'audit/trace_logger'
9
+ require_relative 'audit/middleware'
@@ -2,6 +2,11 @@ module Startback
2
2
  class Context
3
3
  module HFactory
4
4
 
5
+ def inherited(subclass)
6
+ subclass.h_factories = h_factories.dup if h_factories?
7
+ subclass.h_dumpers = h_dumpers.dup if h_dumpers?
8
+ end
9
+
5
10
  def h(hash)
6
11
  h_factor!(self.new, hash)
7
12
  end
@@ -13,6 +18,14 @@ module Startback
13
18
  context
14
19
  end
15
20
 
21
+ def h_factories?
22
+ !!@h_factories && @h_factories.any?
23
+ end
24
+
25
+ def h_factories=(factories)
26
+ @h_factories = factories
27
+ end
28
+
16
29
  def h_factories
17
30
  @h_factories ||= []
18
31
  end
@@ -30,10 +43,18 @@ module Startback
30
43
  hash
31
44
  end
32
45
 
46
+ def h_dumpers?
47
+ !!@h_dumpers && @h_dumpers.any?
48
+ end
49
+
33
50
  def h_dumpers
34
51
  @h_dumpers ||= []
35
52
  end
36
53
 
54
+ def h_dumpers=(dumpers)
55
+ @h_dumpers = dumpers
56
+ end
57
+
37
58
  def h_dump(&dumper)
38
59
  h_dumpers << dumper
39
60
  end
@@ -24,7 +24,7 @@ module Startback
24
24
  # h.merge!("foo" => foo)
25
25
  # end
26
26
  #
27
- # h_factor do |c,h|
27
+ # h_factory do |c,h|
28
28
  # c.foo = h["foo"]
29
29
  # end
30
30
  #
@@ -3,11 +3,10 @@ module Startback
3
3
 
4
4
  def self.emits(type, &bl)
5
5
  after_call do
6
- event_data = instance_exec(&bl)
7
- return unless event_data
8
-
9
- event = type.new(type.to_s, event_data, context)
10
- context.engine.bus.emit(event)
6
+ if event_data = instance_exec(&bl)
7
+ event = type.new(type.to_s, event_data, context)
8
+ context.engine.bus.emit(event)
9
+ end
11
10
  end
12
11
  end
13
12
 
@@ -3,16 +3,27 @@ module Startback
3
3
  class FakeLogger < Logger
4
4
 
5
5
  def initialize(*args)
6
- @last_msg = nil
6
+ @seen = []
7
+ end
8
+ attr_accessor :formatter
9
+ attr_reader :seen
10
+
11
+ def last_msg
12
+ seen.last
7
13
  end
8
- attr_reader :last_msg
9
14
 
10
15
  [:debug, :info, :warn, :error, :fatal].each do |meth|
11
16
  define_method(meth) do |msg|
12
- @last_msg = msg
13
- end
17
+ @seen << format(meth, msg)
18
+ end
19
+ end
20
+
21
+ def format(severity, message)
22
+ return message unless formatter
23
+
24
+ formatter.call(severity.to_s.upcase, Time.now, 'prognam', message)
14
25
  end
15
26
 
16
- end # class Logger
27
+ end # class FakeLogger
17
28
  end # module Support
18
29
  end # module Startback
@@ -2,16 +2,33 @@ module Startback
2
2
  module Support
3
3
  class LogFormatter
4
4
 
5
+ DEFAULT_OPTIONS = {
6
+ pretty_print: nil
7
+ }
8
+
9
+ def initialize(options = {})
10
+ @options = DEFAULT_OPTIONS.merge(options)
11
+ @options[:pretty_print] ||= auto_pretty_print
12
+ end
13
+
14
+ def pretty_print?
15
+ !!@options[:pretty_print]
16
+ end
17
+
5
18
  def call(severity, time, progname, msg)
6
19
  msg = { message: msg } if msg.is_a?(String)
7
20
  msg = { error: msg } if msg.is_a?(Exception)
8
- {
21
+ data = {
9
22
  severity: severity,
10
23
  time: time
11
24
  }.merge(msg)
12
25
  .merge(error: error_to_json(msg[:error], severity))
13
26
  .compact
14
- .to_json << "\n"
27
+ if pretty_print?
28
+ JSON.pretty_generate(data) << "\n"
29
+ else
30
+ data.to_json << "\n"
31
+ end
15
32
  end
16
33
 
17
34
  def error_to_json(error, severity = nil)
@@ -29,6 +46,12 @@ module Startback
29
46
  }.compact
30
47
  end
31
48
 
49
+ private
50
+
51
+ def auto_pretty_print
52
+ ENV['RACK_ENV'] != 'production'
53
+ end
54
+
32
55
  end # class LogFormatter
33
56
  end # module Support
34
57
  end # module Startback
@@ -0,0 +1,40 @@
1
+ module Startback
2
+ module Support
3
+ class Redactor
4
+
5
+ DEFAULT_OPTIONS = {
6
+
7
+ # Words used to stop dumping for, e.g., security reasons
8
+ blacklist: "token password secret credential email address"
9
+
10
+ }
11
+
12
+ def initialize(options = {})
13
+ @options = DEFAULT_OPTIONS.merge(options)
14
+ end
15
+
16
+ def redact(data)
17
+ case data
18
+ when Hash, OpenStruct
19
+ Hash[data.map{|(k,v)|
20
+ [k, (k.to_s =~ blacklist_rx) ? '---redacted---' : redact(v)]
21
+ }]
22
+ when Enumerable
23
+ data.map{|elm| redact(elm) }.compact
24
+ else
25
+ data
26
+ end
27
+ end
28
+
29
+ private
30
+
31
+ def blacklist_rx
32
+ @blacklist_rx ||= Regexp.new(
33
+ @options[:blacklist].split(/\s+/).join("|"),
34
+ Regexp::IGNORECASE
35
+ )
36
+ end
37
+
38
+ end # class Redactor
39
+ end # module Support
40
+ end # module Startback
@@ -15,6 +15,7 @@ module Startback
15
15
  end # module Support
16
16
  end # module Startback
17
17
  require_relative 'support/env'
18
+ require_relative 'support/redactor'
18
19
  require_relative 'support/log_formatter'
19
20
  require_relative 'support/logger'
20
21
  require_relative 'support/robustness'
@@ -1,8 +1,8 @@
1
1
  module Startback
2
2
  module Version
3
3
  MAJOR = 0
4
- MINOR = 17
5
- TINY = 3
4
+ MINOR = 18
5
+ TINY = 0
6
6
  end
7
7
  VERSION = "#{Version::MAJOR}.#{Version::MINOR}.#{Version::TINY}"
8
8
  end
data/spec/spec_helper.rb CHANGED
@@ -1,6 +1,7 @@
1
1
  require 'startback'
2
2
  require 'startback/event'
3
3
  require 'startback/support/fake_logger'
4
+ require 'startback/audit'
4
5
  require 'rack/test'
5
6
  require 'ostruct'
6
7
 
@@ -0,0 +1,66 @@
1
+ require 'spec_helper'
2
+
3
+ module Startback
4
+ module Audit
5
+ describe "Context extension" do
6
+ subject do
7
+ context.to_h
8
+ end
9
+
10
+ context 'no tracing has been added' do
11
+ let(:context) do
12
+ Startback::Context.new
13
+ end
14
+
15
+ it 'has no tracing info' do
16
+ expect(subject).not_to have_key('tracing')
17
+ end
18
+
19
+ it 'does not break reloading a context if no tracing' do
20
+ subject = Startback::Context.h({})
21
+ expect(subject.tracer).to be_an_instance_of(Startback::Audit::Tracer)
22
+ expect(subject.tracer).not_to be_attached
23
+ end
24
+ end
25
+
26
+ context 'some tracing has been added and attached' do
27
+ let(:context) do
28
+ Startback::Context.new.dup do |c|
29
+ c.tracer = Tracer.empty.attach_to('some_trace_uuid', 'some_trace_parent_uuid')
30
+ end
31
+ end
32
+
33
+ it 'has tracing info' do
34
+ expect(subject).to have_key('tracing')
35
+ expect(subject['tracing']['trace_id']).to eql('some_trace_uuid')
36
+ expect(subject['tracing']['span_id']).to eql('some_trace_parent_uuid')
37
+ end
38
+
39
+ it 'helps reloading a tracer instance from h info' do
40
+ subject = Startback::Context.h('tracing' => {
41
+ 'trace_id' => 'some_trace_uuid',
42
+ 'parent_id' => 'some_trace_parent_uuid'
43
+ })
44
+ expect(subject.tracer).to be_an_instance_of(Startback::Audit::Tracer)
45
+ expect(subject.tracer).to be_attached
46
+ end
47
+ end
48
+
49
+ context 'some tracing has been added and attached on a subclass' do
50
+ let(:context) do
51
+ SubContext.new.dup do |c|
52
+ c.tracer = Tracer.empty.attach_to('some_trace_uuid', 'some_trace_parent_uuid')
53
+ end
54
+ end
55
+
56
+ it 'has tracing info' do
57
+ expect(context.tracer).to be_attached
58
+ expect(SubContext.h_factories.size).to eql(3)
59
+ expect(subject).to have_key('tracing')
60
+ expect(subject['tracing']['trace_id']).to eql('some_trace_uuid')
61
+ expect(subject['tracing']['span_id']).to eql('some_trace_parent_uuid')
62
+ end
63
+ end
64
+ end
65
+ end # module Audit
66
+ end # module Startback
@@ -0,0 +1,66 @@
1
+ require 'spec_helper'
2
+
3
+ module Startback
4
+ module Audit
5
+ describe Middleware do
6
+ include Rack::Test::Methods
7
+
8
+ def app
9
+ Rack::Builder.new do
10
+ use Startback::Context::Middleware
11
+ use Middleware
12
+ run ->(env) {
13
+ ctx = env[Startback::Context::Middleware::RACK_ENV_KEY]
14
+ attached = ctx.tracer.attached?
15
+ last_span = ctx.tracer.last_span!
16
+ [200, {
17
+ 'tracer-attached' => attached,
18
+ 'last-span' => last_span
19
+ }, 'ok']
20
+ }
21
+ end
22
+ end
23
+
24
+ context 'when not provided with tracing headers' do
25
+ it 'starts a new trace' do
26
+ get '/'
27
+ expect(last_response.status).to eql(200)
28
+ expect(last_response.headers['tracer-attached']).to eq(true)
29
+ expect(last_response.body).to eql("ok")
30
+ end
31
+
32
+ it 'creates a fresh new span' do
33
+ get '/'
34
+ expect(last_response.status).to eql(200)
35
+ expect(last_response.headers['last-span']).not_to be_nil
36
+ expect(last_response.body).to eql("ok")
37
+ end
38
+ end
39
+
40
+ context 'when provided with tracing headers' do
41
+ it 'does attach the tracer' do
42
+ header('X-Trace-Id', 'trace-id')
43
+ header('X-Span-Id', 'span-id')
44
+ get '/'
45
+ expect(last_response.status).to eql(200)
46
+ expect(last_response.headers['tracer-attached']).to eq(true)
47
+ expect(last_response.body).to eql("ok")
48
+ end
49
+
50
+ it 'does create a new span' do
51
+ header('X-Trace-Id', 'trace-id')
52
+ header('X-Span-Id', 'span-id')
53
+ get '/'
54
+ expect(last_response.status).to eql(200)
55
+
56
+ span = last_response.headers['last-span']
57
+ expect(span).not_to be_nil
58
+ expect(span.trace_id).to eq('trace-id')
59
+ expect(span.parent_id).to eq('span-id')
60
+ expect(span.span_id).not_to be_nil
61
+ expect(span.span_id).not_to eq('span-id')
62
+ end
63
+ end
64
+ end # describe Middleware
65
+ end # module Audit
66
+ end # module Startback
@@ -0,0 +1,51 @@
1
+ require 'spec_helper'
2
+
3
+ module Startback
4
+ module Audit
5
+ describe TraceLogger do
6
+
7
+ let(:fake_logger) do
8
+ Support::FakeLogger.new
9
+ end
10
+
11
+ let(:trace_logger) do
12
+ TraceLogger.new(fake_logger)
13
+ end
14
+
15
+ let(:tracer) do
16
+ Tracer.new.on_span(trace_logger)
17
+ end
18
+
19
+ it 'helps logging successes' do
20
+ attached = tracer.attach_to('trace', 'root-span')
21
+ attached.fork(foo: 'bar') do
22
+ "hello world"
23
+ end
24
+ expect(fake_logger.seen.size).to eql(2)
25
+ expect(fake_logger.seen.first).to match(/DEBUG/)
26
+ expect(fake_logger.seen.last).to match(/INFO/)
27
+ end
28
+
29
+ it 'helps logging user errors' do
30
+ attached = tracer.attach_to('trace', 'root-span')
31
+ attached.fork(foo: 'bar') do
32
+ raise Startback::Errors::ForbiddenError, "no such access granted"
33
+ end rescue nil
34
+ expect(fake_logger.seen.size).to eql(2)
35
+ expect(fake_logger.seen.first).to match(/DEBUG/)
36
+ expect(fake_logger.seen.last).to match(/WARN/)
37
+ end
38
+
39
+ it 'helps logging fatal errors' do
40
+ attached = tracer.attach_to('trace', 'root-span')
41
+ attached.fork(foo: 'bar') do
42
+ raise ArgumentError, "something bad"
43
+ end rescue nil
44
+ expect(fake_logger.seen.size).to eql(2)
45
+ expect(fake_logger.seen.first).to match(/DEBUG/)
46
+ expect(fake_logger.seen.last).to match(/ERROR/)
47
+ end
48
+
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,106 @@
1
+ require 'spec_helper'
2
+
3
+ module Startback
4
+ module Audit
5
+ describe Tracer do
6
+
7
+ let(:spans_seen) do
8
+ []
9
+ end
10
+
11
+ subject do
12
+ Tracer.new.on_span do |span|
13
+ spans_seen << span
14
+ end
15
+ end
16
+
17
+ it 'clones the object on attach_to' do
18
+ expect(subject).not_to be_attached
19
+ attached = subject.attach_to('the-trace', 'root-span')
20
+ expect(attached).to be_a(Tracer)
21
+ expect(attached).not_to be(subject)
22
+ expect(attached).to be_attached
23
+ expect(subject).not_to be_attached
24
+ end
25
+
26
+ describe 'fork' do
27
+ it 'fails if not attached' do
28
+ expect(subject).not_to be_attached
29
+ expect {
30
+ subject.fork do
31
+ 12
32
+ end
33
+ }.to raise_error(/attached/)
34
+ end
35
+
36
+ it 'returns the block result' do
37
+ attached = subject.attach_to('the-trace', 'root-span')
38
+ result = attached.fork do
39
+ 'foo'
40
+ end
41
+ expect(result).to eql('foo')
42
+ end
43
+
44
+ it 'propagates spans to listeners' do
45
+ attached = subject.attach_to('the-trace', 'root-span')
46
+ result = attached.fork do
47
+ 'foo'
48
+ end
49
+ expect(spans_seen.size).to eql(2)
50
+ expect(spans_seen.last).to be_a(Span)
51
+ expect(spans_seen.last.timing).not_to be_nil
52
+ expect(spans_seen.last).to be_finished
53
+ expect(spans_seen.last).to be_success
54
+ expect(spans_seen.last).not_to be_error
55
+ end
56
+
57
+ it 'reraises any error that occurs' do
58
+ attached = subject.attach_to('the-trace', 'root-span')
59
+ expect {
60
+ attached.fork do
61
+ raise ArgumentError, "An error"
62
+ end
63
+ }.to raise_error(ArgumentError)
64
+ end
65
+
66
+ it 'traces any error that occurs' do
67
+ attached = subject.attach_to('the-trace', 'root-span')
68
+ expect {
69
+ attached.fork do
70
+ raise ArgumentError, "An error"
71
+ end
72
+ }.to raise_error(ArgumentError)
73
+ expect(spans_seen.size).to eql(2)
74
+ expect(spans_seen.last).to be_a(Span)
75
+ expect(spans_seen.last.timing).not_to be_nil
76
+ expect(spans_seen.last).to be_finished
77
+ expect(spans_seen.last).not_to be_success
78
+ expect(spans_seen.last).to be_error
79
+ expect(spans_seen.last.error).to be_a(ArgumentError)
80
+ end
81
+ end
82
+
83
+ describe 'redacting' do
84
+ it 'applies by default' do
85
+ attached = subject.attach_to('the-trace', 'root-span')
86
+ result = attached.fork({
87
+ foo: 'bar',
88
+ password: 'baz',
89
+ repeatPassword: 'baz'
90
+ }) do
91
+ 'foo'
92
+ end
93
+ expect(spans_seen.size).to eql(2)
94
+ expect(spans_seen.last).to be_a(Span)
95
+ expect(spans_seen.last.attributes).to eql({
96
+ foo: 'bar',
97
+ password: '---redacted---',
98
+ repeatPassword: '---redacted---'
99
+ })
100
+ end
101
+ end
102
+
103
+ end
104
+ end
105
+ end
106
+
@@ -7,9 +7,11 @@ module Startback
7
7
  expect(Context.new.to_json).to eql("{}")
8
8
  end
9
9
 
10
- it 'allows installing factories' do
11
- expect(Context.h_factories).to be_empty
12
- expect(SubContext.h_factories.size).to eql(2)
10
+ it 'allows installing factories and dumpers' do
11
+ expect(Context.h_factories.size).to eql(1)
12
+ expect(SubContext.h_factories.size).to eql(3)
13
+ expect(Context.h_dumpers.size).to eql(1)
14
+ expect(SubContext.h_dumpers.size).to eql(3)
13
15
  end
14
16
 
15
17
  it 'has a `to_h` information contract that works as expected' do
@@ -0,0 +1,84 @@
1
+ require 'spec_helper'
2
+
3
+ module Startback
4
+ module Support
5
+ describe Redactor do
6
+
7
+ let(:redactor) do
8
+ Redactor.new
9
+ end
10
+
11
+ it 'applies default blacklists for security reasons' do
12
+ data = {
13
+ token: "will not be dumped",
14
+ a_token: "will not be dumped",
15
+ AToken: "will not be dumped",
16
+ password: "will not be dumped",
17
+ secret: "will not be dumped",
18
+ credentials: "will not be dumped",
19
+ foo: "bar"
20
+ }
21
+ expect(redactor.redact(data)).to eql({
22
+ token: "---redacted---",
23
+ a_token: "---redacted---",
24
+ AToken: "---redacted---",
25
+ password: "---redacted---",
26
+ secret: "---redacted---",
27
+ credentials: "---redacted---",
28
+ foo: "bar",
29
+ })
30
+ end
31
+
32
+ it 'applies default blacklists to data arrays too' do
33
+ data = [{
34
+ token: "will not be dumped",
35
+ a_token: "will not be dumped",
36
+ AToken: "will not be dumped",
37
+ password: "will not be dumped",
38
+ secret: "will not be dumped",
39
+ credentials: "will not be dumped",
40
+ foo: "bar"
41
+ }]
42
+ expect(redactor.redact(data)).to eql([{
43
+ token: "---redacted---",
44
+ a_token: "---redacted---",
45
+ AToken: "---redacted---",
46
+ password: "---redacted---",
47
+ secret: "---redacted---",
48
+ credentials: "---redacted---",
49
+ foo: "bar"
50
+ }])
51
+ end
52
+
53
+ it 'redacts recursively' do
54
+ data = [{
55
+ foo: "bar",
56
+ baz: {
57
+ password: 'will not be dumped'
58
+ }
59
+ }]
60
+ expect(redactor.redact(data)).to eql([{
61
+ foo: "bar",
62
+ baz: {
63
+ password: '---redacted---'
64
+ }
65
+ }])
66
+ end
67
+
68
+ it 'uses the stop words provided at construction' do
69
+ r = Redactor.new(blacklist: "hello and world")
70
+ data = {
71
+ Hello: "bar",
72
+ World: "foo",
73
+ foo: "bar"
74
+ }
75
+ expect(r.redact(data)).to eql({
76
+ Hello: "---redacted---",
77
+ World: "---redacted---",
78
+ foo: "bar"
79
+ })
80
+ end
81
+
82
+ end # class Redactor
83
+ end # module Support
84
+ end # module Startback
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: startback
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.17.3
4
+ version: 0.18.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Bernard Lambeau
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2023-04-20 00:00:00.000000000 Z
11
+ date: 2023-05-19 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rspec
@@ -270,20 +270,20 @@ dependencies:
270
270
  requirements:
271
271
  - - ">="
272
272
  - !ruby/object:Gem::Version
273
- version: 0.20.0
273
+ version: 0.21.0
274
274
  - - "<"
275
275
  - !ruby/object:Gem::Version
276
- version: 0.21.0
276
+ version: 0.22.0
277
277
  type: :runtime
278
278
  prerelease: false
279
279
  version_requirements: !ruby/object:Gem::Requirement
280
280
  requirements:
281
281
  - - ">="
282
282
  - !ruby/object:Gem::Version
283
- version: 0.20.0
283
+ version: 0.21.0
284
284
  - - "<"
285
285
  - !ruby/object:Gem::Version
286
- version: 0.21.0
286
+ version: 0.22.0
287
287
  - !ruby/object:Gem::Dependency
288
288
  name: tzinfo
289
289
  requirement: !ruby/object:Gem::Requirement
@@ -382,9 +382,16 @@ files:
382
382
  - Rakefile
383
383
  - lib/startback.rb
384
384
  - lib/startback/audit.rb
385
+ - lib/startback/audit/ext.rb
386
+ - lib/startback/audit/ext/context.rb
387
+ - lib/startback/audit/middleware.rb
388
+ - lib/startback/audit/null_tracer.rb
389
+ - lib/startback/audit/operation_tracer.rb
385
390
  - lib/startback/audit/prometheus.rb
386
391
  - lib/startback/audit/shared.rb
387
- - lib/startback/audit/trailer.rb
392
+ - lib/startback/audit/span.rb
393
+ - lib/startback/audit/trace_logger.rb
394
+ - lib/startback/audit/tracer.rb
388
395
  - lib/startback/caching/entity_cache.rb
389
396
  - lib/startback/caching/no_store.rb
390
397
  - lib/startback/caching/store.rb
@@ -419,6 +426,7 @@ files:
419
426
  - lib/startback/support/log_formatter.rb
420
427
  - lib/startback/support/logger.rb
421
428
  - lib/startback/support/operation_runner.rb
429
+ - lib/startback/support/redactor.rb
422
430
  - lib/startback/support/robustness.rb
423
431
  - lib/startback/support/transaction_manager.rb
424
432
  - lib/startback/support/transaction_policy.rb
@@ -436,8 +444,11 @@ files:
436
444
  - lib/startback/web/prometheus.rb
437
445
  - lib/startback/web/shield.rb
438
446
  - spec/spec_helper.rb
447
+ - spec/unit/audit/ext/test_context.rb
448
+ - spec/unit/audit/test_middleware.rb
439
449
  - spec/unit/audit/test_prometheus.rb
440
- - spec/unit/audit/test_trailer.rb
450
+ - spec/unit/audit/test_trace_logger.rb
451
+ - spec/unit/audit/test_tracer.rb
441
452
  - spec/unit/caching/test_entity_cache.rb
442
453
  - spec/unit/context/test_abstraction_factory.rb
443
454
  - spec/unit/context/test_dup.rb
@@ -454,6 +465,7 @@ files:
454
465
  - spec/unit/support/operation_runner/test_before_after_call.rb
455
466
  - spec/unit/support/test_data_object.rb
456
467
  - spec/unit/support/test_env.rb
468
+ - spec/unit/support/test_redactor.rb
457
469
  - spec/unit/support/test_robusteness.rb
458
470
  - spec/unit/support/test_transaction_manager.rb
459
471
  - spec/unit/support/test_world.rb
@@ -1,132 +0,0 @@
1
- require_relative 'shared'
2
- require 'forwardable'
3
- module Startback
4
- module Audit
5
- #
6
- # Log & Audit trail abstraction, that can be registered as an around
7
- # hook on OperationRunner and as an actual logger on Context instances.
8
- #
9
- # The trail is outputted as JSON lines, using a Logger on the "device"
10
- # passed at construction. The following JSON entries are dumped:
11
- #
12
- # - severity : INFO or ERROR
13
- # - time : ISO8601 Datetime of operation execution
14
- # - op : class name of the operation executed
15
- # - op_took : Execution duration of the operation
16
- # - op_data : Dump of operation input data
17
- # - context : Execution context, through its `h` information contract (IC)
18
- #
19
- # Dumping of operation data follows the following duck typing conventions:
20
- #
21
- # - If the operation instance responds to `to_trail`, this data is taken
22
- # - If the operation instance responds to `input`, this data is taken
23
- # - If the operation instance responds to `request`, this data is taken
24
- # - Otherwise op_data is a JSON null
25
- #
26
- # By contributing to the Context's `h` IC, users can easily dump information that
27
- # makes sense (such as the operation execution requester).
28
- #
29
- # The class implements a sanitization process when dumping the context and
30
- # operation data. Blacklisted words taken in construction options are used to
31
- # prevent dumping hash keys that match them (insentively). Default stop words
32
- # are equivalent to:
33
- #
34
- # Trailer.new("/var/log/trail.log", {
35
- # blacklist: "token password secret credential"
36
- # })
37
- #
38
- # Please note that the sanitization process does not apply recursively if
39
- # the operation data is hierarchic. It only applies to the top object of
40
- # Hash and [Hash]. Use `Operation#to_trail` to fine-tune your audit trail.
41
- #
42
- # Given that this Trailer is intended to be used as around hook on an
43
- # `OperationRunner`, operations that fail at construction time will not be
44
- # trailed at all, since they can't be ran in the first place. This may lead
45
- # to trails not containing important errors cases if operations check their
46
- # input at construction time.
47
- #
48
- class Trailer
49
- include Shared
50
- extend Forwardable
51
- def_delegators :@logger, :debug, :info, :warn, :error, :fatal
52
-
53
- DEFAULT_OPTIONS = {
54
-
55
- # Words used to stop dumping for, e.g., security reasons
56
- blacklist: "token password secret credential"
57
-
58
- }
59
-
60
- def initialize(device, options = {})
61
- @options = DEFAULT_OPTIONS.merge(options)
62
- @logger = ::Logger.new(device, 'daily')
63
- @logger.formatter = Support::LogFormatter.new
64
- end
65
- attr_reader :logger, :options
66
-
67
- def call(runner, op)
68
- result = nil
69
- time = Benchmark.realtime{ result = yield }
70
- logger.info(op_to_trail(op, time))
71
- result
72
- rescue Startback::Errors::BadRequestError => ex
73
- logger.warn(op_to_trail(op, time, ex))
74
- raise
75
- rescue => ex
76
- logger.error(op_to_trail(op, time, ex))
77
- raise
78
- end
79
-
80
- protected
81
-
82
- def op_to_trail(op, time = nil, ex = nil)
83
- log_msg = {
84
- op_took: time ? time.round(8) : nil,
85
- op: op_name(op),
86
- context: op_context(op),
87
- op_data: op_data(op)
88
- }.compact
89
- log_msg[:error] = ex if ex
90
- log_msg
91
- end
92
-
93
- def op_context(op)
94
- sanitize(op.respond_to?(:context, false) ? op.context.to_h : {})
95
- end
96
-
97
- def op_data(op)
98
- data = if op.respond_to?(:op_data, false)
99
- op.op_data
100
- elsif op.respond_to?(:to_trail, false)
101
- op.to_trail
102
- elsif op.respond_to?(:input, false)
103
- op.input
104
- elsif op.respond_to?(:request, false)
105
- op.request
106
- elsif op.is_a?(Operation::MultiOperation)
107
- op.ops.map{ |sub_op| op_to_trail(sub_op) }
108
- end
109
- sanitize(data)
110
- end
111
-
112
- def sanitize(data)
113
- case data
114
- when Hash, OpenStruct
115
- data.dup.delete_if{|k| k.to_s =~ blacklist_rx }
116
- when Enumerable
117
- data.map{|elm| sanitize(elm) }.compact
118
- else
119
- data
120
- end
121
- end
122
-
123
- def blacklist_rx
124
- @blacklist_rx ||= Regexp.new(
125
- options[:blacklist].split(/\s+/).join("|"),
126
- Regexp::IGNORECASE
127
- )
128
- end
129
-
130
- end # class Trailer
131
- end # module Audit
132
- end # module Startback
@@ -1,105 +0,0 @@
1
- require 'spec_helper'
2
- require 'startback/audit'
3
- module Startback
4
- module Audit
5
- describe Trailer do
6
-
7
- let(:trailer) {
8
- Trailer.new("/tmp/trail.log")
9
- }
10
-
11
- describe "op_name" do
12
-
13
- def op_name(op, trailer = self.trailer)
14
- trailer.send(:op_name, op)
15
- end
16
-
17
- it 'uses op_name in priority if provided' do
18
- op = OpenStruct.new(op_name: "foo")
19
- expect(op_name(op)).to eql("foo")
20
- end
21
- end
22
-
23
- describe "op_data" do
24
-
25
- def op_data(op, trailer = self.trailer)
26
- trailer.send(:op_data, op)
27
- end
28
-
29
- it 'uses op_data in priority if provided' do
30
- op = OpenStruct.new(op_data: { foo: "bar" }, input: 12, request: 13)
31
- expect(op_data(op)).to eql({ foo: "bar" })
32
- end
33
-
34
- it 'uses to_trail then' do
35
- op = OpenStruct.new(to_trail: { foo: "bar" }, input: 12, request: 13)
36
- expect(op_data(op)).to eql({ foo: "bar" })
37
- end
38
-
39
- it 'uses input then' do
40
- op = OpenStruct.new(input: { foo: "bar" }, request: 13)
41
- expect(op_data(op)).to eql({ foo: "bar" })
42
- end
43
-
44
- it 'uses request then' do
45
- op = OpenStruct.new(request: { foo: "bar" })
46
- expect(op_data(op)).to eql({ foo: "bar" })
47
- end
48
-
49
- it 'applies default blacklists for security reasons' do
50
- op = OpenStruct.new(input: {
51
- token: "will not be dumped",
52
- a_token: "will not be dumped",
53
- AToken: "will not be dumped",
54
- password: "will not be dumped",
55
- secret: "will not be dumped",
56
- credentials: "will not be dumped",
57
- foo: "bar"
58
- })
59
- expect(op_data(op)).to eql({
60
- foo: "bar"
61
- })
62
- end
63
-
64
- it 'applies default blacklists to data arrays too' do
65
- op = OpenStruct.new(input: [{
66
- token: "will not be dumped",
67
- a_token: "will not be dumped",
68
- AToken: "will not be dumped",
69
- password: "will not be dumped",
70
- secret: "will not be dumped",
71
- credentials: "will not be dumped",
72
- foo: "bar"
73
- }])
74
- expect(op_data(op)).to eql([{
75
- foo: "bar"
76
- }])
77
- end
78
-
79
- it 'uses the stop words provided at construction' do
80
- t = Trailer.new("/tmp/trail.log", blacklist: "hello and world")
81
- op = OpenStruct.new(request: { Hello: "bar", World: "foo", foo: "bar" })
82
- expect(op_data(op, t)).to eql({ foo: "bar" })
83
- end
84
-
85
- end
86
-
87
- describe "op_context" do
88
-
89
- def op_context(op, trailer = self.trailer)
90
- trailer.send(:op_context, op)
91
- end
92
-
93
- it 'applies default blacklists for security reasons' do
94
- op = OpenStruct.new(context: {
95
- token: "will not be dumped",
96
- foo: "bar"
97
- })
98
- expect(op_context(op)).to eql({ foo: "bar" })
99
- end
100
-
101
- end
102
-
103
- end
104
- end
105
- end