trackdown 0.3.0 → 0.4.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.
@@ -3,15 +3,93 @@
3
3
  require 'countries'
4
4
 
5
5
  module Trackdown
6
+ # Where an IP address probably is — and an honest account of how we know.
7
+ #
8
+ # Every result carries its own provenance: which provider answered, from which
9
+ # source, when it answered, how precise that provider says the answer is, and
10
+ # whether the host vouched for the path the request arrived through.
11
+ #
12
+ # Nothing here is guessed. Every field is either what the provider said or
13
+ # something derived from it by a documented rule — never a plausible-looking
14
+ # placeholder. A field the answering provider cannot supply is `nil`, so a
15
+ # caller can always tell "the provider said no" apart from "it never said".
16
+ #
17
+ # GeoIP is an estimate. It never proves that a person or a device was in a
18
+ # place. MaxMind documents those limits exactly:
19
+ # https://support.maxmind.com/knowledge-base/articles/maxmind-geolocation-accuracy
6
20
  class LocationResult
21
+ # What providers have always returned for a value they don't have. Kept for
22
+ # backwards compatibility — new code should ask #available? and read the
23
+ # nil-able fields instead of parsing display strings.
24
+ UNKNOWN = 'Unknown'
25
+ UNKNOWN_FLAG = '🏳️'
26
+
27
+ # Why a lookup came back with no location. Stable and machine-readable:
28
+ # these symbols are part of the public API and are never translated.
29
+ UNAVAILABLE_REASONS = %i[
30
+ no_provider_available
31
+ address_not_found
32
+ provider_returned_unknown_country
33
+ provider_data_incomplete
34
+ ].freeze
35
+
36
+ # How much the host vouches for the source of a request-backed result.
37
+ # :host_verified only ever comes from the host's own verifier — see
38
+ # Trackdown::Configuration#verify_request_came_through_trusted_cloudflare_path_with
39
+ # and #verify_request_came_through_trusted_cloudfront_path_with.
40
+ SOURCE_TRUSTS = %i[unverified host_verified].freeze
41
+
42
+ # Where the IP is.
43
+ LOCATION_FIELDS = %i[
44
+ country_code country_name city flag_emoji
45
+ region region_code continent timezone
46
+ latitude longitude postal_code metro_code
47
+ ].freeze
48
+
49
+ # How we know, and how sure we are.
50
+ PROVENANCE_FIELDS = %i[
51
+ provider_name provider_source source_trust resolved_at
52
+ available estimated unavailable_reason
53
+ accuracy_radius_in_kilometers accuracy_radius_confidence_percentage
54
+ database_build_epoch database_built_at database_sha256
55
+ ].freeze
56
+
57
+ # Every field #to_h can emit, in the order it emits them.
58
+ FIELDS = (LOCATION_FIELDS + PROVENANCE_FIELDS + %i[country_info]).freeze
59
+
60
+ # What a no-argument #to_h returns: the exact shape Trackdown returned before
61
+ # provenance existed, kept that way as an API compatibility guarantee. Ask for
62
+ # the rest with `include_provenance: true` or name it in `only:`.
63
+ DEFAULT_FIELDS = (LOCATION_FIELDS + %i[country_info]).freeze
64
+
65
+ # The digest is the one field that costs real work — a full read of the
66
+ # database file — so nothing hands it to you unless you say its name.
67
+ FIELDS_EXCEPT_DIGEST = (FIELDS - %i[database_sha256]).freeze
68
+ private_constant :FIELDS_EXCEPT_DIGEST
69
+
7
70
  attr_reader :country_code, :country_name, :city, :flag_emoji,
8
71
  :region, :region_code, :continent, :timezone, :latitude, :longitude,
9
- :postal_code, :metro_code
72
+ :postal_code, :metro_code,
73
+ :provider_name, :provider_source, :source_trust, :resolved_at,
74
+ :unavailable_reason,
75
+ :accuracy_radius_in_kilometers, :accuracy_radius_confidence_percentage,
76
+ :database_build_epoch
10
77
 
78
+ # Every keyword is optional, so a provider only supplies what it actually knows.
79
+ #
80
+ # @param database_sha256 [String, #call, nil] the digest itself, or something
81
+ # that returns it, so an expensive digest is computed only if someone asks.
82
+ # Concurrent readers may each call it, so a callable should memoize its own
83
+ # work — Trackdown's does.
11
84
  def initialize(country_code, country_name, city, flag_emoji,
12
85
  region: nil, region_code: nil, continent: nil,
13
86
  timezone: nil, latitude: nil, longitude: nil,
14
- postal_code: nil, metro_code: nil)
87
+ postal_code: nil, metro_code: nil,
88
+ provider_name: nil, provider_source: nil, source_trust: nil,
89
+ resolved_at: nil, unavailable_reason: nil,
90
+ accuracy_radius_in_kilometers: nil,
91
+ accuracy_radius_confidence_percentage: nil,
92
+ database_build_epoch: nil, database_sha256: nil)
15
93
  @country_code = country_code
16
94
  @country_name = country_name
17
95
  @city = city
@@ -24,34 +102,161 @@ module Trackdown
24
102
  @longitude = longitude
25
103
  @postal_code = postal_code
26
104
  @metro_code = metro_code
105
+
106
+ @provider_name = provider_name
107
+ @provider_source = provider_source
108
+ @source_trust = validate!(source_trust, SOURCE_TRUSTS, 'source trust')
109
+ @resolved_at = resolved_at || Time.now.utc
110
+ @unavailable_reason = validate!(unavailable_reason, UNAVAILABLE_REASONS, 'unavailable reason') ||
111
+ (:provider_data_incomplete if blank?(country_code))
112
+ @accuracy_radius_in_kilometers = accuracy_radius_in_kilometers
113
+ @accuracy_radius_confidence_percentage = accuracy_radius_confidence_percentage
114
+ @database_build_epoch = database_build_epoch
115
+ @database_sha256 = database_sha256
116
+ end
117
+
118
+ # A lookup that resolved nothing, and says why.
119
+ #
120
+ # LocationResult.unavailable(:address_not_found, provider_name: :maxmind)
121
+ def self.unavailable(reason, **provenance)
122
+ if reason.nil?
123
+ raise ArgumentError, "An unavailable result has to say why. Must be one of: #{UNAVAILABLE_REASONS.join(', ')}"
124
+ end
125
+ if provenance.key?(:unavailable_reason)
126
+ raise ArgumentError, 'Pass the unavailable reason once, as the first argument to LocationResult.unavailable'
127
+ end
128
+
129
+ new(nil, UNKNOWN, UNKNOWN, UNKNOWN_FLAG, unavailable_reason: reason, **provenance)
27
130
  end
28
131
 
29
132
  alias_method :country, :country_name
30
133
  alias_method :emoji, :flag_emoji
31
134
  alias_method :emoji_flag, :flag_emoji
32
135
  alias_method :country_flag, :flag_emoji
136
+ alias_method :provider, :provider_name
137
+ alias_method :accuracy_radius_km, :accuracy_radius_in_kilometers
138
+
139
+ # Did we actually resolve a location?
140
+ def available?
141
+ @unavailable_reason.nil?
142
+ end
143
+
144
+ def unavailable?
145
+ !available?
146
+ end
147
+
148
+ # True whenever a provider resolved any location data, including a partial
149
+ # result that has a city or coordinates but no country. Availability answers
150
+ # "could we name a country?"; estimated answers "is any returned location an
151
+ # inference?" Those are deliberately independent questions.
152
+ def estimated?
153
+ available? || partial_location_estimate?
154
+ end
155
+
156
+ # True only when the host's own verifier vouched for this request's path.
157
+ # Header presence alone never gets you here.
158
+ def source_was_verified_by_host?
159
+ @source_trust == :host_verified
160
+ end
161
+ alias_method :host_verified?, :source_was_verified_by_host?
162
+
163
+ # The MaxMind database digest, computed the first time it's asked for.
164
+ def database_sha256
165
+ @database_sha256 = @database_sha256.call if @database_sha256.respond_to?(:call)
166
+ @database_sha256
167
+ end
168
+
169
+ # When the database that answered was built.
170
+ def database_built_at
171
+ Time.at(@database_build_epoch).utc if @database_build_epoch.is_a?(Numeric)
172
+ end
33
173
 
34
174
  def country_info
35
175
  return nil unless country_code
176
+
36
177
  ISO3166::Country.new(country_code)
37
178
  end
38
179
 
39
- def to_h
40
- {
41
- country_code: @country_code,
42
- country_name: @country_name,
43
- city: @city,
44
- flag_emoji: @flag_emoji,
45
- region: @region,
46
- region_code: @region_code,
47
- continent: @continent,
48
- timezone: @timezone,
49
- latitude: @latitude,
50
- longitude: @longitude,
51
- postal_code: @postal_code,
52
- metro_code: @metro_code,
53
- country_info: country_info&.data || {}
54
- }
180
+ # The whole result as a hash, or exactly the fields you name.
181
+ #
182
+ # result.to_h
183
+ # result.to_h(only: %i[country_code city latitude longitude provider_name])
184
+ # result.to_h(include_country_info: false)
185
+ # result.to_h(include_provenance: true)
186
+ #
187
+ # @param only [Array<Symbol>, Symbol, nil] the exact fields to serialize, in
188
+ # the order you name them. What you name is what you get: naming a field
189
+ # that doesn't exist raises, and nothing you name is ever dropped, so a typo
190
+ # can't silently cost you a column in a record you're keeping. The full list
191
+ # of names is FIELDS.
192
+ # @param include_country_info [Boolean] the derived `countries` gem payload is
193
+ # large; pass false to leave it out of the default shape. Ignored when you
194
+ # pass `only:`, which already says exactly what you want.
195
+ # @param include_provenance [Boolean] add every provenance field except the
196
+ # database digest, whose first read costs a full pass over the database file.
197
+ # Ignored when `only:` names an exact shape.
198
+ def to_h(only: nil, include_country_info: true, include_provenance: false)
199
+ fields = if only
200
+ requested_fields(only)
201
+ elsif include_provenance
202
+ provenance_fields(include_country_info: include_country_info)
203
+ elsif include_country_info
204
+ DEFAULT_FIELDS
205
+ else
206
+ DEFAULT_FIELDS - %i[country_info]
207
+ end
208
+
209
+ fields.to_h { |field| [field, value_of(field)] }
210
+ end
211
+
212
+ private
213
+
214
+ def value_of(field)
215
+ case field
216
+ when :available then available?
217
+ when :estimated then estimated?
218
+ when :country_info then country_info&.data || {}
219
+ else public_send(field)
220
+ end
221
+ end
222
+
223
+ def requested_fields(only)
224
+ fields = Array(only).map { |field| field.respond_to?(:to_sym) ? field.to_sym : field }
225
+ unknown = fields - FIELDS
226
+
227
+ unless unknown.empty?
228
+ raise ArgumentError, "Unknown #{unknown.one? ? 'field' : 'fields'} for LocationResult#to_h: " \
229
+ "#{unknown.map(&:inspect).join(', ')}. Available fields: #{FIELDS.join(', ')}"
230
+ end
231
+
232
+ fields.uniq
233
+ end
234
+
235
+ def provenance_fields(include_country_info:)
236
+ return FIELDS_EXCEPT_DIGEST if include_country_info
237
+
238
+ FIELDS_EXCEPT_DIGEST - %i[country_info]
239
+ end
240
+
241
+ def validate!(value, allowed, description)
242
+ return nil if value.nil?
243
+ return value if allowed.include?(value)
244
+
245
+ raise ArgumentError, "Unknown #{description}: #{value.inspect}. Must be one of: #{allowed.join(', ')}"
246
+ end
247
+
248
+ def blank?(value)
249
+ value.nil? || (value.respond_to?(:empty?) && value.empty?)
250
+ end
251
+
252
+ def partial_location_estimate?
253
+ named_place = [@country_name, @city].any? { |value| !blank?(value) && value != UNKNOWN }
254
+ provider_fields = [
255
+ @region, @region_code, @continent, @timezone,
256
+ @latitude, @longitude, @postal_code, @metro_code
257
+ ]
258
+
259
+ named_place || provider_fields.any? { |value| !blank?(value) }
55
260
  end
56
261
  end
57
262
  end
@@ -1,25 +1,59 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'ipaddr'
4
+
3
5
  require_relative 'base_provider'
4
6
  require_relative 'cloudflare_provider'
7
+ require_relative 'cloudfront_provider'
5
8
  require_relative 'maxmind_provider'
6
9
 
7
10
  module Trackdown
8
11
  module Providers
9
12
  # Intelligent provider that automatically selects the best available provider
10
- # Priority order:
11
- # 1. Cloudflare (fastest, zero overhead, no external dependencies)
12
- # 2. MaxMind (fallback when Cloudflare not available)
13
+ # Selection order:
14
+ # 1. Use the single edge provider whose client-IP header matches the target IP.
15
+ # 2. Try MaxMind, then return Unknown, when no edge provider can be verified.
16
+ # 3. Fail closed when both edge providers appear valid; header names alone cannot
17
+ # distinguish an authentic stacked-CDN request from forwarded viewer input.
13
18
  #
14
19
  # This is the recommended default for most applications
20
+ #
21
+ # IMPORTANT: When there's an upstream proxy before the CDN (e.g., a legacy
22
+ # API gateway), the CDN's geo headers will reflect the proxy's location, not
23
+ # the real client. AutoProvider detects this by comparing the CDN's own view
24
+ # of the client IP (CF-Connecting-IP for Cloudflare, CloudFront-Viewer-Address
25
+ # for CloudFront) with the passed IP and falls back to MaxMind (or Unknown when
26
+ # MaxMind is unavailable) on a mismatch.
27
+ #
28
+ # Cloudflare documents that CF-Connecting-IP is added only on edge-to-origin traffic:
29
+ # https://developers.cloudflare.com/fundamentals/reference/http-headers/#cf-connecting-ip
30
+ # AWS documents that the managed CloudFront policy forwards every viewer header,
31
+ # so CF-* names can still be viewer-controlled when they arrive through CloudFront:
32
+ # https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/using-managed-origin-request-policies.html#managed-origin-request-policy-all-viewer-and-cloudfront
15
33
  class AutoProvider < BaseProvider
16
- @@warned_no_providers = false
17
- @@warn_mutex = Mutex.new
34
+ CF_CONNECTING_IP_HEADER = 'HTTP_CF_CONNECTING_IP'
35
+ CLOUDFRONT_VIEWER_ADDRESS_HEADER = 'HTTP_CLOUDFRONT_VIEWER_ADDRESS'
36
+
37
+ @warned_no_providers = false
38
+ @warned_ip_mismatch = false
39
+ @warned_ambiguous_edge = false
40
+ @warn_mutex = Mutex.new
18
41
 
19
42
  class << self
43
+ # :auto has no identity of its own. Every result names whichever provider
44
+ # actually won, and a result with no provider at all names none.
45
+ def provider_name
46
+ nil
47
+ end
48
+
49
+ def provider_source
50
+ nil
51
+ end
52
+
20
53
  # Auto provider is available if at least one provider is available
21
54
  def available?(request: nil)
22
- CloudflareProvider.available?(request: request) ||
55
+ cloudflare_auto_available?(request) ||
56
+ cloudfront_auto_available?(request) ||
23
57
  MaxmindProvider.available?(request: request)
24
58
  end
25
59
 
@@ -28,34 +62,227 @@ module Trackdown
28
62
  # @param request [ActionDispatch::Request, nil] Optional Rails request object
29
63
  # @return [LocationResult] The location information
30
64
  def locate(ip, request: nil)
31
- # Try Cloudflare first - it's instant and free!
32
- if CloudflareProvider.available?(request: request)
33
- return CloudflareProvider.locate(ip, request: request)
34
- end
65
+ edge_provider = select_edge_provider(ip, request)
66
+ return edge_provider.locate(ip, request: request) if edge_provider
35
67
 
36
68
  # Fall back to MaxMind if available
37
- if MaxmindProvider.available?(request: request)
38
- return MaxmindProvider.locate(ip, request: request)
39
- end
69
+ return MaxmindProvider.locate(ip, request: request) if MaxmindProvider.available?(request: request)
40
70
 
41
71
  # No providers available - fail gracefully with a warning
42
72
  warn_no_providers
43
- LocationResult.new(nil, 'Unknown', 'Unknown', '🏳️')
73
+ LocationResult.unavailable(:no_provider_available)
44
74
  end
45
75
 
46
76
  private
47
77
 
78
+ def select_edge_provider(ip, request)
79
+ candidates = edge_candidates(ip, request)
80
+ verified_indices = candidates.each_index.select { |index| candidates[index][:matches] }
81
+
82
+ if verified_indices.length > 1
83
+ # The AWS managed policy forwards all viewer headers, so a CloudFront viewer
84
+ # can manufacture a matching CF-Connecting-IP. Conversely, Cloudflare forwards
85
+ # ordinary viewer headers, including CloudFront-* names. With both candidates
86
+ # matching, :auto has no authenticated signal that can safely break the tie.
87
+ warn_ambiguous_edge(ip)
88
+ return nil
89
+ end
90
+
91
+ return warn_first_unverified(candidates, ip) if verified_indices.empty?
92
+
93
+ selected_index = verified_indices.first
94
+ skipped_candidate = candidates.first(selected_index).find { |candidate| candidate[:available] }
95
+ warn_unverified_candidate(skipped_candidate, ip) if skipped_candidate
96
+ candidates[selected_index][:provider]
97
+ end
98
+
99
+ def edge_candidates(ip, request)
100
+ cloudflare_available = CloudflareProvider.available?(request: request)
101
+ cloudfront_available = CloudfrontProvider.available?(request: request)
102
+
103
+ [
104
+ {
105
+ provider: CloudflareProvider,
106
+ name: 'Cloudflare',
107
+ header_name: 'CF-Connecting-IP',
108
+ header_value: request&.env&.dig(CF_CONNECTING_IP_HEADER),
109
+ available: cloudflare_available,
110
+ matches: cloudflare_available && cloudflare_ip_matches?(ip, request)
111
+ },
112
+ {
113
+ provider: CloudfrontProvider,
114
+ name: 'CloudFront',
115
+ header_name: 'CloudFront-Viewer-Address',
116
+ header_value: request&.env&.dig(CLOUDFRONT_VIEWER_ADDRESS_HEADER),
117
+ available: cloudfront_available,
118
+ matches: cloudfront_available && cloudfront_ip_matches?(ip, request)
119
+ }
120
+ ]
121
+ end
122
+
123
+ def warn_first_unverified(candidates, ip)
124
+ candidate = candidates.find { |item| item[:available] }
125
+ warn_unverified_candidate(candidate, ip) if candidate
126
+ nil
127
+ end
128
+
129
+ def warn_unverified_candidate(candidate, ip)
130
+ warn_ip_mismatch(
131
+ provider: candidate[:name],
132
+ ip: ip,
133
+ header_name: candidate[:header_name],
134
+ header_value: candidate[:header_value]
135
+ )
136
+ end
137
+
138
+ # Check if the IP we want to geolocate matches what Cloudflare saw as the client
139
+ # If they don't match, there's an upstream proxy and Cloudflare's geo headers are wrong
140
+ def cloudflare_ip_matches?(ip, request)
141
+ return false unless request
142
+
143
+ cf_connecting_ip = request.env[CF_CONNECTING_IP_HEADER]
144
+ return false unless cf_connecting_ip.is_a?(String)
145
+ return false if cf_connecting_ip.empty?
146
+
147
+ # Normalize IPs for comparison (handle IPv6 formatting differences)
148
+ normalized_ip = normalize_ip(ip)
149
+ normalized_cf_ip = normalize_ip(cf_connecting_ip)
150
+ !normalized_ip.nil? && normalized_ip == normalized_cf_ip
151
+ end
152
+
153
+ # Check if the IP we want to geolocate matches what CloudFront saw as the client.
154
+ # CloudFront-Viewer-Address is "IP:port" (the port trails the last colon, which
155
+ # also works for IPv6 since the address itself contains colons).
156
+ def cloudfront_ip_matches?(ip, request)
157
+ return false unless request
158
+
159
+ normalized_ip = normalize_ip(ip)
160
+ cloudfront_ip = cloudfront_viewer_ip(request)
161
+ !normalized_ip.nil? && normalized_ip == cloudfront_ip
162
+ end
163
+
164
+ def cloudflare_auto_available?(request)
165
+ CloudflareProvider.available?(request: request) &&
166
+ !normalize_ip(request.env[CF_CONNECTING_IP_HEADER]).nil?
167
+ end
168
+
169
+ def cloudfront_auto_available?(request)
170
+ CloudfrontProvider.available?(request: request) && !cloudfront_viewer_ip(request).nil?
171
+ end
172
+
173
+ # AWS specifies CloudFront-Viewer-Address as the viewer IP followed by its
174
+ # source port. Split on the final colon for AWS's unbracketed IPv6 form, while
175
+ # also accepting RFC 3986 bracketed IP literals defensively.
176
+ # AWS: https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/adding-cloudfront-headers.html#cloudfront-headers-viewer-location
177
+ # RFC 3986: https://www.rfc-editor.org/rfc/rfc3986#section-3.2.2
178
+ # Port range/reserved zero: https://www.rfc-editor.org/rfc/rfc6335#section-6
179
+ def cloudfront_viewer_ip(request)
180
+ return nil unless request
181
+
182
+ viewer_address = request.env[CLOUDFRONT_VIEWER_ADDRESS_HEADER]
183
+ return nil unless viewer_address.is_a?(String)
184
+
185
+ value = viewer_address.strip
186
+ return nil if value.empty?
187
+
188
+ match = value.match(/\A\[(.+)\]:(\d+)\z/)
189
+ if match
190
+ ip = match[1]
191
+ port = match[2]
192
+ else
193
+ ip, separator, port = value.rpartition(':')
194
+ return nil if separator.empty?
195
+ end
196
+
197
+ return nil unless valid_source_port?(port)
198
+
199
+ normalize_ip(ip)
200
+ end
201
+
202
+ def valid_source_port?(port)
203
+ return false unless /\A\d{1,5}\z/.match?(port)
204
+
205
+ (1..65_535).cover?(Integer(port, 10))
206
+ rescue ArgumentError
207
+ false
208
+ end
209
+
210
+ def normalize_ip(ip)
211
+ return nil unless ip.is_a?(String)
212
+
213
+ value = ip.to_s.strip
214
+ return nil if value.empty?
215
+
216
+ parsed_ip = IPAddr.new(value)
217
+ # IPAddr normalizes equivalent IPv6 spellings; #native converts an
218
+ # IPv4-mapped IPv6 address to its native IPv4 representation.
219
+ # https://docs.ruby-lang.org/en/3.3/IPAddr.html
220
+ # https://docs.ruby-lang.org/en/3.3/IPAddr.html#method-i-native
221
+ parsed_ip = parsed_ip.native if parsed_ip.ipv4_mapped?
222
+ parsed_ip.to_s.downcase
223
+ rescue IPAddr::Error
224
+ nil
225
+ end
226
+
227
+ def warn_ip_mismatch(provider:, ip:, header_name:, header_value:)
228
+ return if @warned_ip_mismatch
229
+
230
+ warn_mutex.synchronize do
231
+ return if @warned_ip_mismatch
232
+
233
+ @warned_ip_mismatch = true
234
+
235
+ reason = if header_value.nil? || (header_value.is_a?(String) && header_value.empty?)
236
+ "#{header_name} is missing"
237
+ elsif !header_value.is_a?(String)
238
+ "#{header_name} is malformed"
239
+ else
240
+ "#{header_name} (#{header_value}) does not match the request IP (#{ip})"
241
+ end
242
+ message = "[Trackdown] Cannot verify #{provider} geolocation because #{reason}. " \
243
+ "Skipping #{provider} and trying the next available provider."
244
+
245
+ if defined?(Rails)
246
+ Rails.logger.info(message)
247
+ else
248
+ warn(message)
249
+ end
250
+ end
251
+ end
252
+
253
+ def warn_ambiguous_edge(ip)
254
+ return if @warned_ambiguous_edge
255
+
256
+ warn_mutex.synchronize do
257
+ return if @warned_ambiguous_edge
258
+
259
+ @warned_ambiguous_edge = true
260
+
261
+ message = "[Trackdown] Both Cloudflare and CloudFront headers match request IP (#{ip}). " \
262
+ 'Because forwarded viewer headers can spoof this combination, :auto cannot ' \
263
+ 'choose safely. Trying MaxMind and otherwise returning Unknown; configure ' \
264
+ 'an explicit provider if this is an intentional stacked-CDN deployment.'
265
+
266
+ if defined?(Rails)
267
+ Rails.logger.warn(message)
268
+ else
269
+ warn(message)
270
+ end
271
+ end
272
+ end
273
+
48
274
  def warn_no_providers
49
275
  # Only warn once per process to avoid log spam
50
- return if @@warned_no_providers
276
+ return if @warned_no_providers
277
+
278
+ warn_mutex.synchronize do
279
+ return if @warned_no_providers
51
280
 
52
- @@warn_mutex.synchronize do
53
- return if @@warned_no_providers
54
- @@warned_no_providers = true
281
+ @warned_no_providers = true
55
282
 
56
283
  message = "[Trackdown] No IP geolocation provider available. Returning 'Unknown' for all lookups. " \
57
- "Configure Cloudflare headers or MaxMind to enable geolocation. " \
58
- "See: https://github.com/rameerez/trackdown"
284
+ 'Configure verified Cloudflare or CloudFront headers, or MaxMind, to enable geolocation. ' \
285
+ 'See: https://github.com/rameerez/trackdown'
59
286
 
60
287
  if defined?(Rails)
61
288
  Rails.logger.warn(message)
@@ -64,6 +291,10 @@ module Trackdown
64
291
  end
65
292
  end
66
293
  end
294
+
295
+ def warn_mutex
296
+ @warn_mutex ||= Mutex.new
297
+ end
67
298
  end
68
299
  end
69
300
  end