Module: Sessions::Geolocation

Defined in:
lib/sessions/geolocation.rb

Overview

IP geolocation through the ‘trackdown` gem — a SOFT dependency, never required: every call site is `defined?(::Trackdown)`-guarded and rescue-everything (trackdown raises on private/loopback IPs in development, and a geo hiccup must never block a login write). The integration contract is lifted verbatim from footprinted’s proven one (→ docs/research/02-ecosystem.md §2):

- always pass `request:` through so Cloudflare headers win when present
  (zero config, free, synchronous header read);
- go async only when sync would mean a database lookup (the MaxMind
  mode) — Sessions::GeolocateJob enriches the row after commit;
- skip lookups when country_code is already present.

Without trackdown, geo columns simply stay nil and the devices page omits location cleanly.

Constant Summary collapse

COLUMNS =
%i[country_code country_name city region].freeze

Class Method Summary collapse

Class Method Details

.async_capable?Boolean

Whether an async MaxMind lookup could possibly succeed — used to avoid enqueueing a no-op job per login on hosts without a MaxMind database.

Returns:

  • (Boolean)


71
72
73
74
75
76
77
78
# File 'lib/sessions/geolocation.rb', line 71

def async_capable?
  enabled? &&
    defined?(::ActiveJob) &&
    ::Trackdown.respond_to?(:database_exists?) &&
    ::Trackdown.database_exists?
rescue StandardError
  false
end

.cloudflare_headers?(request) ⇒ Boolean

Whether this request already carries a Cloudflare geo answer — the header read is free, so we resolve synchronously.

Returns:

  • (Boolean)


60
61
62
63
64
65
66
67
# File 'lib/sessions/geolocation.rb', line 60

def cloudflare_headers?(request)
  return false unless request

  country = request.get_header("HTTP_CF_IPCOUNTRY")
  !country.nil? && !country.empty? && country != "XX"
rescue StandardError
  false
end

.columns_from(result) ⇒ Object



80
81
82
83
84
85
86
87
88
89
90
# File 'lib/sessions/geolocation.rb', line 80

def columns_from(result)
  return {} unless result
  return {} if result.country_code.to_s.empty?

  {
    country_code: result.country_code,
    country_name: presence(result.country_name),
    city: presence(result.city),
    region: presence(result.region)
  }.compact
end

.coordinates_from(result) ⇒ Object

Latitude/longitude for EVENT rows only, precision-reduced per config.geo_precision (2 decimals ≈ 1km — privacy now, impossible-travel math later).



95
96
97
98
99
100
101
102
103
104
105
# File 'lib/sessions/geolocation.rb', line 95

def coordinates_from(result)
  return {} unless result.respond_to?(:latitude) && result.latitude

  precision = Sessions.config.geo_precision
  {
    latitude: result.latitude.to_f.round(precision),
    longitude: result.longitude.to_f.round(precision)
  }
rescue StandardError
  {}
end

.enabled?Boolean

Returns:

  • (Boolean)


24
25
26
# File 'lib/sessions/geolocation.rb', line 24

def enabled?
  Sessions.config.geolocate == :auto && defined?(::Trackdown)
end

.enqueue(record) ⇒ Object

Hand a record to the async MaxMind path (no-op without ActiveJob or a trackdown database — see async_capable?).



47
48
49
50
51
52
53
54
55
56
# File 'lib/sessions/geolocation.rb', line 47

def enqueue(record)
  return false unless record&.persisted?
  return false unless async_capable?

  Sessions::GeolocateJob.perform_later(record.class.name, record.id)
  true
rescue StandardError => e
  Sessions.warn("geolocation enqueue failed: #{e.class}: #{e.message}")
  false
end

.locate(ip, request: nil, coordinates: false) ⇒ Object

Synchronous geolocation — called inline at session-creation time ONLY when it’s free (Cloudflare already did the lookup and put the answer in request headers). Returns a column hash or {}. ‘coordinates: true` adds precision-reduced lat/lng (event rows only).



32
33
34
35
36
37
38
39
40
41
42
43
# File 'lib/sessions/geolocation.rb', line 32

def locate(ip, request: nil, coordinates: false)
  return {} unless enabled?
  return {} if ip.to_s.empty?

  result = ::Trackdown.locate(ip.to_s, request: request)
  columns = columns_from(result)
  columns = columns.merge(coordinates_from(result)) if coordinates && columns.any?
  columns
rescue StandardError => e
  Sessions.warn("geolocation failed for #{ip}: #{e.class}: #{e.message}")
  {}
end

.presence(value) ⇒ Object



107
108
109
# File 'lib/sessions/geolocation.rb', line 107

def presence(value)
  value.nil? || value.to_s.empty? || value.to_s == "Unknown" ? nil : value
end