startback 0.17.4 → 0.18.1

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5d9b3532828f0cd3b82d8a2df96cd5343b98d1c3c55c8c6d3dd1402c24fbbcd8
4
- data.tar.gz: e7b19db3af487c0c725e35b923c136dad05daa7b2c0be829dc91ab3be01fe885
3
+ metadata.gz: b78dea8794d36995b6941280807a9766deae9604d00ef51f96f88a9f06206142
4
+ data.tar.gz: 95457f1df4d0a3d6341414a17cfb3da56c7c3ad89466322b78e0fd2d9ab049a8
5
5
  SHA512:
6
- metadata.gz: bb5bfea0e5dec4b691fc908260f9af6bb42bdd829954673dd00adc83433fd3bb2ceee01430045ec4b263230a4f4072f00268d8d189bf2c636de8cf7bd129a1b1
7
- data.tar.gz: c28211dca05b881de6d3368152234a7e9b331eda5355d21c57c27a8860a45dc4f45cd730203d02c94d4411da6a784c7fbb356a1129e6530ff6f7b37c93196d90
6
+ metadata.gz: 3c08dc7d170c6a3baf9889a050ae0f239dee3d9cd0d8d3f8952b778026f8f1fd1a72979143bc553cf2b8bc16df816a14618bab067f924d4eb0f28774484685a5
7
+ data.tar.gz: 66547cfef8f9d27f0b9e45d4781fc0db225d753d610bbb95c7e1856bf006fedc48bec30753da0ac10607f4af0219f340db01da2b151f79c3257add9723c23a61
@@ -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,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 = 4
4
+ MINOR = 18
5
+ TINY = 1
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.4
4
+ version: 0.18.1
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