catadog 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.
Files changed (4) hide show
  1. checksums.yaml +7 -0
  2. data/bin/catadog +465 -0
  3. data/mocks/sink.rb +46 -0
  4. metadata +119 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 1316fc9c71fc1509af44246443b82ce5961031fb0d551c02b58f4b8cea6b8fb1
4
+ data.tar.gz: e220233c7970aed319142ab7266a6fc266862869d707e755ded7207265d7c90b
5
+ SHA512:
6
+ metadata.gz: e740a5ffaf11ef2942fab450498c8ab40014c3a221df08914e0b765b9f628f83c343c4135e3f21401201bc99f345a9332c42111e3826118183ce4e6b1a5059ee
7
+ data.tar.gz: e4d4190457cef07377df71f971bf0d7d66cb225620beea2bbea6ce65918a74a87e7df59be5d684e35b6721120f3c90aabcd615373487dada6e0013164fb7272f
data/bin/catadog ADDED
@@ -0,0 +1,465 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "logger"
5
+ require "ipaddr"
6
+ require "webrick"
7
+ require "rack"
8
+ require "sinatra/base"
9
+ require "net/http"
10
+ require "uri"
11
+ require "json"
12
+ require "msgpack"
13
+ require "base64"
14
+ require "date"
15
+ require "pathname"
16
+ require "stringio"
17
+
18
+ class WEBrick::HTTPServlet::ProcHandler
19
+ # rubocop:disable Style/Alias
20
+ alias do_PATCH do_GET
21
+ alias do_PUT do_GET
22
+ alias do_DELETE do_GET
23
+ # rubocop:enable Style/Alias
24
+ end
25
+
26
+ module Datadog
27
+ module Catadog
28
+ class App < Sinatra::Base
29
+ get "/" do
30
+ [200, {"Content-Type" => "text/plain"}, ["app: GET /\n"]]
31
+ end
32
+
33
+ post "/" do
34
+ [200, {"Content-Type" => "text/plain"}, ["app: POST /\n"]]
35
+ end
36
+ end
37
+
38
+ class Intercept
39
+ def initialize(app)
40
+ @app = app
41
+ end
42
+
43
+ def call(env)
44
+ r = Rack::Request.new(env)
45
+
46
+ case r.get_header("HTTP_CONTENT_TYPE")
47
+ when "application/json"
48
+ req_d = JSON.parse(r.body.read.tap { r.body.rewind })
49
+ when "application/msgpack"
50
+ req_d = MessagePack.unpack(r.body.read.tap { r.body.rewind })
51
+ end
52
+
53
+ status, headers, body = @app.call(env)
54
+
55
+ # TODO: these content-type things should at best be fallbacks
56
+ case headers["Content-Type"]
57
+ when "application/json"
58
+ res_d = JSON.parse(body.first)
59
+ when "application/msgpack"
60
+ res_d = MessagePack.unpack(body.first)
61
+ else
62
+ case r.fullpath
63
+ when "/info"
64
+ kind = "info"
65
+ res_d = JSON.parse(body.first)
66
+ when "/v0.7/config"
67
+ kind = "rc"
68
+ res_d = JSON.parse(body.first).tap { |e| e.delete("roots") }
69
+ if res_d.key?("targets")
70
+ res_d["targets"] = Base64.strict_decode64(res_d["targets"])
71
+ res_d["targets"] = JSON.parse(res_d["targets"])
72
+ end
73
+ if res_d.key?("target_files")
74
+ res_d["target_files"].each do |f|
75
+ if f["raw"]
76
+ f["raw"] = Base64.strict_decode64(f["raw"])
77
+ begin
78
+ f["parsed"] = JSON.parse(f["raw"])
79
+ f.delete("raw")
80
+ rescue JSON::ParseError
81
+ nil
82
+ end
83
+ end
84
+ end
85
+ end
86
+ when "/v0.3/traces", "/v0.4/traces", "/v0.7/traces"
87
+ kind = "traces"
88
+ res_d = JSON.parse(body.first)
89
+ when %r{^/telemetry/proxy/api/v2/}
90
+ kind = "telemetry"
91
+ res_d = JSON.parse(body.first)
92
+ end
93
+ end
94
+
95
+ d = {
96
+ kind: kind,
97
+ request: {
98
+ method: r.request_method,
99
+ path: r.fullpath,
100
+ headers: r.each_header.with_object({}) { |(k, v), h| k =~ /HTTP_(.*)/ && h[$1.tr("_", "-").downcase] = v },
101
+ body: req_d
102
+ },
103
+ response: {
104
+ status: status,
105
+ headers: headers,
106
+ body: res_d
107
+ }
108
+ }
109
+
110
+ # used for bubbling up to output middlewares
111
+ env["catadog.intercept"] = d
112
+
113
+ [status, headers, body]
114
+ end
115
+ end
116
+
117
+ class Print
118
+ def initialize(app, out: $stdout)
119
+ @app = app
120
+ @out = out
121
+ end
122
+
123
+ def call(env)
124
+ @app.call(env)
125
+ ensure
126
+ # TODO: consider exception case as well
127
+
128
+ if (d = env["catadog.intercept"])
129
+ @out.write(JSON.pretty_generate(d) << "\n")
130
+ end
131
+ end
132
+ end
133
+
134
+ # Dump from `Intercept`, one file per request
135
+ class Record
136
+ def initialize(app, dir:)
137
+ @app = app
138
+ @ts = Time.now
139
+ @dir = Pathname.new(dir) unless dir == :auto
140
+ @counter = 0
141
+ end
142
+
143
+ def call(env)
144
+ @app.call(env)
145
+ ensure
146
+ # TODO: consider exception case as well
147
+
148
+ if (d = env["catadog.intercept"])
149
+ write(JSON.pretty_generate(d))
150
+ end
151
+
152
+ @counter += 1
153
+ end
154
+
155
+ private
156
+
157
+ def dir
158
+ @dir ||= Pathname.new("records") / @ts.iso8601
159
+ end
160
+
161
+ def filename
162
+ dir / format("%05d.json", @counter)
163
+ end
164
+
165
+ def write(str)
166
+ dir.mkpath
167
+ File.open(filename, "wb") { |f| f << str }
168
+ end
169
+ end
170
+
171
+ class Proxy
172
+ def initialize(host, port)
173
+ @host = host
174
+ @port = port
175
+ @uuid = SecureRandom.uuid
176
+ end
177
+
178
+ def call(env)
179
+ r = Rack::Request.new(env)
180
+
181
+ catadog = if (h = r.get_header("HTTP_X_CATADOG"))
182
+ h.split(",")
183
+ else
184
+ []
185
+ end
186
+
187
+ raise "catadog loop detected!" if catadog.include?(@uuid)
188
+
189
+ host = @host
190
+ port = @port
191
+ uri = URI.join("http://#{host}:#{port}", r.fullpath)
192
+
193
+ Net::HTTP.start(uri.host, uri.port) do |http|
194
+ case env["REQUEST_METHOD"]
195
+ when "HEAD"
196
+ req = Net::HTTP::Head.new(uri)
197
+ when "GET"
198
+ req = Net::HTTP::Get.new(uri)
199
+ when "POST"
200
+ req = Net::HTTP::Post.new(uri)
201
+ req.body = env["rack.input"].read
202
+ when "PUT"
203
+ req = Net::HTTP::Put.new(uri)
204
+ req.body = env["rack.input"].read
205
+ when "PATCH"
206
+ req = Net::HTTP::Patch.new(uri)
207
+ req.body = env["rack.input"].read
208
+ when "DELETE"
209
+ req = Net::HTTP::Delete.new(uri)
210
+ req.body = env["rack.input"].read
211
+ end
212
+
213
+ r.each_header do |k, v|
214
+ if k == "HTTP_HOST"
215
+ req["HTTP_HOST"] = "#{host}:#{port}"
216
+ elsif k =~ /HTTP_(.*)/
217
+ header_name = $1.tr("_", "-").downcase
218
+ req[header_name] = v
219
+ end
220
+ end
221
+
222
+ req["x-catadog"] = (catadog << @uuid).join(",")
223
+
224
+ res = http.request(req)
225
+
226
+ status = Integer(res.code)
227
+ headers = res.each_header.with_object({}) { |(k, v), h| h[k] = v }
228
+ body = res.body
229
+
230
+ return [status, headers, [body]]
231
+ end
232
+ end
233
+ end
234
+
235
+ class NotFound
236
+ def call(env)
237
+ [404, {"Content-Type" => "application/json"}, [JSON.dump({})]]
238
+ end
239
+ end
240
+
241
+ class Server
242
+ def initialize(settings, logger:)
243
+ @logger = logger # for Rack
244
+
245
+ @server = WEBrick::HTTPServer.new(**options(settings, logger: logger))
246
+
247
+ @app = rack_app(settings)
248
+
249
+ @server.mount_proc("/", method(:handler))
250
+ end
251
+
252
+ def start
253
+ trap "INT" do
254
+ @server.shutdown
255
+ end
256
+
257
+ @server.start
258
+ end
259
+
260
+ private
261
+
262
+ def rack_app(settings)
263
+ Rack::Builder.new do
264
+ map "/catadog" do
265
+ run App.new
266
+ end
267
+
268
+ use Record, dir: settings.record_dir if settings.record_dir
269
+ use Print
270
+ use Intercept
271
+
272
+ settings.mocks.each do |mock|
273
+ mock_file, mock_classname = mock.split(":", 2)
274
+
275
+ unless mock_file.nil? || mock_file.empty?
276
+ mock_file = Pathname.new(mock_file)
277
+ mock_classname = mock_file.basename(".rb").to_s.gsub(/(^|_)\S/) { |m| m.tr("_", "").upcase } if mock_classname.nil?
278
+ require Pathname.pwd / mock_file
279
+ end
280
+
281
+ mock_class = mock_classname.split("::").reduce(Kernel) { |mod, name| mod.const_get(name) }
282
+
283
+ use mock_class
284
+ end
285
+
286
+ run settings.forward ? Proxy.new(settings.agent_host, settings.agent_port) : NotFound.new
287
+ end.to_app
288
+ end
289
+
290
+ def handler(req, res)
291
+ # https://github.com/rack/rack/blob/8f5c885f7e0427b489174a55e6d88463173f22d2/SPEC.rdoc
292
+
293
+ env = {}
294
+
295
+ env["REQUEST_METHOD"] = req.request_method
296
+ env["SCRIPT_NAME"] = req.script_name
297
+ env["PATH_INFO"] = req.path_info
298
+ env["QUERY_STRING"] = req.query_string || ""
299
+ env["SERVER_NAME"] = req.request_uri.host
300
+ env["SERVER_PORT"] = req.request_uri.port
301
+ env["SERVER_PROTOCOL"] = (/(HTTP\/\d(?:\.\d)?)/ =~ req.request_line && $1.chomp)
302
+ env["rack.url_scheme"] = req.request_uri.scheme
303
+
304
+ # TODO: #body_reader + IO.copy_stream
305
+ # TODO: body { |chunk| }
306
+ if req.body
307
+ input_stream = StringIO.new(req.body.dup.force_encoding("ASCII-8BIT"), "rb")
308
+ env["rack.input"] = input_stream
309
+ else
310
+ env["rack.input"] = StringIO.new
311
+ end
312
+
313
+ error_stream = StringIO.new(+"", "w")
314
+ env["rack.errors"] = error_stream
315
+
316
+ env["rack.hijack?"] = false
317
+
318
+ session = {}
319
+ env["rack.session"] = session
320
+
321
+ env["rack.logger"] = @logger if @logger
322
+
323
+ # env["rack.multipart.buffer_size"] = 0,
324
+
325
+ if req.header.key?("content-length")
326
+ env["CONTENT_LENGTH"] = Integer(req.header["content-length"].last)
327
+ end
328
+
329
+ # https://datatracker.ietf.org/doc/html/rfc3875#section-4.1.18
330
+ req.header.each { |k, v| env["HTTP_#{k.tr("-", "_").upcase}"] = v.last }
331
+
332
+ status, headers, body = @app.call(env)
333
+
334
+ # TODO: do something with rack.errors?
335
+
336
+ res.status = status
337
+ headers.each { |k, v| res[k] = v }
338
+
339
+ # TODO: handle callable and IO
340
+ res.body = body.join("")
341
+ end
342
+
343
+ def options(settings, logger:)
344
+ {
345
+ Logger: logger,
346
+ AccessLog: [[logger.instance_variable_get(:@logdev).dev, WEBrick::AccessLog::COMBINED_LOG_FORMAT]],
347
+ BindAddress: settings.host.to_s,
348
+ Port: settings.port,
349
+ ServerType: settings.daemon ? WEBrick::Daemon : WEBrick::SimpleServer
350
+ # RequestCallback: request_callback
351
+ }
352
+ end
353
+ end
354
+
355
+ class Settings
356
+ attr_accessor \
357
+ :debug,
358
+ :verbosity,
359
+ :host,
360
+ :port,
361
+ :forward,
362
+ :agent_host,
363
+ :agent_port,
364
+ :record_dir,
365
+ :silent,
366
+ :log,
367
+ :mocks,
368
+ :daemon
369
+
370
+ def initialize
371
+ @debug = false
372
+ @verbosity = 0
373
+ @host = IPAddr.new("127.0.0.1")
374
+ @port = 8128
375
+ @forward = true
376
+ @agent_host = IPAddr.new("127.0.0.1")
377
+ @agent_port = 8126
378
+ @record_dir = nil
379
+ @silent = false
380
+ @daemon = false
381
+ @log = $stderr
382
+ @mocks = []
383
+ end
384
+
385
+ def to_h
386
+ instance_variables.each_with_object({}) { |k, h| h[k] = instance_variable_get(k) }
387
+ end
388
+
389
+ def to_s
390
+ to_h.to_s
391
+ end
392
+ end
393
+
394
+ # CLI interface
395
+ class CLI
396
+ class UsageError < StandardError; end
397
+
398
+ def initialize(args)
399
+ settings = Settings.new
400
+
401
+ while (arg = args.shift)
402
+ case arg
403
+ when "-d", "--debug"
404
+ settings.debug = true
405
+ when "--daemon"
406
+ settings.daemon = true
407
+ when "-s", "--silent"
408
+ settings.silent = true
409
+ when "-v", "--verbose"
410
+ settings.verbosity += 1
411
+ when "-l", "--log"
412
+ settings.log = Pathname.new(args.shift).open("wb")
413
+ when "-h", "--bind"
414
+ settings.host = IPAddr.new(args.shift)
415
+ when "-p", "--port"
416
+ settings.port = Integer(args.shift)
417
+ when "-F", "--no-forward"
418
+ settings.forward = false
419
+ when "-f", "--agent-host"
420
+ settings.agent_host = args.shift
421
+ when "-g", "--agent-port"
422
+ settings.agent_port = Integer(args.shift)
423
+ when "-r", "--record"
424
+ settings.record_dir = (args.empty? || args.first.start_with?("-")) ? :auto : Pathname.new(args.shift)
425
+ when "-m", "--mock"
426
+ settings.mocks << String(args.shift)
427
+ else
428
+ raise UsageError, "invalid argument: #{arg}"
429
+ end
430
+ end
431
+
432
+ @settings = settings.freeze
433
+ end
434
+
435
+ def run
436
+ load_gem_mocks
437
+
438
+ logger = Logger.new(@settings.silent ? Pathname.new("/dev/null").open("wb") : @settings.log, level: log_level)
439
+ logger.debug { "settings: #{@settings}" }
440
+
441
+ server = Server.new(@settings, logger: logger)
442
+
443
+ server.start
444
+ ensure
445
+ @settings.log.close
446
+ end
447
+
448
+ def load_gem_mocks
449
+ gem_mocks_path.glob("*.rb").each { |f| require f.to_s }
450
+ end
451
+
452
+ def gem_mocks_path
453
+ Pathname.new(__dir__) / ".." / "mocks"
454
+ end
455
+
456
+ private
457
+
458
+ def log_level
459
+ @settings.debug ? Logger::DEBUG : Logger::INFO
460
+ end
461
+ end
462
+ end
463
+ end
464
+
465
+ Datadog::Catadog::CLI.new(ARGV).run
data/mocks/sink.rb ADDED
@@ -0,0 +1,46 @@
1
+ class Sink
2
+ def initialize(app)
3
+ @app = app
4
+ end
5
+
6
+ def call(env)
7
+ r = Rack::Request.new(env)
8
+
9
+ case [r.request_method, r.path]
10
+ in "GET", "/info" then info
11
+ in "POST", %r{^/v(0\.3|0\.4|0\.7)/traces$} then traces
12
+ in "POST", %r{^/telemetry/proxy/} then telemetry
13
+ else
14
+ @app.call(env)
15
+ end
16
+ end
17
+
18
+ private
19
+
20
+ def traces
21
+ payload = {}
22
+
23
+ [200, {"Content-Type" => "application/json"}, [JSON.dump(payload)]]
24
+ end
25
+
26
+ def telemetry
27
+ payload = {}
28
+
29
+ [200, {"Content-Type" => "application/json"}, [JSON.dump(payload)]]
30
+ end
31
+
32
+ def info
33
+ payload = {
34
+ "catadog" => true,
35
+ "version" => "7.54",
36
+ "endpoints" => [
37
+ "/v0.3/traces",
38
+ "/v0.4/traces",
39
+ "/v0.7/traces",
40
+ "/telemetry/proxy"
41
+ ]
42
+ }
43
+
44
+ [200, {"Content-Type" => "application/json"}, [JSON.dump(payload)]]
45
+ end
46
+ end
metadata ADDED
@@ -0,0 +1,119 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: catadog
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Datadog, Inc.
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2024-10-22 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: webrick
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: 1.8.0
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: 1.8.0
27
+ - !ruby/object:Gem::Dependency
28
+ name: rack
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '2.2'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '2.2'
41
+ - !ruby/object:Gem::Dependency
42
+ name: sinatra
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '3.0'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '3.0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: json
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: msgpack
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: '0'
76
+ type: :runtime
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: '0'
83
+ description: Intercept and analyse Datadog communication
84
+ email: dev@datadoghq.com
85
+ executables:
86
+ - catadog
87
+ extensions: []
88
+ extra_rdoc_files: []
89
+ files:
90
+ - bin/catadog
91
+ - mocks/sink.rb
92
+ homepage: https://github.com/DataDog/catadog
93
+ licenses:
94
+ - BSD-3-Clause
95
+ - Apache-2.0
96
+ metadata:
97
+ rubygems_mfa_required: 'true'
98
+ allowed_push_host: https://rubygems.org
99
+ source_code_uri: https://github.com/DataDog/catadog
100
+ post_install_message:
101
+ rdoc_options: []
102
+ require_paths:
103
+ - lib
104
+ required_ruby_version: !ruby/object:Gem::Requirement
105
+ requirements:
106
+ - - ">="
107
+ - !ruby/object:Gem::Version
108
+ version: '3.0'
109
+ required_rubygems_version: !ruby/object:Gem::Requirement
110
+ requirements:
111
+ - - ">="
112
+ - !ruby/object:Gem::Version
113
+ version: '0'
114
+ requirements: []
115
+ rubygems_version: 3.4.19
116
+ signing_key:
117
+ specification_version: 4
118
+ summary: Datadog wire introspection tool
119
+ test_files: []