provenance 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +19 -0
  3. data/LICENSE +21 -0
  4. data/README.md +358 -0
  5. data/lib/generators/provenance/install/install_generator.rb +34 -0
  6. data/lib/generators/provenance/install/templates/create_provenance_outbox.rb.tt +21 -0
  7. data/lib/generators/provenance/install/templates/initializer.rb.tt +12 -0
  8. data/lib/provenance/action.rb +160 -0
  9. data/lib/provenance/actor.rb +86 -0
  10. data/lib/provenance/configuration.rb +152 -0
  11. data/lib/provenance/context.rb +30 -0
  12. data/lib/provenance/controller.rb +93 -0
  13. data/lib/provenance/emitter.rb +66 -0
  14. data/lib/provenance/entity_change.rb +73 -0
  15. data/lib/provenance/errors.rb +12 -0
  16. data/lib/provenance/event.rb +42 -0
  17. data/lib/provenance/integrity.rb +75 -0
  18. data/lib/provenance/job.rb +62 -0
  19. data/lib/provenance/json_safe.rb +41 -0
  20. data/lib/provenance/middleware.rb +44 -0
  21. data/lib/provenance/model.rb +123 -0
  22. data/lib/provenance/outbox/record.rb +23 -0
  23. data/lib/provenance/outbox/relay.rb +81 -0
  24. data/lib/provenance/outbox.rb +67 -0
  25. data/lib/provenance/railtie.rb +35 -0
  26. data/lib/provenance/rake.rb +27 -0
  27. data/lib/provenance/recorder.rb +206 -0
  28. data/lib/provenance/redactor.rb +63 -0
  29. data/lib/provenance/registry.rb +48 -0
  30. data/lib/provenance/relay_job.rb +15 -0
  31. data/lib/provenance/rspec.rb +139 -0
  32. data/lib/provenance/serializers/cloud_events.rb +26 -0
  33. data/lib/provenance/serializers/native.rb +16 -0
  34. data/lib/provenance/serializers/ocsf.rb +127 -0
  35. data/lib/provenance/serializers.rb +31 -0
  36. data/lib/provenance/sinks/http.rb +81 -0
  37. data/lib/provenance/sinks/io.rb +24 -0
  38. data/lib/provenance/sinks/kafka.rb +45 -0
  39. data/lib/provenance/sinks/logger.rb +22 -0
  40. data/lib/provenance/sinks/memory.rb +29 -0
  41. data/lib/provenance/sinks/proc.rb +20 -0
  42. data/lib/provenance/sinks.rb +35 -0
  43. data/lib/provenance/testing.rb +36 -0
  44. data/lib/provenance/uuid.rb +20 -0
  45. data/lib/provenance/version.rb +6 -0
  46. data/lib/provenance.rb +166 -0
  47. data/lib/tasks/provenance.rake +22 -0
  48. data/schema/event-2.json +126 -0
  49. metadata +150 -0
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Provenance
4
+ # Replaces sensitive values with "[REDACTED]".
5
+ module Redactor
6
+ # Replacement for redacted values.
7
+ MASK = "[REDACTED]"
8
+
9
+ module_function
10
+
11
+ # @return [Array<Symbol, String, Regexp, Proc>] configured redactions merged with Rails filter_parameters
12
+ def filters
13
+ rails = (defined?(Rails) && Rails.respond_to?(:application) && Rails.application&.config&.filter_parameters) || []
14
+ Array(rails) + Array(Provenance.config.redact_attributes)
15
+ end
16
+
17
+ # @param name [String, Symbol]
18
+ # @param extra [Array<String>] exact attribute names to redact
19
+ # @return [Boolean]
20
+ def redact?(name, extra = [])
21
+ return true if extra.include?(name.to_s)
22
+
23
+ filtered = parameter_filter.filter(name.to_s => true)
24
+ filtered[name.to_s] != true
25
+ end
26
+
27
+ # Redacts a diff, keeping its shape.
28
+ #
29
+ # @param diff [Hash, nil]
30
+ # @param extra [Array<String>]
31
+ # @return [Hash, nil]
32
+ def diff(diff, extra = [])
33
+ return diff unless diff.is_a?(Hash)
34
+
35
+ diff.to_h do |key, value|
36
+ next [key, value] unless redact?(key, extra)
37
+
38
+ [key, if value.is_a?(Array)
39
+ value.map { |v| v.nil? ? nil : MASK }
40
+ else
41
+ MASK
42
+ end]
43
+ end
44
+ end
45
+
46
+ # Redacts nested hashes such as metadata.
47
+ #
48
+ # @param hash [Hash]
49
+ # @return [Hash]
50
+ def deep(hash)
51
+ parameter_filter.filter(hash)
52
+ end
53
+
54
+ # @api private
55
+ def parameter_filter
56
+ current = filters
57
+ cached = @parameter_filter
58
+ return cached.last if cached && cached.first == current
59
+
60
+ ActiveSupport::ParameterFilter.new(current, mask: MASK).tap { |f| @parameter_filter = [current, f] }
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Provenance
4
+ # Tracking options of every model declared with +has_provenance+.
5
+ class Registry
6
+ # Options of one tracked model.
7
+ Options = Struct.new(:only, :except, :redact, :associations, :ignore_if) do
8
+ # @param attribute [String]
9
+ # @return [Boolean] whether the attribute belongs in diffs
10
+ def track?(attribute)
11
+ return false if Provenance.config.ignored_attributes.map(&:to_s).include?(attribute)
12
+ return only.include?(attribute) if only.present?
13
+
14
+ !except.include?(attribute)
15
+ end
16
+ end
17
+
18
+ def initialize
19
+ @models = Concurrent::Map.new
20
+ end
21
+
22
+ # @param model [Class]
23
+ # @param options [Options]
24
+ # @return [Options]
25
+ def register(model, options)
26
+ @models[model.name] = options
27
+ end
28
+
29
+ # Options for the model or its closest tracked ancestor (STI).
30
+ #
31
+ # @param model [Class]
32
+ # @return [Options, nil]
33
+ def options_for(model)
34
+ model.ancestors.each do |klass|
35
+ next unless klass.is_a?(Class) && klass.name
36
+
37
+ options = @models[klass.name]
38
+ return options if options
39
+ end
40
+ nil
41
+ end
42
+
43
+ # @return [Array<String>] names of tracked models
44
+ def model_names
45
+ @models.keys
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Provenance
4
+ # Delivers pending outbox rows. Enqueued after every outbox write when
5
+ # +outbox_enqueue_relay+ is on; can also be scheduled periodically.
6
+ class RelayJob < ActiveJob::Base
7
+ skip_provenance if respond_to?(:skip_provenance)
8
+
9
+ # @param batch_size [Integer, nil]
10
+ # @return [Integer] delivered rows
11
+ def perform(batch_size: nil)
12
+ Outbox::Relay.run(batch_size: batch_size || Provenance.config.outbox_batch_size)
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "provenance"
4
+ require "rspec/expectations"
5
+
6
+ module Provenance
7
+ module Testing
8
+ # Block matcher for emitted events.
9
+ #
10
+ # @example
11
+ # expect { patch invoice_path(invoice), params: {status: "sent"} }
12
+ # .to emit_provenance_event(action: "invoices#update", outcome: "success")
13
+ # .with_change(entity: "Invoice", operation: "update", diff: including(status: ["draft", "sent"]))
14
+ class EmitEventMatcher
15
+ include RSpec::Matchers::Composable
16
+
17
+ # Expected attribute name -> path in the native event.
18
+ PATHS = {
19
+ action: %w[action name], kind: %w[action kind], caused_by: %w[action caused_by],
20
+ outcome: %w[outcome result], error: %w[outcome error], actor: %w[actor], actor_id: %w[actor id],
21
+ request: %w[request], status: %w[request status], metadata: %w[metadata], app: %w[app]
22
+ }.freeze
23
+
24
+ # @param expected [Hash] any of the keys in {PATHS}
25
+ def initialize(expected)
26
+ unknown = expected.keys - PATHS.keys
27
+ raise ArgumentError, "unknown keys #{unknown.inspect}" if unknown.any?
28
+
29
+ @expected = expected
30
+ @changes = []
31
+ @count = nil
32
+ end
33
+
34
+ # Requires a change matching the given attributes in the same event.
35
+ #
36
+ # @param expected [Hash] entity, entity_id, operation, diff, association, count, ids
37
+ # @return [self]
38
+ def with_change(**expected)
39
+ @changes << expected
40
+ self
41
+ end
42
+
43
+ # Requires exactly +n+ matching events.
44
+ #
45
+ # @param n [Integer]
46
+ # @return [self]
47
+ def exactly(n)
48
+ @count = n
49
+ self
50
+ end
51
+
52
+ # Alias for +exactly(1)+.
53
+ #
54
+ # @return [self]
55
+ def once
56
+ exactly(1)
57
+ end
58
+
59
+ # @return [Boolean]
60
+ def supports_block_expectations?
61
+ true
62
+ end
63
+
64
+ # @return [Boolean]
65
+ def supports_value_expectations?
66
+ false
67
+ end
68
+
69
+ # @param block [Proc]
70
+ # @return [Boolean]
71
+ def matches?(block)
72
+ @events = Testing.capture(&block)
73
+ @matching = @events.select { |event| event_matches?(event) }
74
+ @count ? @matching.size == @count : @matching.any?
75
+ end
76
+
77
+ # @param block [Proc]
78
+ # @return [Boolean]
79
+ def does_not_match?(block)
80
+ raise ArgumentError, "exactly/once is not supported with not_to" if @count
81
+
82
+ @events = Testing.capture(&block)
83
+ @matching = @events.select { |event| event_matches?(event) }
84
+ @matching.empty?
85
+ end
86
+
87
+ # @return [String]
88
+ def description
89
+ "emit provenance event #{description_of(@expected)}" +
90
+ @changes.map { |c| " with change #{description_of(c)}" }.join
91
+ end
92
+
93
+ # @return [String]
94
+ def failure_message
95
+ "expected block to #{description}#{" #{@count} time(s)" if @count}, " \
96
+ "but #{@matching.size} of #{@events.size} emitted event(s) matched:\n#{dump}"
97
+ end
98
+
99
+ # @return [String]
100
+ def failure_message_when_negated
101
+ "expected block not to #{description}, but it emitted:\n#{dump(@matching)}"
102
+ end
103
+
104
+ private
105
+
106
+ def event_matches?(event)
107
+ @expected.all? { |key, value| values_match?(value, indifferent(event.dig(*PATHS.fetch(key)))) } &&
108
+ @changes.all? { |expected| event["changes"].any? { |change| change_matches?(expected, change) } }
109
+ end
110
+
111
+ def change_matches?(expected, change)
112
+ expected.all? { |key, value| values_match?(value, indifferent(change[key.to_s])) }
113
+ end
114
+
115
+ def indifferent(value)
116
+ case value
117
+ when Hash then value.to_h { |k, v| [k, indifferent(v)] }.with_indifferent_access
118
+ when Array then value.map { |v| indifferent(v) }
119
+ else value
120
+ end
121
+ end
122
+
123
+ def dump(events = @events)
124
+ events.map { |event| " #{JSON.generate(event)}" }.join("\n")
125
+ end
126
+ end
127
+
128
+ # RSpec helpers, included into every example group.
129
+ module Matchers
130
+ # @param expected [Hash] see {EmitEventMatcher::PATHS}
131
+ # @return [EmitEventMatcher]
132
+ def emit_provenance_event(**expected)
133
+ EmitEventMatcher.new(expected)
134
+ end
135
+ end
136
+ end
137
+ end
138
+
139
+ RSpec.configure { |config| config.include Provenance::Testing::Matchers } if defined?(RSpec.configure)
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Provenance
4
+ module Serializers
5
+ # CloudEvents 1.0 structured-mode JSON with the native event as +data+.
6
+ module CloudEvents
7
+ module_function
8
+
9
+ # @param event [Hash] native event
10
+ # @return [Array<Hash>]
11
+ def call(event)
12
+ [{
13
+ "specversion" => "1.0",
14
+ "id" => event["id"],
15
+ "source" => event["app"],
16
+ "type" => "provenance.action.#{event.dig("action", "kind")}",
17
+ "subject" => event.dig("action", "name"),
18
+ "time" => event["occurred_at"],
19
+ "datacontenttype" => "application/json",
20
+ "dataschema" => "urn:provenance:event:2",
21
+ "data" => event
22
+ }]
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Provenance
4
+ module Serializers
5
+ # The native "provenance/event@2" format, as built by {Event.build}.
6
+ module Native
7
+ module_function
8
+
9
+ # @param event [Hash] native event
10
+ # @return [Array<Hash>]
11
+ def call(event)
12
+ [event]
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Provenance
4
+ module Serializers
5
+ # OCSF 1.x: one API Activity (class_uid 6003) event per action plus one Entity
6
+ # Management (class_uid 3004) event per entity change. The README documents the
7
+ # field mapping.
8
+ module OCSF
9
+ # OCSF schema version the events declare.
10
+ VERSION = "1.3.0"
11
+ # API Activity class.
12
+ API_ACTIVITY = {class_uid: 6003, category_uid: 6, class_name: "API Activity", category_name: "Application Activity"}.freeze
13
+ # Entity Management class.
14
+ ENTITY_MANAGEMENT = {class_uid: 3004, category_uid: 3, class_name: "Entity Management", category_name: "Identity & Access Management"}.freeze
15
+ # activity_id by HTTP method for API Activity.
16
+ HTTP_ACTIVITIES = {"POST" => 1, "GET" => 2, "HEAD" => 2, "PUT" => 3, "PATCH" => 3, "DELETE" => 4}.freeze
17
+ # activity_id by change operation for Entity Management.
18
+ CHANGE_ACTIVITIES = {
19
+ "create" => 1, "bulk_insert" => 1, "update" => 3, "bulk_update" => 3, "destroy" => 4, "bulk_delete" => 4
20
+ }.freeze
21
+ # Activity names for ids used above.
22
+ ACTIVITY_NAMES = {1 => "Create", 2 => "Read", 3 => "Update", 4 => "Delete", 99 => "Other"}.freeze
23
+ # status_id, status and severity_id by outcome.
24
+ OUTCOMES = {
25
+ "success" => [1, "Success", 1],
26
+ "failure" => [2, "Failure", 2],
27
+ "denied" => [2, "Failure", 3]
28
+ }.freeze
29
+
30
+ module_function
31
+
32
+ # @param event [Hash] native event
33
+ # @return [Array<Hash>] the API Activity event followed by Entity Management events
34
+ def call(event)
35
+ [api_activity(event)] + event["changes"].each_with_index.map { |change, i| entity_management(event, change, i) }
36
+ end
37
+
38
+ # @param event [Hash]
39
+ # @return [Hash]
40
+ def api_activity(event)
41
+ request = event["request"] || {}
42
+ activity_id = HTTP_ACTIVITIES.fetch(request["method"].to_s.upcase) { activity_from_changes(event["changes"]) }
43
+ result = base(event, API_ACTIVITY, activity_id, event["id"])
44
+ result["api"] = {
45
+ "operation" => event.dig("action", "name"),
46
+ "request" => {"uid" => request["id"] || event["id"]},
47
+ "response" => response(event)
48
+ }.compact
49
+ result["src_endpoint"] = {"ip" => request["ip"]}.compact
50
+ if event["request"]
51
+ result["http_request"] = {
52
+ "http_method" => request["method"], "url" => {"path" => request["path"]},
53
+ "user_agent" => request["user_agent"], "uid" => request["id"]
54
+ }.compact
55
+ result["http_response"] = {"code" => request["status"]}.compact
56
+ end
57
+ result["resources"] = event["changes"].map { |c| {"type" => c["entity"], "uid" => c["entity_id"]}.compact }
58
+ result
59
+ end
60
+
61
+ # @param event [Hash]
62
+ # @param change [Hash]
63
+ # @param index [Integer]
64
+ # @return [Hash]
65
+ def entity_management(event, change, index)
66
+ activity_id = CHANGE_ACTIVITIES.fetch(change["operation"], 99)
67
+ result = base(event, ENTITY_MANAGEMENT, activity_id, "#{event["id"]}/#{index}")
68
+ result["activity_name"] = change["operation"].capitalize if activity_id == 99
69
+ result["entity"] = {
70
+ "name" => change["entity"], "type" => change["entity"], "uid" => change["entity_id"], "data" => change["diff"]
71
+ }.compact
72
+ result["unmapped"]["change"] = change.except("entity", "entity_id", "diff")
73
+ result
74
+ end
75
+
76
+ # @api private
77
+ def base(event, klass, activity_id, uid)
78
+ status_id, status, severity_id = OUTCOMES.fetch(event.dig("outcome", "result"), [99, "Other", 1])
79
+ {
80
+ "category_uid" => klass[:category_uid], "category_name" => klass[:category_name],
81
+ "class_uid" => klass[:class_uid], "class_name" => klass[:class_name],
82
+ "activity_id" => activity_id, "activity_name" => ACTIVITY_NAMES[activity_id],
83
+ "type_uid" => (klass[:class_uid] * 100) + activity_id,
84
+ "time" => (Time.iso8601(event["occurred_at"]).to_r * 1000).to_i,
85
+ "severity_id" => severity_id,
86
+ "status_id" => status_id, "status" => status,
87
+ "status_detail" => event.dig("outcome", "result"),
88
+ "metadata" => {
89
+ "version" => VERSION, "uid" => uid, "correlation_uid" => event["id"], "log_name" => event["app"],
90
+ "product" => {"name" => "Provenance", "vendor_name" => "Provenance", "version" => Provenance::VERSION}
91
+ },
92
+ "actor" => actor(event["actor"], event["app"]),
93
+ "unmapped" => {
94
+ "action" => event["action"], "metadata" => event["metadata"],
95
+ "impersonator" => event.dig("actor", "impersonator"), "integrity" => event["integrity"]
96
+ }.compact
97
+ }
98
+ end
99
+
100
+ # @api private
101
+ def actor(actor, app)
102
+ result = {"app_name" => app}
103
+ if actor
104
+ result["user"] = {
105
+ "uid" => actor["id"], "name" => actor["display"], "type" => actor["type"],
106
+ "groups" => Array(actor["roles"]).map { |role| {"name" => role} }
107
+ }.compact
108
+ end
109
+ result
110
+ end
111
+
112
+ # @api private
113
+ def response(event)
114
+ error = event.dig("outcome", "error")
115
+ response = {"code" => event.dig("request", "status")}
116
+ response.merge!("error" => error["class"], "error_message" => error["message"]) if error
117
+ response.compact.presence
118
+ end
119
+
120
+ # @api private
121
+ def activity_from_changes(changes)
122
+ ids = changes.map { |c| CHANGE_ACTIVITIES.fetch(c["operation"], 99) }.uniq
123
+ (ids.size == 1) ? ids.first : 99
124
+ end
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "provenance/serializers/native"
4
+ require "provenance/serializers/ocsf"
5
+ require "provenance/serializers/cloud_events"
6
+
7
+ module Provenance
8
+ # Converts native events into the configured wire format.
9
+ module Serializers
10
+ module_function
11
+
12
+ # @param format [Symbol] :native, :ocsf or :cloudevents
13
+ # @return [#call] serializer returning an Array of serialized events per native event
14
+ def for(format)
15
+ case format.to_sym
16
+ when :native then Native
17
+ when :ocsf then OCSF
18
+ when :cloudevents then CloudEvents
19
+ else raise ConfigurationError, "unknown format #{format.inspect}"
20
+ end
21
+ end
22
+
23
+ # @param events [Array<Hash>] native events
24
+ # @param format [Symbol]
25
+ # @return [Array<Hash>] serialized events; OCSF may produce several per native event
26
+ def serialize_all(events, format)
27
+ serializer = self.for(format)
28
+ events.flat_map { |event| serializer.call(event) }
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+
6
+ module Provenance
7
+ module Sinks
8
+ # POSTs each batch as a JSON array. Any 2xx response is a success; other
9
+ # responses and network errors are retried with exponential backoff and jitter.
10
+ class HTTP
11
+ # Errors treated as retryable network failures.
12
+ NETWORK_ERRORS = [
13
+ IOError, SystemCallError, SocketError, Timeout::Error, OpenSSL::SSL::SSLError, Net::ProtocolError
14
+ ].freeze
15
+
16
+ # @return [URI::HTTP]
17
+ attr_reader :uri
18
+
19
+ # @param url [String]
20
+ # @param headers [Hash{String => String}]
21
+ # @param open_timeout [Numeric] seconds
22
+ # @param read_timeout [Numeric] seconds
23
+ # @param retries [Integer] retries after the first attempt
24
+ # @param backoff [Numeric] base delay in seconds, doubled per retry
25
+ # @param max_backoff [Numeric] delay cap in seconds
26
+ # @param sleeper [#call] receives the delay; replaceable in tests
27
+ def initialize(url:, headers: {}, open_timeout: 2, read_timeout: 5, retries: 3, backoff: 0.5,
28
+ max_backoff: 30, sleeper: ->(seconds) { sleep(seconds) })
29
+ @uri = URI.parse(url.to_s)
30
+ raise ConfigurationError, "http sink needs an http(s) url" unless @uri.is_a?(URI::HTTP)
31
+
32
+ @headers = {"Content-Type" => "application/json"}.merge(headers.transform_keys(&:to_s))
33
+ @open_timeout = open_timeout
34
+ @read_timeout = read_timeout
35
+ @retries = retries
36
+ @backoff = backoff
37
+ @max_backoff = max_backoff
38
+ @sleeper = sleeper
39
+ end
40
+
41
+ # @param batch [Array<Hash>]
42
+ # @return [void]
43
+ # @raise [DeliveryError] when every attempt failed
44
+ def deliver(batch)
45
+ body = JSON.generate(batch)
46
+ attempt = 0
47
+ begin
48
+ post(body)
49
+ rescue DeliveryError, *NETWORK_ERRORS => e
50
+ raise DeliveryError, "#{uri.host}: #{e.message}" if attempt >= @retries
51
+
52
+ @sleeper.call(delay(attempt))
53
+ attempt += 1
54
+ retry
55
+ end
56
+ end
57
+
58
+ # @param attempt [Integer] zero-based retry number
59
+ # @return [Float] seconds to wait before the retry
60
+ def delay(attempt)
61
+ base = [@backoff * (2**attempt), @max_backoff].min
62
+ base / 2.0 + rand * base / 2.0
63
+ end
64
+
65
+ private
66
+
67
+ def post(body)
68
+ http = Net::HTTP.new(uri.host, uri.port)
69
+ http.use_ssl = uri.scheme == "https"
70
+ http.open_timeout = @open_timeout
71
+ http.read_timeout = @read_timeout
72
+ request = Net::HTTP::Post.new(uri.request_uri, @headers)
73
+ request.body = body
74
+ response = http.request(request)
75
+ return response if response.code.to_i.between?(200, 299)
76
+
77
+ raise DeliveryError, "HTTP #{response.code}"
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Provenance
4
+ module Sinks
5
+ # Writes one JSON line per event to any IO.
6
+ class IO
7
+ # @param io [#write]
8
+ def initialize(io = $stdout)
9
+ @io = io
10
+ @lock = Mutex.new
11
+ end
12
+
13
+ # @param batch [Array<Hash>]
14
+ # @return [void]
15
+ def deliver(batch)
16
+ lines = batch.map { |event| "#{JSON.generate(event)}\n" }.join
17
+ @lock.synchronize do
18
+ @io.write(lines)
19
+ @io.flush if @io.respond_to?(:flush)
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ begin
4
+ require "rdkafka"
5
+ rescue LoadError
6
+ raise LoadError, "the :kafka sink needs the rdkafka gem; add it to your Gemfile"
7
+ end
8
+
9
+ module Provenance
10
+ module Sinks
11
+ # Produces one Kafka message per event, keyed by event id. Optional: requires
12
+ # the +rdkafka+ gem.
13
+ #
14
+ # @example
15
+ # require "provenance/sinks/kafka"
16
+ # c.sink :kafka, topic: "audit", config: {"bootstrap.servers": "kafka:9092"}
17
+ class Kafka
18
+ # @param topic [String]
19
+ # @param config [Hash] rdkafka producer configuration
20
+ # @param producer [Object, nil] an existing rdkafka producer
21
+ # @param timeout [Numeric] seconds to wait for delivery reports
22
+ def initialize(topic:, config: {}, producer: nil, timeout: 10)
23
+ @topic = topic
24
+ @producer = producer || Rdkafka::Config.new(config).producer
25
+ @timeout = timeout
26
+ end
27
+
28
+ # @param batch [Array<Hash>]
29
+ # @return [void]
30
+ def deliver(batch)
31
+ handles = batch.map do |event|
32
+ @producer.produce(topic: @topic, payload: JSON.generate(event), key: (event["id"] || event.dig("metadata", "uid")).to_s)
33
+ end
34
+ handles.each { |handle| handle.wait(max_wait_timeout: @timeout) }
35
+ end
36
+
37
+ # Closes the underlying producer.
38
+ #
39
+ # @return [void]
40
+ def close
41
+ @producer.close
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Provenance
4
+ module Sinks
5
+ # Writes one JSON line per event to a logger (Rails.logger by default).
6
+ class Logger
7
+ # @param logger [::Logger, nil] defaults to the configured Provenance logger
8
+ # @param level [Symbol] log level
9
+ def initialize(logger: nil, level: :info)
10
+ @logger = logger
11
+ @level = level
12
+ end
13
+
14
+ # @param batch [Array<Hash>]
15
+ # @return [void]
16
+ def deliver(batch)
17
+ logger = @logger || Provenance.config.logger
18
+ batch.each { |event| logger.public_send(@level, JSON.generate(event)) }
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Provenance
4
+ module Sinks
5
+ # Keeps delivered events in memory. Intended for tests.
6
+ class Memory
7
+ def initialize
8
+ @events = []
9
+ @lock = Mutex.new
10
+ end
11
+
12
+ # @param batch [Array<Hash>]
13
+ # @return [void]
14
+ def deliver(batch)
15
+ @lock.synchronize { @events.concat(batch) }
16
+ end
17
+
18
+ # @return [Array<Hash>] a copy of the delivered events
19
+ def events
20
+ @lock.synchronize { @events.dup }
21
+ end
22
+
23
+ # @return [void]
24
+ def clear
25
+ @lock.synchronize { @events.clear }
26
+ end
27
+ end
28
+ end
29
+ end