-
Notifications
You must be signed in to change notification settings - Fork 375
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add support for http.client_ip
tag for Rack-based frameworks
#2248
Changes from 12 commits
70440ae
cbfca12
010066e
0c9eecb
ee61750
ca4c899
d9532ca
5d22113
ef6f427
00f3c7d
f49d03e
2dae9e6
76e6e0a
ea3b064
7bbb322
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,39 @@ | ||
module Datadog | ||
module Core | ||
# A some-what abstract class representing a collection of headers. | ||
# | ||
# Use the `HeaderCollection.from_hash` function to create a header collection from a `Hash`. | ||
# Another option is to use `HashHeaderCollection` directly. | ||
class HeaderCollection | ||
# Gets a single value of the header with the given name, case insensitive. | ||
# | ||
# @param [String] header_name Name of the header to get the value of. | ||
# @returns [String, nil] A single value of the header, or nil if the header with | ||
# the given name is missing from the collection. | ||
def get(header_name) | ||
nil | ||
end | ||
|
||
# Create a header collection that retrieves headers from the given Hash. | ||
# | ||
# This can be useful for testing or other trivial use cases. | ||
# | ||
# @param [Hash] hash Hash with the headers. | ||
def self.from_hash(hash) | ||
HashHeaderCollection.new(hash) | ||
end | ||
end | ||
|
||
# A header collection implementation that looks up headers in a Hash. | ||
class HashHeaderCollection < HeaderCollection | ||
def initialize(hash) | ||
super() | ||
@hash = hash.transform_keys(&:downcase) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
puts({}.transform_keys(&:downcase))
# == 2.4.10 ==
# -e:1:in `<main>': undefined method `transform_keys' for {}:Hash (NoMethodError)
# Did you mean? transform_values
#
# == 2.5.6 ==
# {} Is this method being hit during our test runs? It should show up as a failure in 2.1-2.4 test cases, if so. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It's used in There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. For some reason these tests don't fail in the pipeline: |
||
end | ||
|
||
def get(header_name) | ||
@hash[header_name.downcase] | ||
end | ||
end | ||
end | ||
end |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,151 @@ | ||
# typed: true | ||
|
||
require_relative '../core/configuration' | ||
require_relative 'metadata/ext' | ||
require_relative 'span' | ||
|
||
module Datadog | ||
module Tracing | ||
# Common functions for supporting the `http.client_ip` span attribute. | ||
module ClientIp | ||
DEFAULT_IP_HEADERS_NAMES = %w[ | ||
x-forwarded-for | ||
x-real-ip | ||
x-client-ip | ||
x-forwarded | ||
x-cluster-client-ip | ||
forwarded-for | ||
forwarded | ||
via | ||
true-client-ip | ||
].freeze | ||
|
||
TAG_MULTIPLE_IP_HEADERS = '_dd.multiple-ip-headers'.freeze | ||
|
||
# Sets the `http.client_ip` tag on the given span. | ||
# | ||
# This function respects the user's settings: if they disable the client IP tagging, | ||
# or provide a different IP header name. | ||
# | ||
# If multiple IP headers are present in the request, this function will instead set | ||
# the `_dd.multiple-ip-headers` tag with the names of the present headers, | ||
# and **NOT** set the `http.client_ip` tag. | ||
# | ||
# @param [Span] span The span that's associated with the request. | ||
# @param [HeaderCollection, #get, nil] headers A collection with the request headers. | ||
# @param [String, nil] remote_ip The remote IP the request associated with the span is sent to. | ||
def self.set_client_ip_tag(span, headers: nil, remote_ip: nil) | ||
return unless configuration.enabled | ||
|
||
result = raw_ip_from_request(headers, remote_ip) | ||
|
||
if result.raw_ip | ||
ip = strip_decorations(result.raw_ip) | ||
return unless valid_ip?(ip) | ||
|
||
span.set_tag(Tracing::Metadata::Ext::HTTP::TAG_CLIENT_IP, ip) | ||
elsif result.multiple_ip_headers | ||
span.set_tag(TAG_MULTIPLE_IP_HEADERS, result.multiple_ip_headers.keys.join(',')) | ||
end | ||
end | ||
|
||
IpExtractionResult = Struct.new(:raw_ip, :multiple_ip_headers) | ||
|
||
# Returns a result struct that holds the raw client IP associated with the request if it was | ||
# retrieved successfully. | ||
# | ||
# The client IP is looked up by the following logic: | ||
# * If the user has configured a header name, return that header's value. | ||
# * If exactly one of the known IP headers is present, return that header's value. | ||
# * If none of the known IP headers are present, return the remote IP from the request. | ||
# | ||
# If more than one of the known IP headers is present, the result will have a `multiple_ip_headers` | ||
# field with the name of the present IP headers. | ||
# | ||
# @param [Datadog::Core::HeaderCollection, #get, nil] headers The request headers | ||
# @param [String] remote_ip The remote IP of the request. | ||
# @return [IpExtractionResult] A struct that holds the unprocessed IP value, | ||
# or `nil` if it wasn't found. Additionally, the `multiple_ip_headers` fields will hold the | ||
# name of known IP headers present in the request if more than one of these were found. | ||
def self.raw_ip_from_request(headers, remote_ip) | ||
return IpExtractionResult.new(headers && headers.get(configuration.header_name), nil) if configuration.header_name | ||
|
||
headers_present = ip_headers(headers) | ||
|
||
case headers_present.size | ||
when 0 | ||
IpExtractionResult.new(remote_ip, nil) | ||
when 1 | ||
IpExtractionResult.new(headers_present.values.first, nil) | ||
else | ||
IpExtractionResult.new(nil, headers_present) | ||
end | ||
end | ||
|
||
# Removes any port notations or zone specifiers from the IP address without | ||
# verifying its validity. | ||
def self.strip_decorations(address) | ||
return strip_ipv4_port(address) if likely_ipv4?(address) | ||
|
||
address = strip_ipv6_port(address) | ||
|
||
strip_zone_specifier(address) | ||
end | ||
|
||
def self.strip_zone_specifier(ipv6) | ||
ipv6.gsub(/%.*/, '') | ||
end | ||
|
||
def self.strip_ipv4_port(ip) | ||
ip.gsub(/:\d+\z/, '') | ||
end | ||
|
||
def self.strip_ipv6_port(ip) | ||
if /\[(.*)\](?::\d+)?/ =~ ip | ||
Regexp.last_match(1) | ||
else | ||
ip | ||
end | ||
end | ||
|
||
# Returns whether the given value is more likely to be an IPv4 than an IPv6 address. | ||
# | ||
# This is done by checking if a dot (`'.'`) character appears before a colon (`':'`) in the value. | ||
# The rationale is that in valid IPv6 addresses, colons will always preced dots, | ||
# and in valid IPv4 addresses dots will always preced colons. | ||
def self.likely_ipv4?(value) | ||
dot_index = value.index('.') || value.size | ||
colon_index = value.index(':') || value.size | ||
|
||
dot_index < colon_index | ||
end | ||
|
||
# Determines whether the given string is a valid IPv4 or IPv6 address. | ||
def self.valid_ip?(ip) | ||
# Client IPs should not have subnet masks even though IPAddr can parse them. | ||
return false if ip.include?('/') | ||
|
||
begin | ||
IPAddr.new(ip) | ||
|
||
true | ||
rescue IPAddr::Error | ||
false | ||
end | ||
end | ||
|
||
def self.ip_headers(headers) | ||
return {} unless headers | ||
|
||
DEFAULT_IP_HEADERS_NAMES.each_with_object({}) do |name, result| | ||
value = headers.get(name) | ||
result[name] = value unless value.nil? | ||
end | ||
end | ||
|
||
def self.configuration | ||
Datadog.configuration.tracing.client_ip | ||
end | ||
end | ||
end | ||
end |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,35 @@ | ||
require_relative '../../../core/header_collection' | ||
|
||
module Datadog | ||
module Tracing | ||
module Contrib | ||
module Rack | ||
# Classes and utilities for handling headers in Rack. | ||
module Header | ||
# An implementation of a header collection that looks up headers from a Rack environment. | ||
class RequestHeaderCollection < Datadog::Core::HeaderCollection | ||
# Creates a header collection from a rack environment. | ||
def initialize(env) | ||
super() | ||
@env = env | ||
end | ||
|
||
# Gets the value of the header with the given name. | ||
def get(header_name) | ||
@env[Header.to_rack_header(header_name)] | ||
end | ||
|
||
# Tests whether a header with the given name exists in the environment. | ||
def key?(header_name) | ||
@env.key?(Header.to_rack_header(header_name)) | ||
end | ||
end | ||
|
||
def self.to_rack_header(name) | ||
"HTTP_#{name.to_s.upcase.gsub(/[-\s]/, '_')}" | ||
end | ||
end | ||
end | ||
end | ||
end | ||
end |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Linter should have caught this?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Whoops.
Rubocop seems to pass but I shouldv'e considered the formatting in the other options.
Fixed