Skip to content

Commit a4e23d2

Browse files
committed
Add instrumentation for HTTP::Client and general distributed tracing support.
1 parent 44a1703 commit a4e23d2

File tree

6 files changed

+198
-15
lines changed

6 files changed

+198
-15
lines changed

VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
0.2.7
1+
0.2.8

shard.yml

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
name: opentelemetry-instrumentation
2-
version: 0.2.7
2+
version: 0.2.8
33

44
authors:
55
- Kirk Haines <[email protected]>
@@ -13,7 +13,6 @@ dependencies:
1313
github: wyhaines/tracer.cr
1414
opentelemetry-api:
1515
github: wyhaines/opentelemetry-api.cr
16-
branch: main
1716
defined:
1817
github: wyhaines/defined.cr
1918

spec/instrumentation/crystal_http_server_spec.cr

Lines changed: 29 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,10 @@ describe HTTP::Server, tags: ["HTTP::Server"] do
3939
server.listen
4040
end
4141

42+
sleep 1
43+
4244
# Send requests to the server. These will generate OTel traces.
43-
2.times do
45+
1.times do
4446
HTTP::Client.get("http://127.0.0.1:8080")
4547
sleep((rand() * 100) / 1000)
4648
end
@@ -54,17 +56,33 @@ describe HTTP::Server, tags: ["HTTP::Server"] do
5456
traces << JSON.parse(json)
5557
end
5658

57-
traces[0]["spans"][0]["name"].should eq "HTTP::Server connection"
58-
traces[0]["resource"]["service.name"].should eq "Crystal OTel Instrumentation - HTTP::Server"
59-
traces[0]["spans"][0]["kind"].should eq 2 # Unspecified = 0 | Internal = 1 | Server = 2 | Client = 3 | Producer = 4 | Consumer = 5
60-
traces[0]["resource"]["service.version"].should eq "1.0.0"
61-
traces[0]["spans"][0]["attributes"]["net.peer.ip"].should eq "127.0.0.1"
59+
client_traces = traces.select { |t| t["spans"][0]["kind"] == 3 }
60+
server_traces = traces.reject { |t| t["spans"][0]["kind"] == 3 }
61+
62+
{% begin %}
63+
{% if flag? :DEBUG %}
64+
pp client_traces
65+
pp "---------------"
66+
pp server_traces
67+
{% end %}
68+
{% end %}
69+
70+
client_traces[0]["spans"][0]["name"].should eq("HTTP::Client GET")
71+
client_traces[0]["spans"][0]["kind"].should eq 3
72+
client_traces[0]["spans"][0]["attributes"]["http.url"].should eq "http://127.0.0.1:8080/"
73+
74+
server_traces[0]["spans"][0]["name"].should eq "HTTP::Server connection"
75+
server_traces[0]["resource"]["service.name"].should eq "Crystal OTel Instrumentation - HTTP::Server"
76+
server_traces[0]["spans"][0]["kind"].should eq 2 # Unspecified = 0 | Internal = 1 | Server = 2 | Client = 3 | Producer = 4 | Consumer = 5
77+
server_traces[0]["resource"]["service.version"].should eq "1.0.0"
78+
server_traces[0]["spans"][0]["attributes"]["net.peer.ip"].should eq "127.0.0.1"
6279

63-
traces[1]["spans"][0]["name"].should eq "GET /"
64-
traces[1]["spans"][0]["attributes"]["http.method"].should eq "GET"
65-
traces[1]["spans"][0]["attributes"]["http.scheme"].should eq "http"
80+
server_traces[1]["spans"][0]["name"].should eq "GET /"
81+
server_traces[1]["spans"][0]["attributes"]["http.method"].should eq "GET"
82+
server_traces[1]["spans"][0]["attributes"]["http.scheme"].should eq "http"
83+
server_traces[1]["spans"][0]["parentSpanId"].should eq client_traces[0]["spans"][0]["spanId"]
6684

67-
traces[1]["spans"][1]["name"].should eq "Invoke handler Proc(HTTP::Server::Context, Nil)"
68-
traces[1]["spans"][1]["kind"].should eq 1
85+
server_traces[1]["spans"][1]["name"].should eq "Invoke handler Proc(HTTP::Server::Context, Nil)"
86+
server_traces[1]["spans"][1]["kind"].should eq 1
6987
end
7088
end

src/opentelemetry/instrumentation/crystal/db.cr

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ unless_enabled?("OTEL_CRYSTAL_DISABLE_INSTRUMENTATION_DB") do
104104
span["db.operation"] = operation
105105
span.kind = OpenTelemetry::Span::Kind::Client
106106

107-
yield # Perform the actual query
107+
yield # Perform the actual query
108108
end.not_nil! # Because of exception handling in the `#in_span`, the compiler gets confused about nils.
109109
# If the block passed to `#in_span` will never return a nil, the `#in_span` implementation should detect
110110
# that, and at runtime, the right thing will be returned. But, the compiler doesn't realize this, so
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
require "../instrument"
2+
3+
# # OpenTelemetry::Instrumentation::CrystalHttpClient
4+
#
5+
# ### Instruments
6+
# * First::Class::Instrumented
7+
# * Second::Class::Instrumented
8+
#
9+
# ### Reference: [https://crystal-lang.org/api/1.4.1/HTTP/Client.html](https://crystal-lang.org/api/1.4.1/HTTP/Client.html)
10+
#
11+
# Description of the instrumentation provided, including any nuances, caveats, instructions, or warnings.
12+
#
13+
# ## Methods Affected
14+
#
15+
# * First::Class#method_name
16+
#
17+
struct OpenTelemetry::InstrumentationDocumentation::CrystalHttpClient
18+
end
19+
20+
# This allows opt-out of specific instrumentation at compile time, via environment variables.
21+
# Refer to https://wyhaines.github.io/defined.cr/ for details about all supported check types.
22+
unless_enabled?("OTEL_CRYSTAL_DISABLE_INSTRUMENTATION_HTTP_CLIENT") do
23+
# Next should be one or more checks intended to validate that the class(es) to be instrumented
24+
# actually exist. It should be possible to require all instrumentation, regardless of whether
25+
# a given class/package is actually used, as the instrumentation should not attempt to install
26+
# itself if that installation will fail.
27+
if_defined?(HTTP::Client) do
28+
# This exists to record the instrumentation in the OpenTelemetry::Instrumentation::Registry,
29+
# which may be used by other code/tools to introspect the installed instrumentation.
30+
module OpenTelemetry::Instrumentation
31+
class CrystalHttpClient < OpenTelemetry::Instrumentation::Instrument
32+
end
33+
end
34+
35+
if_version?(Crystal, :>=, "1.0.0") do
36+
# TODO: Offer these refactors as a PR to core Crystal.
37+
class HTTP::Client
38+
private def io
39+
io = @io
40+
return io if io
41+
unless @reconnect
42+
raise "This HTTP::Client cannot be reconnected"
43+
end
44+
45+
do_connect
46+
end
47+
48+
def do_connect
49+
hostname = @host.starts_with?('[') && @host.ends_with?(']') ? @host[1..-2] : @host
50+
io = TCPSocket.new hostname, @port, @dns_timeout, @connect_timeout
51+
io.read_timeout = @read_timeout if @read_timeout
52+
io.write_timeout = @write_timeout if @write_timeout
53+
io.sync = false
54+
55+
{% if !flag?(:without_openssl) %}
56+
io = do_connect_ssl(io)
57+
{% end %}
58+
59+
@io = io
60+
end
61+
62+
def do_connect_ssl(io)
63+
if tls = @tls
64+
tcp_socket = io
65+
begin
66+
io = OpenSSL::SSL::Socket::Client.new(tcp_socket, context: tls, sync_close: true, hostname: @host)
67+
rescue exc
68+
# don't leak the TCP socket when the SSL connection failed
69+
tcp_socket.close
70+
raise exc
71+
end
72+
end
73+
74+
io
75+
end
76+
end
77+
78+
# #### Actual Instrumentation Here. This way, if the above gets accepted into Crystal
79+
# #### core, we just need to delete everything above here and we are good to go.
80+
81+
class HTTP::Client
82+
trace("do_connect") do
83+
trace = OpenTelemetry.trace
84+
trace.in_span("HTTP::Client Connect") do |span|
85+
span.client!
86+
io = previous_def
87+
span["net.peer.name"] = @host
88+
span["net.peer.port"] = @port
89+
90+
io
91+
end.not_nil!
92+
end
93+
94+
trace("do_connect_ssl") do
95+
trace = OpenTelemetry.trace
96+
trace.in_span("Negotiate SSL") do |_span|
97+
previous_def
98+
end.not_nil!
99+
end
100+
101+
def_around_exec do |request|
102+
trace = OpenTelemetry.trace
103+
trace.in_span("HTTP::Client #{request.method}") do |span|
104+
span.client!
105+
span["http.host"] = self.host
106+
span["http.port"] = self.port
107+
span["http.method"] = request.method
108+
span["http.flavor"] = request.version.split("/").last
109+
span["http.scheme"] = request.scheme
110+
if content_length = request.content_length
111+
span["http.response_content_length"] = content_length
112+
end
113+
span["http.url"] = "#{request.scheme}://#{self.host}:#{self.port}#{request.resource}"
114+
span["guid"] = span.span_id.hexstring
115+
116+
OpenTelemetry::Propagation::TraceContext.new(span.context).inject(request.headers).not_nil!
117+
118+
response = yield request
119+
120+
span["http.status_code"] = response.status_code
121+
if response.success?
122+
# span.status.ok!
123+
else
124+
span.status.error!
125+
span["http.status_message"] = response.status_message.to_s
126+
end
127+
128+
response
129+
end.not_nil!
130+
end
131+
end
132+
end
133+
end
134+
end

src/opentelemetry/instrumentation/crystal/http_server.cr

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,31 @@ unless_enabled?("OTEL_CRYSTAL_DISABLE_INSTRUMENTATION_HTTP_SERVER") do
107107
break unless request
108108
trace = OpenTelemetry.trace
109109
trace_name = request.is_a?(HTTP::Request) ? "#{request.method} #{request.path}" : "ERROR #{request.code}"
110+
if request.is_a?(HTTP::Request) && (tp_header = request.headers["traceparent"]?)
111+
puts "()()()()()() traceparent header: #{tp_header}"
112+
traceparent = OpenTelemetry::Propagation::TraceContext::TraceParent.from_string(tp_header)
113+
puts "()()()()()() Set Parent TraceID -- #{traceparent.trace_id.hexstring}"
114+
trace.trace_id = traceparent.trace_id
115+
trace.span_context.trace_id = traceparent.trace_id
116+
end
117+
110118
trace.in_span(trace_name) do |span|
119+
if request.is_a?(HTTP::Request) && request.headers["traceparent"]?
120+
parent = OpenTelemetry::Span.build do |pspan|
121+
pspan.is_recording = false
122+
123+
pspan.context = OpenTelemetry::Propagation::TraceContext.new(span.context).extract(request.headers).not_nil!
124+
puts request.headers["traceparent"]?
125+
request.headers.delete("traceparent")
126+
request.headers.delete("tracestate")
127+
end
128+
129+
# if span.parent.nil?
130+
span.parent = parent
131+
puts " setting parent span id: #{parent.context.span_id.hexstring}"
132+
# end
133+
end
134+
111135
span.server!
112136
# TODO: When Span Links are supported, add a Link to the span that instrumented the actual connection.
113137
response.reset
@@ -137,6 +161,12 @@ unless_enabled?("OTEL_CRYSTAL_DISABLE_INSTRUMENTATION_HTTP_SERVER") do
137161
span["http.response_content_length"] = content_length
138162
end
139163
span["http.url"] = request.full_url
164+
# span["http.target"] = request.resource
165+
if span_parent = span.parent
166+
span["parentId"] = span_parent.span_id.hexstring
167+
span["parent.id"] = span_parent.span_id.hexstring
168+
end
169+
span["guid"] = span.span_id.hexstring
140170
end
141171

142172
OpenTelemetry::Trace.current_trace.not_nil!.in_span("Invoke handler #{@handler.class.name}") do |handler_span|
@@ -162,6 +192,8 @@ unless_enabled?("OTEL_CRYSTAL_DISABLE_INSTRUMENTATION_HTTP_SERVER") do
162192
ensure
163193
if response.status_code >= 400
164194
span.status.error!("HTTP Error: #{response.status.code} -> #{response.status.description}")
195+
else
196+
# span.status.ok!
165197
end
166198
response.output.close
167199
end

0 commit comments

Comments
 (0)