flare 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +148 -0
- data/app/controllers/flare/application_controller.rb +22 -0
- data/app/controllers/flare/jobs_controller.rb +55 -0
- data/app/controllers/flare/requests_controller.rb +73 -0
- data/app/controllers/flare/spans_controller.rb +101 -0
- data/app/helpers/flare/application_helper.rb +168 -0
- data/app/views/flare/jobs/index.html.erb +69 -0
- data/app/views/flare/jobs/show.html.erb +323 -0
- data/app/views/flare/requests/index.html.erb +120 -0
- data/app/views/flare/requests/show.html.erb +498 -0
- data/app/views/flare/spans/index.html.erb +112 -0
- data/app/views/flare/spans/show.html.erb +184 -0
- data/app/views/layouts/flare/application.html.erb +126 -0
- data/config/routes.rb +20 -0
- data/exe/flare +9 -0
- data/lib/flare/backoff_policy.rb +73 -0
- data/lib/flare/cli/doctor_command.rb +129 -0
- data/lib/flare/cli/output.rb +45 -0
- data/lib/flare/cli/setup_command.rb +404 -0
- data/lib/flare/cli/status_command.rb +47 -0
- data/lib/flare/cli.rb +50 -0
- data/lib/flare/configuration.rb +121 -0
- data/lib/flare/engine.rb +43 -0
- data/lib/flare/http_metrics_config.rb +101 -0
- data/lib/flare/metric_counter.rb +45 -0
- data/lib/flare/metric_flusher.rb +124 -0
- data/lib/flare/metric_key.rb +42 -0
- data/lib/flare/metric_span_processor.rb +470 -0
- data/lib/flare/metric_storage.rb +42 -0
- data/lib/flare/metric_submitter.rb +221 -0
- data/lib/flare/source_location.rb +113 -0
- data/lib/flare/sqlite_exporter.rb +279 -0
- data/lib/flare/storage/sqlite.rb +789 -0
- data/lib/flare/storage.rb +54 -0
- data/lib/flare/version.rb +5 -0
- data/lib/flare.rb +411 -0
- data/public/flare-assets/flare.css +1245 -0
- data/public/flare-assets/images/flipper.png +0 -0
- metadata +240 -0
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "json"
|
|
5
|
+
require "zlib"
|
|
6
|
+
require "stringio"
|
|
7
|
+
require "securerandom"
|
|
8
|
+
require "socket"
|
|
9
|
+
|
|
10
|
+
module Flare
|
|
11
|
+
# Submits metrics to the Flare metrics service via HTTP.
|
|
12
|
+
# Handles gzip compression, retries with exponential backoff, and error handling.
|
|
13
|
+
class MetricSubmitter
|
|
14
|
+
SCHEMA_VERSION = "V1"
|
|
15
|
+
GZIP_ENCODING = "gzip"
|
|
16
|
+
USER_AGENT = "Flare Ruby/#{Flare::VERSION}"
|
|
17
|
+
|
|
18
|
+
# Default timeouts (in seconds)
|
|
19
|
+
DEFAULT_OPEN_TIMEOUT = 2
|
|
20
|
+
DEFAULT_READ_TIMEOUT = 5
|
|
21
|
+
DEFAULT_WRITE_TIMEOUT = 5
|
|
22
|
+
|
|
23
|
+
# Max retries before giving up
|
|
24
|
+
MAX_RETRIES = 3
|
|
25
|
+
|
|
26
|
+
class SubmissionError < StandardError
|
|
27
|
+
attr_reader :request_id, :response_code, :response_body
|
|
28
|
+
|
|
29
|
+
def initialize(message, request_id:, response_code: nil, response_body: nil)
|
|
30
|
+
@request_id = request_id
|
|
31
|
+
@response_code = response_code
|
|
32
|
+
@response_body = response_body
|
|
33
|
+
super(message)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
class ClientError < StandardError
|
|
38
|
+
attr_reader :request_id, :response_code, :response_body
|
|
39
|
+
|
|
40
|
+
def initialize(message, request_id:, response_code: nil, response_body: nil)
|
|
41
|
+
@request_id = request_id
|
|
42
|
+
@response_code = response_code
|
|
43
|
+
@response_body = response_body
|
|
44
|
+
super(message)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
attr_reader :endpoint, :api_key, :backoff_policy
|
|
49
|
+
|
|
50
|
+
def initialize(endpoint:, api_key:, project: nil, environment: nil, backoff_policy: nil, open_timeout: nil, read_timeout: nil, write_timeout: nil)
|
|
51
|
+
@endpoint = URI("#{endpoint.to_s.chomp('/')}/api/metrics")
|
|
52
|
+
@api_key = api_key
|
|
53
|
+
@project = project || default_project
|
|
54
|
+
@environment = environment || default_environment
|
|
55
|
+
@backoff_policy = backoff_policy || BackoffPolicy.new
|
|
56
|
+
@open_timeout = open_timeout || DEFAULT_OPEN_TIMEOUT
|
|
57
|
+
@read_timeout = read_timeout || DEFAULT_READ_TIMEOUT
|
|
58
|
+
@write_timeout = write_timeout || DEFAULT_WRITE_TIMEOUT
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Submit drained metrics to the server.
|
|
62
|
+
# Returns [success_count, error] where error may be nil on success.
|
|
63
|
+
def submit(drained)
|
|
64
|
+
return [0, nil] if drained.empty?
|
|
65
|
+
|
|
66
|
+
request_id = SecureRandom.uuid
|
|
67
|
+
Flare.log "Submitting #{drained.size} metrics to #{@endpoint} (request_id=#{request_id})"
|
|
68
|
+
|
|
69
|
+
body = build_body(drained, request_id)
|
|
70
|
+
return [0, nil] if body.nil?
|
|
71
|
+
|
|
72
|
+
@backoff_policy.reset
|
|
73
|
+
response, error = retry_with_backoff(MAX_RETRIES) { post(body, request_id) }
|
|
74
|
+
|
|
75
|
+
if error
|
|
76
|
+
Flare.log "Submission failed: #{error.message} (request_id=#{request_id})"
|
|
77
|
+
[0, error]
|
|
78
|
+
else
|
|
79
|
+
Flare.log "Submission succeeded: #{response.code} (request_id=#{request_id})"
|
|
80
|
+
[drained.size, nil]
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
def build_body(drained, request_id)
|
|
87
|
+
metrics = drained.map do |key, values|
|
|
88
|
+
{
|
|
89
|
+
bucket: format_time(key.bucket),
|
|
90
|
+
namespace: key.namespace,
|
|
91
|
+
service: key.service,
|
|
92
|
+
target: key.target || "",
|
|
93
|
+
operation: key.operation,
|
|
94
|
+
count: values[:count],
|
|
95
|
+
sum_ms: values[:sum_ms],
|
|
96
|
+
error_count: values[:error_count]
|
|
97
|
+
}
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
payload = {
|
|
101
|
+
request_id: request_id,
|
|
102
|
+
schema_version: SCHEMA_VERSION,
|
|
103
|
+
project: @project,
|
|
104
|
+
environment: @environment,
|
|
105
|
+
metrics: metrics
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
gzip(JSON.generate(payload))
|
|
109
|
+
rescue => e
|
|
110
|
+
warn "[Flare] Failed to build submission body: #{e.message}"
|
|
111
|
+
nil
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def post(body, request_id)
|
|
115
|
+
http = Net::HTTP.new(@endpoint.host, @endpoint.port)
|
|
116
|
+
http.use_ssl = @endpoint.scheme == "https"
|
|
117
|
+
http.open_timeout = @open_timeout
|
|
118
|
+
http.read_timeout = @read_timeout
|
|
119
|
+
http.write_timeout = @write_timeout if http.respond_to?(:write_timeout=)
|
|
120
|
+
|
|
121
|
+
request_uri = @endpoint.request_uri
|
|
122
|
+
request = Net::HTTP::Post.new(request_uri == "" ? "/" : request_uri)
|
|
123
|
+
request["Content-Type"] = "application/json"
|
|
124
|
+
request["Content-Encoding"] = GZIP_ENCODING
|
|
125
|
+
request["Authorization"] = "Bearer #{@api_key}"
|
|
126
|
+
request["User-Agent"] = USER_AGENT
|
|
127
|
+
request["X-Request-Id"] = request_id
|
|
128
|
+
request["X-Schema-Version"] = SCHEMA_VERSION
|
|
129
|
+
|
|
130
|
+
# Client metadata headers (like Flipper)
|
|
131
|
+
request["X-Client-Language"] = "ruby"
|
|
132
|
+
request["X-Client-Language-Version"] = RUBY_VERSION
|
|
133
|
+
request["X-Client-Platform"] = RUBY_PLATFORM
|
|
134
|
+
request["X-Client-Pid"] = Process.pid.to_s
|
|
135
|
+
request["X-Client-Hostname"] = Socket.gethostname rescue "unknown"
|
|
136
|
+
|
|
137
|
+
request.body = body
|
|
138
|
+
response = http.request(request)
|
|
139
|
+
|
|
140
|
+
code = response.code.to_i
|
|
141
|
+
|
|
142
|
+
# Success
|
|
143
|
+
if code >= 200 && code < 300
|
|
144
|
+
return [response, false] # [result, should_retry]
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Retriable errors: rate limiting, server errors
|
|
148
|
+
if code == 429 || code >= 500
|
|
149
|
+
raise SubmissionError.new(
|
|
150
|
+
"Retriable error: #{code}",
|
|
151
|
+
request_id: request_id,
|
|
152
|
+
response_code: code,
|
|
153
|
+
response_body: response.body
|
|
154
|
+
)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Non-retriable client errors (4xx except 429)
|
|
158
|
+
raise ClientError.new(
|
|
159
|
+
"Client error: #{code}",
|
|
160
|
+
request_id: request_id,
|
|
161
|
+
response_code: code,
|
|
162
|
+
response_body: response.body
|
|
163
|
+
)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def retry_with_backoff(max_attempts)
|
|
167
|
+
attempts_remaining = max_attempts
|
|
168
|
+
last_error = nil
|
|
169
|
+
|
|
170
|
+
while attempts_remaining > 0
|
|
171
|
+
begin
|
|
172
|
+
result, should_retry = yield
|
|
173
|
+
return [result, nil] unless should_retry
|
|
174
|
+
rescue SubmissionError, Net::OpenTimeout, Net::ReadTimeout, Errno::ECONNREFUSED, Errno::ECONNRESET => e
|
|
175
|
+
last_error = e
|
|
176
|
+
attempts_remaining -= 1
|
|
177
|
+
|
|
178
|
+
if attempts_remaining > 0
|
|
179
|
+
sleep_time = @backoff_policy.next_interval / 1000.0
|
|
180
|
+
sleep(sleep_time)
|
|
181
|
+
end
|
|
182
|
+
next
|
|
183
|
+
rescue => e
|
|
184
|
+
# Unexpected errors - don't retry
|
|
185
|
+
return [nil, e]
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
[nil, last_error]
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def gzip(string)
|
|
193
|
+
io = StringIO.new
|
|
194
|
+
io.set_encoding("BINARY")
|
|
195
|
+
gz = Zlib::GzipWriter.new(io)
|
|
196
|
+
gz.write(string)
|
|
197
|
+
gz.close
|
|
198
|
+
io.string
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def format_time(time)
|
|
202
|
+
time.utc.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def default_project
|
|
206
|
+
if defined?(Rails) && Rails.application
|
|
207
|
+
Rails.application.class.module_parent_name.underscore rescue "rails_app"
|
|
208
|
+
else
|
|
209
|
+
"app"
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def default_environment
|
|
214
|
+
if defined?(Rails)
|
|
215
|
+
Rails.env.to_s
|
|
216
|
+
else
|
|
217
|
+
ENV.fetch("RACK_ENV", "development")
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
end
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Flare
|
|
4
|
+
# Utility for finding the source location (file, line, method) of app code
|
|
5
|
+
# that triggered a database query or other instrumented operation.
|
|
6
|
+
module SourceLocation
|
|
7
|
+
# How many app code lines to capture for the backtrace
|
|
8
|
+
MAX_TRACE_LINES = 8
|
|
9
|
+
|
|
10
|
+
# Patterns to filter out from backtraces (gems, framework code)
|
|
11
|
+
IGNORE_PATTERNS = [
|
|
12
|
+
/\/gems\//,
|
|
13
|
+
/\/ruby\//,
|
|
14
|
+
/\/rubygems\//,
|
|
15
|
+
/lib\/active_record/,
|
|
16
|
+
/lib\/active_support/,
|
|
17
|
+
/lib\/action_/,
|
|
18
|
+
/opentelemetry/,
|
|
19
|
+
/flare/,
|
|
20
|
+
/<internal:/,
|
|
21
|
+
/\/bin\//,
|
|
22
|
+
].freeze
|
|
23
|
+
|
|
24
|
+
module_function
|
|
25
|
+
|
|
26
|
+
# Find the first app source location from the current backtrace
|
|
27
|
+
# Returns a hash with :filepath, :lineno, :function or nil
|
|
28
|
+
def find
|
|
29
|
+
backtrace = caller(2, 50)
|
|
30
|
+
return nil unless backtrace
|
|
31
|
+
|
|
32
|
+
# Find the first line that's app code (not gems/framework)
|
|
33
|
+
app_line = backtrace.find { |line| app_code?(line) }
|
|
34
|
+
return nil unless app_line
|
|
35
|
+
|
|
36
|
+
parse_backtrace_line(app_line)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Find multiple app source locations from the current backtrace
|
|
40
|
+
# Returns an array of cleaned backtrace lines (up to MAX_TRACE_LINES)
|
|
41
|
+
def find_trace
|
|
42
|
+
backtrace = caller(2, 100)
|
|
43
|
+
return [] unless backtrace
|
|
44
|
+
|
|
45
|
+
# Filter to only app code lines
|
|
46
|
+
app_lines = backtrace.select { |line| app_code?(line) }
|
|
47
|
+
return [] if app_lines.empty?
|
|
48
|
+
|
|
49
|
+
# Clean and format each line, limit to MAX_TRACE_LINES
|
|
50
|
+
app_lines.first(MAX_TRACE_LINES).map { |line| clean_backtrace_line(line) }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Add source location attributes to a hash (for span attributes)
|
|
54
|
+
def add_to_attributes(attrs)
|
|
55
|
+
location = find
|
|
56
|
+
return attrs unless location
|
|
57
|
+
|
|
58
|
+
attrs["code.filepath"] = location[:filepath]
|
|
59
|
+
attrs["code.lineno"] = location[:lineno]
|
|
60
|
+
attrs["code.function"] = location[:function] if location[:function]
|
|
61
|
+
|
|
62
|
+
# Add full trace as a single string attribute
|
|
63
|
+
trace = find_trace
|
|
64
|
+
attrs["code.stacktrace"] = trace.join("\n") if trace.any?
|
|
65
|
+
|
|
66
|
+
attrs
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def app_code?(line)
|
|
70
|
+
# Must contain /app/ (Rails convention) and not match ignore patterns
|
|
71
|
+
return false unless line.include?("/app/")
|
|
72
|
+
|
|
73
|
+
IGNORE_PATTERNS.none? { |pattern| line.match?(pattern) }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def parse_backtrace_line(line)
|
|
77
|
+
# Parse: /path/to/file.rb:123:in `method_name'
|
|
78
|
+
if line =~ /\A(.+):(\d+):in [`'](.+)'\z/
|
|
79
|
+
{
|
|
80
|
+
filepath: clean_path($1),
|
|
81
|
+
lineno: $2.to_i,
|
|
82
|
+
function: $3
|
|
83
|
+
}
|
|
84
|
+
elsif line =~ /\A(.+):(\d+)\z/
|
|
85
|
+
{
|
|
86
|
+
filepath: clean_path($1),
|
|
87
|
+
lineno: $2.to_i,
|
|
88
|
+
function: nil
|
|
89
|
+
}
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def clean_backtrace_line(line)
|
|
94
|
+
# Parse and reformat: "app/models/user.rb:42:in `find_by_email'"
|
|
95
|
+
if line =~ /\A(.+):(\d+):in [`'](.+)'\z/
|
|
96
|
+
"#{clean_path($1)}:#{$2} in `#{$3}'"
|
|
97
|
+
elsif line =~ /\A(.+):(\d+)\z/
|
|
98
|
+
"#{clean_path($1)}:#{$2}"
|
|
99
|
+
else
|
|
100
|
+
clean_path(line)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def clean_path(path)
|
|
105
|
+
# Remove Rails.root prefix if present
|
|
106
|
+
if defined?(Rails) && Rails.respond_to?(:root) && Rails.root
|
|
107
|
+
path.sub(/\A#{Regexp.escape(Rails.root.to_s)}\//, "")
|
|
108
|
+
else
|
|
109
|
+
path
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "sqlite3"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Flare
|
|
7
|
+
class SQLiteExporter
|
|
8
|
+
SUCCESS = OpenTelemetry::SDK::Trace::Export::SUCCESS
|
|
9
|
+
FAILURE = OpenTelemetry::SDK::Trace::Export::FAILURE
|
|
10
|
+
TIMEOUT = OpenTelemetry::SDK::Trace::Export::TIMEOUT
|
|
11
|
+
|
|
12
|
+
# Prune roughly every 100 exports (1% chance per export)
|
|
13
|
+
PRUNE_PROBABILITY = 0.01
|
|
14
|
+
|
|
15
|
+
def initialize(database_path)
|
|
16
|
+
@database_path = database_path
|
|
17
|
+
@mutex = Mutex.new
|
|
18
|
+
@setup = false
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Maximum number of retry attempts when the database is busy.
|
|
22
|
+
# Mirrors ActiveRecord's retry strategy for SQLite.
|
|
23
|
+
MAX_RETRIES = 3
|
|
24
|
+
|
|
25
|
+
def export(span_datas, timeout: nil)
|
|
26
|
+
setup_database unless @setup
|
|
27
|
+
|
|
28
|
+
retries = 0
|
|
29
|
+
exported = 0
|
|
30
|
+
|
|
31
|
+
begin
|
|
32
|
+
@mutex.synchronize do
|
|
33
|
+
connection.transaction do
|
|
34
|
+
span_datas.each do |span_data|
|
|
35
|
+
next if should_ignore_span?(span_data)
|
|
36
|
+
|
|
37
|
+
create_span(span_data)
|
|
38
|
+
exported += 1
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
rescue ::SQLite3::BusyException
|
|
43
|
+
retries += 1
|
|
44
|
+
if retries <= MAX_RETRIES
|
|
45
|
+
sleep 0.1 * retries
|
|
46
|
+
retry
|
|
47
|
+
end
|
|
48
|
+
warn "[Flare] SQLite export error: database is busy after #{MAX_RETRIES} retries"
|
|
49
|
+
return FAILURE
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
Flare.log "Exported #{exported} spans to SQLite" if exported > 0
|
|
53
|
+
|
|
54
|
+
# Periodically prune old data
|
|
55
|
+
maybe_prune
|
|
56
|
+
|
|
57
|
+
SUCCESS
|
|
58
|
+
rescue => e
|
|
59
|
+
warn "[Flare] SQLite export error: #{e.message}"
|
|
60
|
+
FAILURE
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def force_flush(timeout: nil)
|
|
64
|
+
SUCCESS
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def shutdown(timeout: nil)
|
|
68
|
+
SUCCESS
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
def maybe_prune
|
|
74
|
+
return unless rand < PRUNE_PROBABILITY
|
|
75
|
+
|
|
76
|
+
Flare.storage.prune(
|
|
77
|
+
retention_hours: Flare.configuration.retention_hours,
|
|
78
|
+
max_spans: Flare.configuration.max_spans
|
|
79
|
+
)
|
|
80
|
+
rescue => e
|
|
81
|
+
warn "[Flare] Prune error: #{e.message}"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def should_ignore_span?(span_data)
|
|
85
|
+
span_data.name&.start_with?("Flare::")
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def create_span(span_data)
|
|
89
|
+
now = Time.now.iso8601(6)
|
|
90
|
+
|
|
91
|
+
sql = <<~SQL
|
|
92
|
+
INSERT INTO flare_spans (name, kind, span_id, trace_id, parent_span_id, start_timestamp, end_timestamp, total_recorded_links, total_recorded_events, total_recorded_properties, created_at, updated_at)
|
|
93
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
94
|
+
SQL
|
|
95
|
+
|
|
96
|
+
values = [
|
|
97
|
+
span_data.name,
|
|
98
|
+
span_data.kind.to_s,
|
|
99
|
+
span_data.hex_span_id,
|
|
100
|
+
span_data.hex_trace_id,
|
|
101
|
+
span_data.hex_parent_span_id,
|
|
102
|
+
span_data.start_timestamp,
|
|
103
|
+
span_data.end_timestamp,
|
|
104
|
+
span_data.total_recorded_links,
|
|
105
|
+
span_data.total_recorded_events,
|
|
106
|
+
span_data.total_recorded_attributes,
|
|
107
|
+
now,
|
|
108
|
+
now
|
|
109
|
+
]
|
|
110
|
+
|
|
111
|
+
connection.execute(sql, values)
|
|
112
|
+
span_record_id = connection.last_insert_row_id
|
|
113
|
+
|
|
114
|
+
span_data.events&.each do |span_event|
|
|
115
|
+
create_event(span_record_id, span_event)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
create_properties("Flare::Span", span_record_id, span_data.attributes)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def create_event(span_record_id, span_event)
|
|
122
|
+
now = Time.now.iso8601(6)
|
|
123
|
+
timestamp = span_event.timestamp ? Time.at(span_event.timestamp / 1_000_000_000.0).iso8601(6) : now
|
|
124
|
+
|
|
125
|
+
sql = <<~SQL
|
|
126
|
+
INSERT INTO flare_events (span_id, name, created_at, updated_at)
|
|
127
|
+
VALUES (?, ?, ?, ?)
|
|
128
|
+
SQL
|
|
129
|
+
|
|
130
|
+
connection.execute(sql, [span_record_id, span_event.name, timestamp, now])
|
|
131
|
+
event_record_id = connection.last_insert_row_id
|
|
132
|
+
create_properties("Flare::Event", event_record_id, span_event.attributes)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def create_properties(owner_type, owner_id, attributes)
|
|
136
|
+
return unless attributes
|
|
137
|
+
|
|
138
|
+
now = Time.now.iso8601(6)
|
|
139
|
+
|
|
140
|
+
sql = <<~SQL
|
|
141
|
+
INSERT INTO flare_properties (key, value, value_type, owner_type, owner_id, created_at, updated_at)
|
|
142
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
143
|
+
SQL
|
|
144
|
+
|
|
145
|
+
attributes.each do |key, value|
|
|
146
|
+
next if value.nil?
|
|
147
|
+
|
|
148
|
+
value_type = determine_value_type(value)
|
|
149
|
+
serialized_value = JSON.generate(value)
|
|
150
|
+
connection.execute(sql, [key, serialized_value, value_type, owner_type, owner_id, now, now])
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def determine_value_type(value)
|
|
155
|
+
case value
|
|
156
|
+
when String then 0 # string
|
|
157
|
+
when Integer then 1 # integer
|
|
158
|
+
when Float then 2 # float
|
|
159
|
+
when TrueClass, FalseClass then 3 # boolean
|
|
160
|
+
when Array then 4 # array
|
|
161
|
+
else 0 # default to string
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def setup_database
|
|
166
|
+
@mutex.synchronize do
|
|
167
|
+
return if @setup
|
|
168
|
+
|
|
169
|
+
db = connection
|
|
170
|
+
configure_pragmas(db)
|
|
171
|
+
|
|
172
|
+
db.execute(<<~SQL)
|
|
173
|
+
CREATE TABLE IF NOT EXISTS flare_spans (
|
|
174
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
175
|
+
name TEXT NOT NULL,
|
|
176
|
+
kind TEXT NOT NULL,
|
|
177
|
+
span_id TEXT NOT NULL,
|
|
178
|
+
trace_id TEXT NOT NULL,
|
|
179
|
+
parent_span_id TEXT,
|
|
180
|
+
start_timestamp INTEGER NOT NULL,
|
|
181
|
+
end_timestamp INTEGER NOT NULL,
|
|
182
|
+
total_recorded_properties INTEGER NOT NULL DEFAULT 0,
|
|
183
|
+
total_recorded_events INTEGER NOT NULL DEFAULT 0,
|
|
184
|
+
total_recorded_links INTEGER NOT NULL DEFAULT 0,
|
|
185
|
+
created_at TEXT NOT NULL,
|
|
186
|
+
updated_at TEXT NOT NULL
|
|
187
|
+
)
|
|
188
|
+
SQL
|
|
189
|
+
|
|
190
|
+
db.execute(<<~SQL)
|
|
191
|
+
CREATE INDEX IF NOT EXISTS idx_spans_span_id ON flare_spans(span_id)
|
|
192
|
+
SQL
|
|
193
|
+
|
|
194
|
+
db.execute(<<~SQL)
|
|
195
|
+
CREATE INDEX IF NOT EXISTS idx_spans_trace_id ON flare_spans(trace_id)
|
|
196
|
+
SQL
|
|
197
|
+
|
|
198
|
+
db.execute(<<~SQL)
|
|
199
|
+
CREATE INDEX IF NOT EXISTS idx_spans_parent_span_id ON flare_spans(parent_span_id)
|
|
200
|
+
SQL
|
|
201
|
+
|
|
202
|
+
db.execute(<<~SQL)
|
|
203
|
+
CREATE INDEX IF NOT EXISTS idx_spans_created_at ON flare_spans(created_at)
|
|
204
|
+
SQL
|
|
205
|
+
|
|
206
|
+
db.execute(<<~SQL)
|
|
207
|
+
CREATE TABLE IF NOT EXISTS flare_events (
|
|
208
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
209
|
+
span_id INTEGER NOT NULL,
|
|
210
|
+
name TEXT NOT NULL,
|
|
211
|
+
created_at TEXT NOT NULL,
|
|
212
|
+
updated_at TEXT NOT NULL,
|
|
213
|
+
FOREIGN KEY (span_id) REFERENCES flare_spans(id)
|
|
214
|
+
)
|
|
215
|
+
SQL
|
|
216
|
+
|
|
217
|
+
db.execute(<<~SQL)
|
|
218
|
+
CREATE INDEX IF NOT EXISTS idx_events_span_id ON flare_events(span_id)
|
|
219
|
+
SQL
|
|
220
|
+
|
|
221
|
+
db.execute(<<~SQL)
|
|
222
|
+
CREATE TABLE IF NOT EXISTS flare_properties (
|
|
223
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
224
|
+
key TEXT NOT NULL,
|
|
225
|
+
value TEXT,
|
|
226
|
+
value_type INTEGER NOT NULL DEFAULT 0,
|
|
227
|
+
owner_type TEXT NOT NULL,
|
|
228
|
+
owner_id INTEGER NOT NULL,
|
|
229
|
+
created_at TEXT NOT NULL,
|
|
230
|
+
updated_at TEXT NOT NULL
|
|
231
|
+
)
|
|
232
|
+
SQL
|
|
233
|
+
|
|
234
|
+
db.execute(<<~SQL)
|
|
235
|
+
CREATE INDEX IF NOT EXISTS idx_properties_owner ON flare_properties(owner_type, owner_id)
|
|
236
|
+
SQL
|
|
237
|
+
|
|
238
|
+
db.execute(<<~SQL)
|
|
239
|
+
CREATE INDEX IF NOT EXISTS idx_properties_key ON flare_properties(key)
|
|
240
|
+
SQL
|
|
241
|
+
|
|
242
|
+
close_connection # avoid inheriting connection across fork
|
|
243
|
+
@setup = true
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# Applies the same SQLite pragmas that ActiveRecord uses for good
|
|
248
|
+
# concurrency and performance with threaded/multi-process access.
|
|
249
|
+
def configure_pragmas(db)
|
|
250
|
+
db.execute("PRAGMA journal_mode=WAL")
|
|
251
|
+
db.execute("PRAGMA synchronous=NORMAL")
|
|
252
|
+
db.execute("PRAGMA mmap_size=134217728") # 128MB
|
|
253
|
+
db.execute("PRAGMA journal_size_limit=67108864") # 64MB
|
|
254
|
+
db.execute("PRAGMA cache_size=2000")
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def connection_key
|
|
258
|
+
:"flare_sqlite_db_#{@database_path.hash}"
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def connection
|
|
262
|
+
Thread.current[connection_key] ||= begin
|
|
263
|
+
dir = File.dirname(@database_path)
|
|
264
|
+
FileUtils.mkdir_p(dir) unless File.directory?(dir)
|
|
265
|
+
db = ::SQLite3::Database.new(@database_path, results_as_hash: true)
|
|
266
|
+
db.busy_timeout = 5000
|
|
267
|
+
db
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def close_connection
|
|
272
|
+
key = connection_key
|
|
273
|
+
if db = Thread.current[key]
|
|
274
|
+
db.close rescue nil
|
|
275
|
+
Thread.current[key] = nil
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
end
|