chronos-ruby 0.1.0.pre.2
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 +7 -0
- data/CHANGELOG.md +34 -0
- data/CODE_OF_CONDUCT.md +74 -0
- data/CONTRIBUTING.md +23 -0
- data/LICENSE.txt +21 -0
- data/README.md +251 -0
- data/SECURITY.md +9 -0
- data/contracts/event-v1.schema.json +51 -0
- data/docs/adr/ADR-001-core-and-integrations.md +25 -0
- data/docs/adr/ADR-002-version-lines.md +25 -0
- data/docs/adr/ADR-003-net-http-legacy-transport.md +25 -0
- data/docs/adr/ADR-004-bounded-queue.md +25 -0
- data/docs/adr/ADR-005-sanitize-before-backlog.md +25 -0
- data/docs/adr/ADR-006-versioned-event-contract.md +25 -0
- data/docs/architecture.md +31 -0
- data/docs/compatibility.md +21 -0
- data/docs/configuration.md +40 -0
- data/docs/data-collected.md +20 -0
- data/docs/examples/plain-ruby.md +12 -0
- data/docs/modules/async-queue.md +17 -0
- data/docs/modules/configuration.md +9 -0
- data/docs/modules/notice-pipeline.md +20 -0
- data/docs/modules/transport.md +9 -0
- data/docs/performance.md +16 -0
- data/docs/privacy-lgpd.md +16 -0
- data/docs/troubleshooting.md +25 -0
- data/lib/chronos/adapters/net_http_transport.rb +132 -0
- data/lib/chronos/adapters.rb +10 -0
- data/lib/chronos/agent.rb +57 -0
- data/lib/chronos/application/capture_exception.rb +61 -0
- data/lib/chronos/application.rb +10 -0
- data/lib/chronos/configuration.rb +139 -0
- data/lib/chronos/core/backtrace_parser.rb +74 -0
- data/lib/chronos/core/exception_cause_collector.rb +63 -0
- data/lib/chronos/core/notice.rb +53 -0
- data/lib/chronos/core/notice_builder.rb +83 -0
- data/lib/chronos/core/payload_serializer.rb +165 -0
- data/lib/chronos/core/runtime_info.rb +42 -0
- data/lib/chronos/core.rb +10 -0
- data/lib/chronos/errors.rb +24 -0
- data/lib/chronos/internal/bounded_queue.rb +79 -0
- data/lib/chronos/internal/safe_logger.rb +55 -0
- data/lib/chronos/internal/worker_pool.rb +155 -0
- data/lib/chronos/internal.rb +10 -0
- data/lib/chronos/ports/transport.rb +45 -0
- data/lib/chronos/ports.rb +10 -0
- data/lib/chronos/ruby/version.rb +1 -0
- data/lib/chronos/ruby.rb +1 -0
- data/lib/chronos/version.rb +4 -0
- data/lib/chronos.rb +98 -0
- metadata +156 -0
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
module Chronos
|
|
2
|
+
module Core
|
|
3
|
+
# Parses CRuby and common JRuby backtrace lines without rejecting unknown input.
|
|
4
|
+
#
|
|
5
|
+
# @responsibility Convert backtrace strings into bounded structured frames.
|
|
6
|
+
# @motivation Give the SaaS stable fields for display and grouping.
|
|
7
|
+
# @limits It does not read source files or collect code hunks.
|
|
8
|
+
# @collaborators NoticeBuilder.
|
|
9
|
+
# @thread_safety Stateless and safe to reuse between threads.
|
|
10
|
+
# @compatibility CRuby 2.2.10 through 2.6 and common JRuby line formats.
|
|
11
|
+
# @example
|
|
12
|
+
# parser.call(["app/job.rb:12:in `call'"])
|
|
13
|
+
# @performance Linear in the number of frames, capped by max_frames.
|
|
14
|
+
class BacktraceParser
|
|
15
|
+
DEFAULT_MAX_FRAMES = 200
|
|
16
|
+
|
|
17
|
+
def initialize(root_directory, max_frames = DEFAULT_MAX_FRAMES)
|
|
18
|
+
@root_directory = expand_root(root_directory)
|
|
19
|
+
@max_frames = max_frames
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def call(lines)
|
|
23
|
+
Array(lines).first(@max_frames).map { |line| parse(line) }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def parse(line)
|
|
29
|
+
text = safe_string(line)
|
|
30
|
+
match = text.match(/\A(.+?):(\d+)(?::in [`'](.*)['`])?\z/)
|
|
31
|
+
return unknown_frame(text) unless match
|
|
32
|
+
|
|
33
|
+
file = normalize_file(match[1])
|
|
34
|
+
{
|
|
35
|
+
"file" => file,
|
|
36
|
+
"line" => match[2].to_i,
|
|
37
|
+
"function" => match[3],
|
|
38
|
+
"in_app" => in_app?(file)
|
|
39
|
+
}
|
|
40
|
+
rescue StandardError
|
|
41
|
+
unknown_frame(safe_string(line))
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def unknown_frame(line)
|
|
45
|
+
{"file" => line, "line" => nil, "function" => nil, "in_app" => false}
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def normalize_file(file)
|
|
49
|
+
return file unless @root_directory && file.start_with?(@root_directory + File::SEPARATOR)
|
|
50
|
+
|
|
51
|
+
file[(@root_directory.length + 1)..-1]
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def in_app?(file)
|
|
55
|
+
return false unless @root_directory
|
|
56
|
+
return true unless file.start_with?(File::SEPARATOR)
|
|
57
|
+
|
|
58
|
+
file.start_with?(@root_directory + File::SEPARATOR)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def expand_root(root)
|
|
62
|
+
root && File.expand_path(root.to_s)
|
|
63
|
+
rescue StandardError
|
|
64
|
+
nil
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def safe_string(value)
|
|
68
|
+
value.to_s
|
|
69
|
+
rescue StandardError
|
|
70
|
+
"<unreadable backtrace>"
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
module Chronos
|
|
2
|
+
module Core
|
|
3
|
+
# Collects a bounded exception cause chain with cycle protection.
|
|
4
|
+
#
|
|
5
|
+
# @responsibility Normalize nested Exception#cause values.
|
|
6
|
+
# @motivation Preserve root-cause information without unbounded traversal.
|
|
7
|
+
# @limits It records class and message only; each cause backtrace is handled elsewhere.
|
|
8
|
+
# @collaborators NoticeBuilder.
|
|
9
|
+
# @thread_safety Uses only call-local state.
|
|
10
|
+
# @compatibility Feature-detects Exception#cause for legacy runtimes.
|
|
11
|
+
# @example
|
|
12
|
+
# collector.call(exception)
|
|
13
|
+
# @performance Constant memory bounded by max_depth.
|
|
14
|
+
class ExceptionCauseCollector
|
|
15
|
+
DEFAULT_MAX_DEPTH = 10
|
|
16
|
+
|
|
17
|
+
def initialize(max_depth = DEFAULT_MAX_DEPTH)
|
|
18
|
+
@max_depth = max_depth
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def call(exception)
|
|
22
|
+
causes = []
|
|
23
|
+
seen = {}
|
|
24
|
+
current = exception
|
|
25
|
+
|
|
26
|
+
@max_depth.times do
|
|
27
|
+
break unless current.respond_to?(:cause)
|
|
28
|
+
current = safe_cause(current)
|
|
29
|
+
break unless current
|
|
30
|
+
break if seen[current.object_id]
|
|
31
|
+
|
|
32
|
+
seen[current.object_id] = true
|
|
33
|
+
causes << {
|
|
34
|
+
"class" => safe_class_name(current),
|
|
35
|
+
"message" => safe_message(current)
|
|
36
|
+
}
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
causes
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def safe_cause(exception)
|
|
45
|
+
exception.cause
|
|
46
|
+
rescue StandardError
|
|
47
|
+
nil
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def safe_class_name(exception)
|
|
51
|
+
exception.class.name.to_s
|
|
52
|
+
rescue StandardError
|
|
53
|
+
"Exception"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def safe_message(exception)
|
|
57
|
+
exception.message.to_s
|
|
58
|
+
rescue StandardError
|
|
59
|
+
"<unreadable exception message>"
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
module Chronos
|
|
2
|
+
module Core
|
|
3
|
+
# Immutable value object containing a normalized Ruby exception.
|
|
4
|
+
#
|
|
5
|
+
# @responsibility Carry exception and diagnostic context through the pipeline.
|
|
6
|
+
# @motivation Keep transport details separate from exception normalization.
|
|
7
|
+
# @limits It does not sanitize, serialize, enqueue, or send itself.
|
|
8
|
+
# @collaborators NoticeBuilder and PayloadSerializer.
|
|
9
|
+
# @thread_safety Immutable after construction and safe to share.
|
|
10
|
+
# @compatibility Ruby 2.2.10 through Ruby 2.6; independent of Rails.
|
|
11
|
+
# @example
|
|
12
|
+
# notice.exception_class #=> "RuntimeError"
|
|
13
|
+
# @performance Construction is linear in backtrace and context size.
|
|
14
|
+
class Notice
|
|
15
|
+
ATTRIBUTES = [
|
|
16
|
+
:event_id, :exception_class, :message, :backtrace, :causes,
|
|
17
|
+
:severity, :timestamp, :context, :parameters, :session, :user,
|
|
18
|
+
:environment, :runtime, :versions, :host, :process, :thread,
|
|
19
|
+
:tags, :fingerprint
|
|
20
|
+
].freeze
|
|
21
|
+
|
|
22
|
+
attr_reader(*ATTRIBUTES)
|
|
23
|
+
|
|
24
|
+
def initialize(attributes)
|
|
25
|
+
ATTRIBUTES.each do |attribute|
|
|
26
|
+
instance_variable_set("@#{attribute}", deep_freeze(attributes[attribute]))
|
|
27
|
+
end
|
|
28
|
+
freeze
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def to_h
|
|
32
|
+
ATTRIBUTES.each_with_object({}) do |attribute, result|
|
|
33
|
+
result[attribute] = public_send(attribute)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def deep_freeze(value)
|
|
40
|
+
case value
|
|
41
|
+
when Hash
|
|
42
|
+
value.each do |key, child|
|
|
43
|
+
deep_freeze(key)
|
|
44
|
+
deep_freeze(child)
|
|
45
|
+
end
|
|
46
|
+
when Array
|
|
47
|
+
value.each { |child| deep_freeze(child) }
|
|
48
|
+
end
|
|
49
|
+
value.freeze
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
require "securerandom"
|
|
2
|
+
require "time"
|
|
3
|
+
|
|
4
|
+
module Chronos
|
|
5
|
+
module Core
|
|
6
|
+
# Normalizes an Exception and caller context into a Notice.
|
|
7
|
+
#
|
|
8
|
+
# @responsibility Coordinate backtrace, causes, runtime, and caller-provided fields.
|
|
9
|
+
# @motivation Isolate Ruby runtime behavior from delivery code.
|
|
10
|
+
# @limits It does not serialize, enqueue, or perform HTTP.
|
|
11
|
+
# @collaborators BacktraceParser, ExceptionCauseCollector, RuntimeInfo, and Notice.
|
|
12
|
+
# @thread_safety Collaborators are stateless and the builder keeps no call state.
|
|
13
|
+
# @compatibility Ruby 2.2.10 through Ruby 2.6.
|
|
14
|
+
# @example
|
|
15
|
+
# notice = builder.call(RuntimeError.new("failed"), :tags => ["billing"])
|
|
16
|
+
# @performance Linear in backtrace and supplied context size.
|
|
17
|
+
class NoticeBuilder
|
|
18
|
+
def initialize(config, clock = nil)
|
|
19
|
+
@config = config
|
|
20
|
+
@clock = clock || proc { Time.now }
|
|
21
|
+
@backtrace_parser = BacktraceParser.new(config.root_directory)
|
|
22
|
+
@cause_collector = ExceptionCauseCollector.new
|
|
23
|
+
@runtime_info = RuntimeInfo.new
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def call(exception, context = {})
|
|
27
|
+
raise ArgumentError, "exception must be an Exception" unless exception.is_a?(Exception)
|
|
28
|
+
|
|
29
|
+
context = {} unless context.is_a?(Hash)
|
|
30
|
+
runtime = @runtime_info.call
|
|
31
|
+
Notice.new(
|
|
32
|
+
:event_id => SecureRandom.uuid,
|
|
33
|
+
:exception_class => safe_class_name(exception),
|
|
34
|
+
:message => safe_message(exception),
|
|
35
|
+
:backtrace => @backtrace_parser.call(exception.backtrace),
|
|
36
|
+
:causes => @cause_collector.call(exception),
|
|
37
|
+
:severity => value(context, :severity) || "error",
|
|
38
|
+
:timestamp => @clock.call.utc.iso8601(6),
|
|
39
|
+
:context => value(context, :context) || context_without_reserved_keys(context),
|
|
40
|
+
:parameters => value(context, :parameters) || {},
|
|
41
|
+
:session => value(context, :session) || {},
|
|
42
|
+
:user => value(context, :user) || {},
|
|
43
|
+
:environment => @config.environment,
|
|
44
|
+
:runtime => runtime[:runtime],
|
|
45
|
+
:versions => {"agent" => Chronos::VERSION, "application" => @config.app_version},
|
|
46
|
+
:host => runtime[:host],
|
|
47
|
+
:process => runtime[:process],
|
|
48
|
+
:thread => runtime[:thread],
|
|
49
|
+
:tags => Array(value(context, :tags)),
|
|
50
|
+
:fingerprint => value(context, :fingerprint)
|
|
51
|
+
)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
RESERVED_KEYS = [:severity, :context, :parameters, :session, :user, :tags, :fingerprint].freeze
|
|
57
|
+
|
|
58
|
+
def value(hash, key)
|
|
59
|
+
hash.key?(key) ? hash[key] : hash[key.to_s]
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def context_without_reserved_keys(context)
|
|
63
|
+
context.each_with_object({}) do |(key, child), result|
|
|
64
|
+
result[key] = child unless RESERVED_KEYS.include?(key.to_sym)
|
|
65
|
+
end
|
|
66
|
+
rescue StandardError
|
|
67
|
+
{}
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def safe_class_name(exception)
|
|
71
|
+
exception.class.name.to_s
|
|
72
|
+
rescue StandardError
|
|
73
|
+
"Exception"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def safe_message(exception)
|
|
77
|
+
exception.message.to_s
|
|
78
|
+
rescue StandardError
|
|
79
|
+
"<unreadable exception message>"
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "time"
|
|
3
|
+
|
|
4
|
+
module Chronos
|
|
5
|
+
module Core
|
|
6
|
+
# Serialized event ready for transport.
|
|
7
|
+
#
|
|
8
|
+
# @responsibility Keep an event ID next to its JSON body.
|
|
9
|
+
# @motivation Allow transports to set idempotency headers without reparsing JSON.
|
|
10
|
+
# @limits It contains no retry or HTTP behavior.
|
|
11
|
+
# @thread_safety Immutable after construction.
|
|
12
|
+
# @compatibility Ruby 2.2.10 through Ruby 2.6.
|
|
13
|
+
class SerializedEvent
|
|
14
|
+
attr_reader :event_id, :body
|
|
15
|
+
|
|
16
|
+
def initialize(event_id, body)
|
|
17
|
+
@event_id = event_id.to_s.freeze
|
|
18
|
+
@body = body.to_s.freeze
|
|
19
|
+
freeze
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def bytesize
|
|
23
|
+
body.bytesize
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Converts a Notice into the versioned Chronos JSON envelope.
|
|
28
|
+
#
|
|
29
|
+
# @responsibility Normalize values to JSON primitives and enforce size limits.
|
|
30
|
+
# @motivation Prevent arbitrary application objects or invalid encodings from breaking capture.
|
|
31
|
+
# @limits Version 0.1 applies structural limits, not the advanced privacy rules from 0.2.
|
|
32
|
+
# @collaborators Notice and SerializedEvent.
|
|
33
|
+
# @thread_safety Stateless apart from immutable configuration.
|
|
34
|
+
# @compatibility Uses the JSON standard library available on Ruby 2.2.10.
|
|
35
|
+
# @example
|
|
36
|
+
# event = serializer.call(notice)
|
|
37
|
+
# event.body #=> "{...}"
|
|
38
|
+
# @errors Serialization failures are handled by CaptureException.
|
|
39
|
+
# @performance Linear in payload size with bounded depth and collection sizes.
|
|
40
|
+
class PayloadSerializer
|
|
41
|
+
MAX_DEPTH = 10
|
|
42
|
+
MAX_KEYS = 100
|
|
43
|
+
MAX_ITEMS = 100
|
|
44
|
+
MAX_STRING_BYTES = 8192
|
|
45
|
+
|
|
46
|
+
def initialize(config, clock = nil)
|
|
47
|
+
@config = config
|
|
48
|
+
@clock = clock || proc { Time.now }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def call(notice)
|
|
52
|
+
envelope = build_envelope(notice)
|
|
53
|
+
body = JSON.generate(envelope)
|
|
54
|
+
body = JSON.generate(compact_envelope(envelope)) if body.bytesize > @config.max_payload_size
|
|
55
|
+
raise Error, "event exceeds max_payload_size" if body.bytesize > @config.max_payload_size
|
|
56
|
+
|
|
57
|
+
SerializedEvent.new(notice.event_id, body)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def build_envelope(notice)
|
|
63
|
+
{
|
|
64
|
+
"schema_version" => "1.0",
|
|
65
|
+
"event_id" => notice.event_id,
|
|
66
|
+
"event_type" => "exception",
|
|
67
|
+
"occurred_at" => notice.timestamp,
|
|
68
|
+
"sent_at" => @clock.call.utc.iso8601(6),
|
|
69
|
+
"project_key" => safe_string(@config.project_id),
|
|
70
|
+
"environment" => safe_string(notice.environment),
|
|
71
|
+
"service" => {
|
|
72
|
+
"name" => safe_string(@config.service_name),
|
|
73
|
+
"version" => safe_string(@config.app_version),
|
|
74
|
+
"instance_id" => safe_string(notice.host)
|
|
75
|
+
},
|
|
76
|
+
"runtime" => normalize(notice.runtime, 0),
|
|
77
|
+
"context" => normalize(notice.context, 0),
|
|
78
|
+
"payload" => payload(notice)
|
|
79
|
+
}
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def payload(notice)
|
|
83
|
+
normalize({
|
|
84
|
+
"exception" => {
|
|
85
|
+
"class" => notice.exception_class,
|
|
86
|
+
"message" => notice.message,
|
|
87
|
+
"backtrace" => notice.backtrace,
|
|
88
|
+
"causes" => notice.causes
|
|
89
|
+
},
|
|
90
|
+
"severity" => notice.severity,
|
|
91
|
+
"parameters" => notice.parameters,
|
|
92
|
+
"session" => notice.session,
|
|
93
|
+
"user" => notice.user,
|
|
94
|
+
"versions" => notice.versions,
|
|
95
|
+
"host" => notice.host,
|
|
96
|
+
"process" => notice.process,
|
|
97
|
+
"thread" => notice.thread,
|
|
98
|
+
"tags" => notice.tags,
|
|
99
|
+
"fingerprint" => notice.fingerprint
|
|
100
|
+
}, 0)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def compact_envelope(envelope)
|
|
104
|
+
payload = envelope["payload"]
|
|
105
|
+
exception = payload["exception"]
|
|
106
|
+
exception["message"] = truncate_string(exception["message"], 1024)
|
|
107
|
+
exception["backtrace"] = Array(exception["backtrace"]).first(20)
|
|
108
|
+
payload["parameters"] = {"_truncated" => true}
|
|
109
|
+
payload["session"] = {"_truncated" => true}
|
|
110
|
+
payload["user"] = {"_truncated" => true}
|
|
111
|
+
envelope["context"] = {"_truncated" => true}
|
|
112
|
+
envelope
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def normalize(value, depth)
|
|
116
|
+
return "<maximum depth reached>" if depth >= MAX_DEPTH
|
|
117
|
+
|
|
118
|
+
case value
|
|
119
|
+
when nil, true, false, Integer
|
|
120
|
+
value
|
|
121
|
+
when Float
|
|
122
|
+
value.finite? ? value : value.to_s
|
|
123
|
+
when String, Symbol
|
|
124
|
+
truncate_string(safe_string(value), MAX_STRING_BYTES)
|
|
125
|
+
when Array
|
|
126
|
+
value.first(MAX_ITEMS).map { |child| normalize(child, depth + 1) }
|
|
127
|
+
when Hash
|
|
128
|
+
normalize_hash(value, depth)
|
|
129
|
+
else
|
|
130
|
+
"<#{safe_class_name(value)}>"
|
|
131
|
+
end
|
|
132
|
+
rescue StandardError
|
|
133
|
+
"<unserializable value>"
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def normalize_hash(value, depth)
|
|
137
|
+
result = {}
|
|
138
|
+
value.to_a.first(MAX_KEYS).each do |key, child|
|
|
139
|
+
result[truncate_string(safe_string(key), 256)] = normalize(child, depth + 1)
|
|
140
|
+
end
|
|
141
|
+
result
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def safe_string(value)
|
|
145
|
+
return nil if value.nil?
|
|
146
|
+
|
|
147
|
+
value.to_s.encode("UTF-8", :invalid => :replace, :undef => :replace, :replace => "�")
|
|
148
|
+
rescue StandardError
|
|
149
|
+
"<unreadable value>"
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def truncate_string(value, limit)
|
|
153
|
+
return value if value.nil? || value.bytesize <= limit
|
|
154
|
+
|
|
155
|
+
value.byteslice(0, limit).to_s.encode("UTF-8", :invalid => :replace, :undef => :replace, :replace => "�") + "…"
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def safe_class_name(value)
|
|
159
|
+
value.class.name.to_s
|
|
160
|
+
rescue StandardError
|
|
161
|
+
"Object"
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
require "socket"
|
|
2
|
+
|
|
3
|
+
module Chronos
|
|
4
|
+
module Core
|
|
5
|
+
# Collects low-cost runtime identifiers for an event.
|
|
6
|
+
#
|
|
7
|
+
# @responsibility Report Ruby, platform, process, thread, and optional host data.
|
|
8
|
+
# @motivation Provide enough environment context for diagnosis and grouping.
|
|
9
|
+
# @limits It does not enumerate environment variables, gems, or machine secrets.
|
|
10
|
+
# @thread_safety Stateless; all returned hashes are new objects.
|
|
11
|
+
# @compatibility Ruby 2.2.10 through Ruby 2.6 and feature-detected engines.
|
|
12
|
+
# @example
|
|
13
|
+
# Chronos::Core::RuntimeInfo.new.call
|
|
14
|
+
# @performance Uses constant-time runtime lookups and one hostname lookup.
|
|
15
|
+
class RuntimeInfo
|
|
16
|
+
def call
|
|
17
|
+
{
|
|
18
|
+
:runtime => {
|
|
19
|
+
"ruby_version" => RUBY_VERSION,
|
|
20
|
+
"ruby_engine" => ruby_engine,
|
|
21
|
+
"platform" => RUBY_PLATFORM
|
|
22
|
+
},
|
|
23
|
+
:host => safe_hostname,
|
|
24
|
+
:process => {"pid" => Process.pid},
|
|
25
|
+
:thread => {"id" => Thread.current.object_id.to_s}
|
|
26
|
+
}
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def ruby_engine
|
|
32
|
+
defined?(RUBY_ENGINE) ? RUBY_ENGINE : "ruby"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def safe_hostname
|
|
36
|
+
Socket.gethostname.to_s
|
|
37
|
+
rescue StandardError
|
|
38
|
+
nil
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
data/lib/chronos/core.rb
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
module Chronos
|
|
2
|
+
# Framework-independent domain values and normalization services.
|
|
3
|
+
#
|
|
4
|
+
# @responsibility Model and normalize Chronos events.
|
|
5
|
+
# @motivation Keep runtime data independent from delivery mechanisms.
|
|
6
|
+
# @limits Does not depend on Rails, Rack, Sidekiq, or Net::HTTP.
|
|
7
|
+
# @thread_safety Components are immutable or stateless unless documented otherwise.
|
|
8
|
+
# @compatibility Ruby 2.2.10 through Ruby 2.6.
|
|
9
|
+
module Core; end
|
|
10
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
module Chronos
|
|
2
|
+
# Base error raised by explicit Chronos setup operations.
|
|
3
|
+
#
|
|
4
|
+
# @responsibility Provide a common ancestor for errors callers may handle.
|
|
5
|
+
# @motivation Separate agent setup failures from application failures.
|
|
6
|
+
# @limits Runtime capture and delivery errors never escape through this class.
|
|
7
|
+
# @thread_safety Error instances are not shared by the agent.
|
|
8
|
+
# @compatibility Ruby 2.2.10 and newer legacy runtimes.
|
|
9
|
+
# @example
|
|
10
|
+
# rescue Chronos::Error => error
|
|
11
|
+
# warn error.message
|
|
12
|
+
class Error < StandardError; end
|
|
13
|
+
|
|
14
|
+
# Raised when a configuration cannot produce a valid immutable snapshot.
|
|
15
|
+
#
|
|
16
|
+
# @responsibility Report all invalid configuration fields before agent startup.
|
|
17
|
+
# @motivation Fail early during explicit setup instead of failing in requests.
|
|
18
|
+
# @limits It is not used for transport or event errors.
|
|
19
|
+
# @thread_safety Instances are immutable after construction by convention.
|
|
20
|
+
# @compatibility Ruby 2.2.10 and newer legacy runtimes.
|
|
21
|
+
# @example
|
|
22
|
+
# Chronos.configure { |config| config.project_key = nil }
|
|
23
|
+
class ConfigurationError < Error; end
|
|
24
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
module Chronos
|
|
2
|
+
module Internal
|
|
3
|
+
# Thread-safe queue with fixed capacity and non-blocking producer behavior.
|
|
4
|
+
#
|
|
5
|
+
# @responsibility Accept events up to a limit and count accepted and dropped items.
|
|
6
|
+
# @motivation Prevent telemetry bursts from growing application memory indefinitely.
|
|
7
|
+
# @limits Version 0.1 drops the newest item when full and does not persist to disk.
|
|
8
|
+
# @collaborators WorkerPool.
|
|
9
|
+
# @thread_safety Mutex and condition variable protect all mutable state.
|
|
10
|
+
# @compatibility Ruby 2.2.10 through Ruby 2.6 and fork-aware callers.
|
|
11
|
+
# @example
|
|
12
|
+
# queue.push(event) #=> true or false
|
|
13
|
+
# @performance Push is constant-time and never waits for free capacity.
|
|
14
|
+
class BoundedQueue
|
|
15
|
+
attr_reader :capacity
|
|
16
|
+
|
|
17
|
+
def initialize(capacity)
|
|
18
|
+
raise ArgumentError, "capacity must be positive" unless capacity.is_a?(Integer) && capacity > 0
|
|
19
|
+
|
|
20
|
+
@capacity = capacity
|
|
21
|
+
@items = []
|
|
22
|
+
@accepted = 0
|
|
23
|
+
@dropped = 0
|
|
24
|
+
@closed = false
|
|
25
|
+
@mutex = Mutex.new
|
|
26
|
+
@condition = ConditionVariable.new
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def push(item)
|
|
30
|
+
@mutex.synchronize do
|
|
31
|
+
return false if @closed
|
|
32
|
+
if @items.length >= @capacity
|
|
33
|
+
@dropped += 1
|
|
34
|
+
return false
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
@items << item
|
|
38
|
+
@accepted += 1
|
|
39
|
+
@condition.signal
|
|
40
|
+
true
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def pop(timeout = nil)
|
|
45
|
+
@mutex.synchronize do
|
|
46
|
+
@condition.wait(@mutex, timeout) while @items.empty? && !@closed && timeout.nil?
|
|
47
|
+
@condition.wait(@mutex, timeout) if @items.empty? && !@closed && timeout
|
|
48
|
+
@items.shift
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def close
|
|
53
|
+
@mutex.synchronize do
|
|
54
|
+
@closed = true
|
|
55
|
+
@condition.broadcast
|
|
56
|
+
end
|
|
57
|
+
true
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def closed?
|
|
61
|
+
@mutex.synchronize { @closed }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def empty?
|
|
65
|
+
@mutex.synchronize { @items.empty? }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def size
|
|
69
|
+
@mutex.synchronize { @items.size }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def stats
|
|
73
|
+
@mutex.synchronize do
|
|
74
|
+
{:size => @items.size, :capacity => capacity, :accepted => @accepted, :dropped => @dropped}
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
module Chronos
|
|
2
|
+
module Internal
|
|
3
|
+
# Protects the host application from logger failures and recursive diagnostics.
|
|
4
|
+
#
|
|
5
|
+
# @responsibility Emit bounded internal messages through a configured logger.
|
|
6
|
+
# @motivation Diagnostics must never become a new application failure.
|
|
7
|
+
# @limits It does not store logs or include credentials and payloads.
|
|
8
|
+
# @thread_safety Uses a mutex for recursion protection across threads.
|
|
9
|
+
# @compatibility Ruby 2.2.10 through Ruby 2.6.
|
|
10
|
+
# @example
|
|
11
|
+
# logger.warn("transport failed")
|
|
12
|
+
class SafeLogger
|
|
13
|
+
MAX_MESSAGE_BYTES = 1024
|
|
14
|
+
|
|
15
|
+
def initialize(logger)
|
|
16
|
+
@logger = logger
|
|
17
|
+
@mutex = Mutex.new
|
|
18
|
+
@logging = false
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def debug(message)
|
|
22
|
+
write(:debug, message)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def warn(message)
|
|
26
|
+
write(:warn, message)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def write(level, message)
|
|
32
|
+
return unless @logger && @logger.respond_to?(level)
|
|
33
|
+
|
|
34
|
+
allowed = @mutex.synchronize do
|
|
35
|
+
next false if @logging
|
|
36
|
+
@logging = true
|
|
37
|
+
end
|
|
38
|
+
return unless allowed
|
|
39
|
+
|
|
40
|
+
@logger.public_send(level, bounded(message))
|
|
41
|
+
rescue StandardError
|
|
42
|
+
nil
|
|
43
|
+
ensure
|
|
44
|
+
@mutex.synchronize { @logging = false } if allowed
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def bounded(message)
|
|
48
|
+
text = message.to_s
|
|
49
|
+
text.bytesize > MAX_MESSAGE_BYTES ? text.byteslice(0, MAX_MESSAGE_BYTES) : text
|
|
50
|
+
rescue StandardError
|
|
51
|
+
"Chronos internal diagnostic unavailable"
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|