julewire-gcp 1.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.
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Julewire
4
+ module GCP
5
+ module FormatterOptions
6
+ ALLOWED_KEYS = %i[
7
+ label_formatter
8
+ label_options
9
+ max_label_key_bytes
10
+ max_label_value_bytes
11
+ max_labels
12
+ span_id_path
13
+ trace_id_path
14
+ trace_sampled_path
15
+ ].freeze
16
+
17
+ class << self
18
+ def validate!(options)
19
+ Core::Validation.validate_options!(options, ALLOWED_KEYS, name: :formatter)
20
+ end
21
+
22
+ def trace_headers_paths(paths)
23
+ Array(paths).filter_map { normalize_path(it, min_length: 1) }.freeze
24
+ end
25
+
26
+ def trace_value_path(path)
27
+ normalize_path(path, min_length: 2)
28
+ end
29
+
30
+ def label_formatter(options)
31
+ options[:label_formatter] || LabelFormatter.new(**label_options(options))
32
+ end
33
+
34
+ def label_options(options)
35
+ label_options = options.fetch(:label_options, {}).dup
36
+ %i[max_labels max_label_key_bytes max_label_value_bytes].each do |key|
37
+ label_options[key] = options[key] if options.key?(key)
38
+ end
39
+ label_options
40
+ end
41
+
42
+ private
43
+
44
+ def normalize_path(path, min_length:)
45
+ return if path.nil?
46
+
47
+ normalized = Array(path).map { normalize_path_segment(it) }
48
+ return if normalized.length < min_length
49
+
50
+ normalized.freeze
51
+ end
52
+
53
+ def normalize_path_segment(segment)
54
+ segment.is_a?(String) ? segment.to_sym : segment
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Julewire
4
+ module GCP
5
+ module HttpRequestFields
6
+ class << self
7
+ def http_request(record, attributes)
8
+ values = Core::Integration::Values::Shape
9
+ request = {}
10
+ values.append_field(request, "requestMethod", attributes[Core::Fields::AttributeKeys::HTTP_REQUEST_METHOD])
11
+ values.append_field(
12
+ request,
13
+ "requestUrl",
14
+ attributes[Core::Fields::AttributeKeys::URL_FULL] || attributes[Core::Fields::AttributeKeys::URL_PATH]
15
+ )
16
+ values.append_field(request, "status", attributes[Core::Fields::AttributeKeys::HTTP_RESPONSE_STATUS_CODE])
17
+ values.append_field(request, "userAgent", attributes[Core::Fields::AttributeKeys::USER_AGENT_ORIGINAL])
18
+ values.append_field(request, "remoteIp", attributes[Core::Fields::AttributeKeys::CLIENT_ADDRESS])
19
+ values.append_field(
20
+ request,
21
+ "responseSize",
22
+ int64_string(attributes[Core::Fields::AttributeKeys::HTTP_RESPONSE_BODY_SIZE])
23
+ )
24
+ return if request.empty?
25
+
26
+ latency_value = latency(record)
27
+ request["latency"] = latency_value if latency_value
28
+ request
29
+ end
30
+
31
+ private
32
+
33
+ def latency(record)
34
+ duration_ms = record.fetch(:metrics)[:duration_ms]
35
+ seconds = Float(duration_ms) / 1000
36
+ "#{format("%.9f", seconds).sub(/0+\z/, "").delete_suffix(".")}s"
37
+ rescue ArgumentError, TypeError
38
+ nil
39
+ end
40
+
41
+ def int64_string(value)
42
+ Integer(value).to_s
43
+ rescue ArgumentError, TypeError
44
+ nil
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Julewire
4
+ module GCP
5
+ class LabelFormatter
6
+ def initialize(max_labels: GCP::DEFAULT_MAX_LABELS,
7
+ max_label_key_bytes: GCP::DEFAULT_MAX_LABEL_KEY_BYTES,
8
+ max_label_value_bytes: GCP::DEFAULT_MAX_LABEL_VALUE_BYTES)
9
+ @max_labels = validate_count_limit(max_labels, name: :max_labels)
10
+ @max_label_key_bytes = validate_byte_limit(max_label_key_bytes, name: :max_label_key_bytes)
11
+ @max_label_value_bytes = validate_byte_limit(max_label_value_bytes, name: :max_label_value_bytes)
12
+ end
13
+
14
+ def call(labels)
15
+ return if labels.empty?
16
+
17
+ labels.each_with_object({}) do |(key, value), result|
18
+ break result if @max_labels && result.size >= @max_labels
19
+
20
+ label_key = label_key(key)
21
+ next unless label_key
22
+
23
+ result[label_key] = bounded_label_string(value, @max_label_value_bytes)
24
+ end
25
+ end
26
+
27
+ private
28
+
29
+ def label_key(value)
30
+ key = label_string(value)
31
+ return key unless @max_label_key_bytes && key.bytesize > @max_label_key_bytes
32
+
33
+ nil
34
+ end
35
+
36
+ def bounded_label_string(value, max_bytes)
37
+ string = label_string(value)
38
+ return string unless max_bytes && string.bytesize > max_bytes
39
+
40
+ label_string(string.byteslice(0, max_bytes))
41
+ end
42
+
43
+ def label_string(value)
44
+ Core::Serialization::EncodingSanitizer.call(value.to_s)
45
+ end
46
+
47
+ def validate_count_limit(value, name:)
48
+ return if value.nil?
49
+
50
+ Core::Validation.validate_integer_limit!(value, name: name, positive: true)
51
+ end
52
+
53
+ def validate_byte_limit(value, name:)
54
+ Core::Validation.validate_byte_limit!(value, name: name)
55
+ value
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Julewire
4
+ module GCP
5
+ module LogDecoder
6
+ RecordDecoder = Core::CLI::LogFormats::RecordDecoder
7
+ private_constant :RecordDecoder
8
+
9
+ JULEWIRE_SECTION_KEYS = {
10
+ execution: "execution",
11
+ context: "context",
12
+ metrics: "metrics"
13
+ }.freeze
14
+ PAYLOAD_SECTION_KEYS = {
15
+ attributes: "attributes",
16
+ labels: "logging.googleapis.com/labels",
17
+ payload: "payload"
18
+ }.freeze
19
+ private_constant :JULEWIRE_SECTION_KEYS, :PAYLOAD_SECTION_KEYS
20
+
21
+ class << self
22
+ def match?(payload)
23
+ payload[JULEWIRE_PAYLOAD_FIELD].is_a?(Hash)
24
+ end
25
+
26
+ def call(payload)
27
+ julewire = payload.fetch(JULEWIRE_PAYLOAD_FIELD)
28
+ record_base(payload, julewire).merge(
29
+ record_sections(payload, julewire),
30
+ error: RecordDecoder.error(julewire["error"])
31
+ )
32
+ end
33
+
34
+ private
35
+
36
+ def record_base(payload, julewire)
37
+ {
38
+ timestamp: payload["time"] || payload["timestamp"],
39
+ severity: Julewire::Core::Records::Severity.normalize(payload["severity"] || :info),
40
+ kind: RecordDecoder.kind(julewire["kind"] || :point),
41
+ event: julewire["event"],
42
+ message: payload["message"],
43
+ logger: julewire["logger"],
44
+ source: julewire["source"]
45
+ }
46
+ end
47
+
48
+ def record_sections(payload, julewire)
49
+ RecordDecoder.sections(payload) do |section, _source|
50
+ gcp_section_value(section, payload, julewire)
51
+ end
52
+ end
53
+
54
+ def gcp_section_value(section, payload, julewire)
55
+ if (key = JULEWIRE_SECTION_KEYS[section])
56
+ julewire[key]
57
+ elsif (key = PAYLOAD_SECTION_KEYS[section])
58
+ payload[key]
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Julewire
4
+ module GCP
5
+ module LogEncoder
6
+ class << self
7
+ def call(record)
8
+ json_encoder.call(formatter.call(record))
9
+ end
10
+
11
+ private
12
+
13
+ def formatter = @formatter ||= Formatter.new
14
+
15
+ def json_encoder = @json_encoder ||= JsonEncoder.new
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Julewire
4
+ module GCP
5
+ module SourceLocation
6
+ BACKTRACE_PATTERN = /\A(?<file>.+?):(?<line>\d+)(?::in (?:[`'](?<quoted>.*)[`']|(?<plain>.*)))?\z/
7
+ private_constant :BACKTRACE_PATTERN
8
+
9
+ class << self
10
+ def call(options)
11
+ values = Core::Integration::Values::Shape
12
+ location = {}
13
+ values.append_field(location, "file", string_value(options[:file]))
14
+ values.append_field(location, "line", line_value(options[:line]))
15
+ values.append_field(location, "function", string_value(options[:function]))
16
+ location unless location.empty?
17
+ end
18
+
19
+ def from_error(error)
20
+ return unless error.is_a?(Hash)
21
+
22
+ Array(error[:backtrace]).each do |line|
23
+ location = from_backtrace_line(line)
24
+ return location if location
25
+ end
26
+ nil
27
+ end
28
+
29
+ def from_backtrace_line(line)
30
+ match = BACKTRACE_PATTERN.match(line.to_s)
31
+ return unless match
32
+
33
+ call(
34
+ file: match[:file],
35
+ line: match[:line],
36
+ function: match[:quoted] || match[:plain]
37
+ )
38
+ end
39
+
40
+ def string_value(value)
41
+ return if Core::Integration::Values::Read.blank?(value)
42
+
43
+ value.to_s
44
+ end
45
+
46
+ def line_value(value)
47
+ string = value.to_s
48
+ string if string.match?(/\A\d+\z/)
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Julewire
4
+ module GCP
5
+ module SourceLocationOptions
6
+ EMPTY_HASH = {}.freeze
7
+ private_constant :EMPTY_HASH
8
+
9
+ class << self
10
+ def call(record, neutral_attributes)
11
+ options = record.dig(:payload, :gcp, :source_location)
12
+ return options if options.is_a?(Hash)
13
+
14
+ from_neutral_attributes(neutral_attributes)
15
+ end
16
+
17
+ def from_neutral_attributes(neutral_attributes)
18
+ file = neutral_attributes[Core::Fields::AttributeKeys::CODE_FILE_PATH]
19
+ line = neutral_attributes[Core::Fields::AttributeKeys::CODE_LINE_NUMBER]
20
+ function = neutral_attributes[Core::Fields::AttributeKeys::CODE_FUNCTION_NAME]
21
+ return EMPTY_HASH if file.nil? && line.nil? && function.nil?
22
+
23
+ values = Core::Integration::Values::Shape
24
+ options = {}
25
+ values.append_field(options, :file, file)
26
+ values.append_field(options, :line, line)
27
+ values.append_field(options, :function, function)
28
+ options
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Julewire
4
+ module GCP
5
+ module StackTrace
6
+ class << self
7
+ def call(error)
8
+ return unless error.is_a?(Hash)
9
+
10
+ lines = lines(error)
11
+ lines.join("\n") unless lines.empty?
12
+ end
13
+
14
+ def remove_backtraces(value)
15
+ case value
16
+ when Hash
17
+ value.each_with_object({}) do |(key, item), copy|
18
+ next if key == :backtrace
19
+
20
+ copy[key] = remove_backtraces(item)
21
+ end
22
+ when Array
23
+ value.map { remove_backtraces(it) }
24
+ else
25
+ value
26
+ end
27
+ end
28
+
29
+ private
30
+
31
+ def lines(error)
32
+ backtrace = Array(error[:backtrace])
33
+ cause = error[:cause]
34
+ cause_lines = cause.is_a?(Hash) ? lines(cause) : []
35
+ return [] if backtrace.empty? && cause_lines.empty?
36
+
37
+ summary = Core::Records::DisplayMessage.error_summary(error)
38
+ [summary, *backtrace, *prefixed_cause_lines(cause_lines)].compact
39
+ end
40
+
41
+ def prefixed_cause_lines(lines)
42
+ lines.map.with_index { |line, index| index.zero? ? "Caused by: #{line}" : line }
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Julewire
4
+ module GCP
5
+ module TraceContext
6
+ module Traceparent
7
+ TRACEPARENT_PATTERN =
8
+ /\A[[:xdigit:]]{2}-[[:xdigit:]]{32}-[[:xdigit:]]{16}-[[:xdigit:]]{2}(?:-.*)?\z/
9
+ MIN_BYTES = 55
10
+ TRACE_ID_OFFSET = 3
11
+ TRACE_ID_BYTES = 32
12
+ SPAN_ID_OFFSET = 36
13
+ SPAN_ID_BYTES = 16
14
+ TRACE_FLAGS_OFFSET = 53
15
+ private_constant :TRACEPARENT_PATTERN,
16
+ :MIN_BYTES,
17
+ :TRACE_ID_OFFSET,
18
+ :TRACE_ID_BYTES,
19
+ :SPAN_ID_OFFSET,
20
+ :SPAN_ID_BYTES,
21
+ :TRACE_FLAGS_OFFSET
22
+
23
+ class << self
24
+ def call(value)
25
+ value = value.to_s.scrub.strip
26
+ return unless TRACEPARENT_PATTERN.match?(value)
27
+
28
+ parse_value(value)
29
+ end
30
+
31
+ private
32
+
33
+ def parse_value(value)
34
+ version = value.byteslice(0, 2).downcase
35
+ return if version == "ff"
36
+ return if version == "00" && value.byteslice(MIN_BYTES)
37
+
38
+ trace_id = value.byteslice(TRACE_ID_OFFSET, TRACE_ID_BYTES)
39
+ span_id = value.byteslice(SPAN_ID_OFFSET, SPAN_ID_BYTES)
40
+ trace_id.downcase!
41
+ span_id.downcase!
42
+ return if Hex.zero?(trace_id) || Hex.zero?(span_id)
43
+
44
+ context(trace_id, span_id, trace_flags(value).allbits?(1))
45
+ end
46
+
47
+ def context(trace_id, span_id, sampled)
48
+ {
49
+ trace_id: trace_id,
50
+ span_id: span_id,
51
+ trace_sampled: sampled
52
+ }
53
+ end
54
+
55
+ def trace_flags(value)
56
+ Integer(value.byteslice(TRACE_FLAGS_OFFSET, 2), 16)
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Julewire
4
+ module GCP
5
+ module TraceContext
6
+ X_CLOUD_TRACE_PATTERN = %r{\A([[:xdigit:]]{32})(?:/(\d+))?(?:;o=(\d+))?\z}
7
+ TRACEPARENT_HEADER = "traceparent"
8
+ X_CLOUD_TRACE_CONTEXT_HEADER = "x-cloud-trace-context"
9
+ X_CLOUD_TRACE_CONTEXT_UNDERSCORE_HEADER = "x_cloud_trace_context"
10
+ MAX_SPAN_ID = (2**64) - 1
11
+ DIRECT_HEADER_KEYS = {
12
+ traceparent: [:traceparent, TRACEPARENT_HEADER],
13
+ x_cloud_trace_context: [
14
+ :x_cloud_trace_context,
15
+ X_CLOUD_TRACE_CONTEXT_UNDERSCORE_HEADER,
16
+ X_CLOUD_TRACE_CONTEXT_HEADER
17
+ ]
18
+ }.freeze
19
+ CANONICAL_HEADER_NAMES = {
20
+ traceparent: TRACEPARENT_HEADER,
21
+ x_cloud_trace_context: X_CLOUD_TRACE_CONTEXT_HEADER
22
+ }.freeze
23
+ private_constant :DIRECT_HEADER_KEYS, :CANONICAL_HEADER_NAMES
24
+
25
+ module Hex
26
+ class << self
27
+ def zero?(value)
28
+ offset = 0
29
+ while offset < value.bytesize
30
+ return false unless value.getbyte(offset) == 48
31
+
32
+ offset += 1
33
+ end
34
+ true
35
+ end
36
+ end
37
+ end
38
+ private_constant :Hex
39
+
40
+ class << self
41
+ def extract(headers)
42
+ return {} unless headers.respond_to?(:[])
43
+
44
+ parse_traceparent(fetch_header(headers, :traceparent)) ||
45
+ parse_x_cloud_trace_context(fetch_header(headers, :x_cloud_trace_context)) ||
46
+ {}
47
+ end
48
+
49
+ def parse_traceparent(value)
50
+ Traceparent.call(value)
51
+ end
52
+
53
+ def parse_x_cloud_trace_context(value)
54
+ match = X_CLOUD_TRACE_PATTERN.match(value.to_s.scrub.strip)
55
+ return unless match
56
+
57
+ trace_id = match[1].downcase
58
+ return if Hex.zero?(trace_id)
59
+
60
+ context = { trace_id: trace_id }
61
+ if match[2]
62
+ span_id = decimal_span_to_hex(match[2])
63
+ context[:span_id] = span_id if span_id
64
+ end
65
+ context[:trace_sampled] = Integer(match[3], 10).allbits?(1) if match[3]
66
+ context
67
+ end
68
+
69
+ private
70
+
71
+ def fetch_header(headers, key)
72
+ DIRECT_HEADER_KEYS.fetch(key).each do |header_key|
73
+ value = headers[header_key]
74
+ return value unless value.nil?
75
+ end
76
+ return unless headers.respond_to?(:each)
77
+
78
+ canonical_name = CANONICAL_HEADER_NAMES.fetch(key)
79
+ headers.each do |name, value|
80
+ return value if normalize_header_name(name) == canonical_name
81
+ end
82
+ nil
83
+ end
84
+
85
+ def normalize_header_name(name)
86
+ normalized = name.to_s.dup
87
+ normalized.tr!("_", "-")
88
+ normalized.downcase!
89
+ normalized
90
+ end
91
+
92
+ def decimal_span_to_hex(value)
93
+ integer = Integer(value, 10)
94
+ return if integer > MAX_SPAN_ID
95
+
96
+ span_id = integer.to_s(16).rjust(16, "0")
97
+ return if Hex.zero?(span_id)
98
+
99
+ span_id
100
+ end
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Julewire
4
+ module GCP
5
+ VERSION = "1.0.0"
6
+ end
7
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "zeitwerk"
4
+ require "julewire/core"
5
+
6
+ module Julewire
7
+ module GCP
8
+ CARRY_REQUEST_HEADERS = %w[
9
+ traceparent
10
+ tracestate
11
+ x-cloud-trace-context
12
+ ].freeze
13
+ RECOMMENDED_MAX_RECORD_BYTES = 256 * 1024
14
+ DEFAULT_MAX_RECORD_BYTES = RECOMMENDED_MAX_RECORD_BYTES
15
+ DEFAULT_MAX_LABELS = 64
16
+ DEFAULT_MAX_LABEL_KEY_BYTES = 512
17
+ DEFAULT_MAX_LABEL_VALUE_BYTES = 64 * 1024
18
+ JULEWIRE_PAYLOAD_FIELD = "julewire"
19
+
20
+ class << self
21
+ def operation(id: nil, producer: nil, first: nil, last: nil)
22
+ values = Core::Integration::Values::Shape
23
+ operation = {}
24
+ values.append_field(operation, :id, id)
25
+ values.append_field(operation, :producer, producer)
26
+ values.append_field(operation, :first, first)
27
+ values.append_field(operation, :last, last)
28
+ {
29
+ gcp: {
30
+ operation: operation
31
+ }
32
+ }
33
+ end
34
+
35
+ def source_location(file: nil, line: nil, function: nil)
36
+ values = Core::Integration::Values::Shape
37
+ source_location = {}
38
+ values.append_field(source_location, :file, file)
39
+ values.append_field(source_location, :line, line)
40
+ values.append_field(source_location, :function, function)
41
+ {
42
+ gcp: {
43
+ source_location: source_location
44
+ }
45
+ }
46
+ end
47
+ end
48
+ end
49
+
50
+ loader = Zeitwerk::Loader.for_gem_extension(self)
51
+ loader.inflector.inflect("gcp" => "GCP")
52
+ loader.setup
53
+ Core::Destinations.register(:gcp) { |name:, **options| GCP::Destination.new(name: name, **options) }
54
+ Core::CLI::LogFormats.register(:gcp, decoder: GCP::LogDecoder, encoder: GCP::LogEncoder, priority: 100)
55
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "julewire/gcp"