Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# Changelog

## 1.6.0

* A new `residential` object has been added to the `anonymizer` object on
`MaxMind::GeoIP2::Model::Insights`. This object contains residential
proxy data for the network, including a confidence score, the provider
name, and the date the network was last seen. This is only available
from the GeoIP2 Insights web service.

## 1.5.1 (2026-01-19)

* Re-release with a fix to the release process. This includes a bump of the
Expand Down
3 changes: 3 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ def network_last_seen
date_string = get('network_last_seen')

if !date_string
@network_last_seen = nil
return nil
end

Expand All @@ -125,6 +126,7 @@ end
- Use `defined?(@variable)` to check if already parsed
- Parse only once and cache in instance variable
- Handle nil cases before parsing
- Memoize nil results too, so repeated calls do not re-run the lookup

#### 5. **Web Service Only vs Database Models**

Expand Down Expand Up @@ -233,6 +235,7 @@ For database models (like AnonymousPlus):
date_string = get('network_last_seen')

if !date_string
@network_last_seen = nil
return nil
end

Expand Down
1 change: 1 addition & 0 deletions lib/maxmind/geoip2/model/anonymous_plus.rb
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ def network_last_seen
date_string = get('network_last_seen')

if !date_string
@network_last_seen = nil
return nil
end

Expand Down
16 changes: 16 additions & 0 deletions lib/maxmind/geoip2/record/anonymizer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

require 'date'
require 'maxmind/geoip2/record/abstract'
require 'maxmind/geoip2/record/anonymizer_feed'

module MaxMind
module GeoIP2
Expand All @@ -11,6 +12,20 @@ module Record
#
# This record is returned by the Insights web service.
class Anonymizer < Abstract
# Residential proxy data for the network. This may be populated even
# when no other anonymizer attributes are set. This property is only
# available from Insights.
#
# @return [MaxMind::GeoIP2::Record::AnonymizerFeed]
attr_reader :residential

# @!visibility private
def initialize(record)
super

@residential = AnonymizerFeed.new(record.nil? ? nil : record['residential'])

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test gap (major): the absent-residential and absent-anonymizer paths are untested. Insights#initialize always calls Anonymizer.new(record['anonymizer']), and most Insights responses carry no residential data (and non-Insights responses have no anonymizer at all), so Anonymizer.new(nil) / AnonymizerFeed.new(nil) are the common paths. This record.nil? ? nil : record['residential'] guard is the only thing preventing a NoMethodError, yet the sole fixture always fully populates residential.

Suggest adding a test with (a) an anonymizer that has only residential (the documented "may be populated even when no other anonymizer attributes are set" case) and (b) an anonymizer-less response, asserting residential is non-nil and its three fields return nil without raising. GeoIP2-php's InsightsTest.php has this exact case as a model.

— Claude, on behalf of Will

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude, on behalf of Greg: Added coverage in commit f9d3fbc (test/test_record.rb): test_insights_no_anonymizer builds a Model::Insights from a raw hash with no anonymizer key at all and asserts every anonymizer/anonymizer.residential getter returns nil with no NoMethodError, and test_insights_anonymizer_without_residential covers an anonymizer present without a nested residential key, asserting residential.confidence/.network_last_seen/.provider_name are all nil.

end
Comment thread
oschwald marked this conversation as resolved.

# A score ranging from 1 to 99 that represents our percent confidence
# that the network is currently part of an actively used VPN service.
# This property is only available from Insights.
Expand Down Expand Up @@ -85,6 +100,7 @@ def network_last_seen
date_string = get('network_last_seen')

if !date_string
@network_last_seen = nil
return nil
end

Expand Down
53 changes: 53 additions & 0 deletions lib/maxmind/geoip2/record/anonymizer_feed.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# frozen_string_literal: true

require 'date'
require 'maxmind/geoip2/record/abstract'

module MaxMind
module GeoIP2
module Record
# Contains data for one type of anonymizer detection, currently
# residential proxies. Additional feeds may be added in the future.
#
# This record is returned by the anonymizer object of the Insights web
# service.
class AnonymizerFeed < Abstract
# A score ranging from 1 to 99 that represents our percent confidence
# that the network is currently part of this anonymizer feed. This
# property is only available from Insights.
#
# @return [Integer, nil]
def confidence
get('confidence')
end

# The last day that the network was sighted in our analysis of this
# anonymizer feed. This value is parsed lazily. This property is only
# available from Insights.
#
# @return [Date, nil] A Date object representing the last seen date,
# or nil if the date is not available.
def network_last_seen
return @network_last_seen if defined?(@network_last_seen)

date_string = get('network_last_seen')

if !date_string

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test gap (major): this nil-date branch is untested — network_last_seen is only exercised with a valid date string. A residential feed with confidence/provider but no date is a normal payload; if this if !date_string guard regressed, Date.parse(nil) would raise. Note commit 39e1d6f ("Memoize nil results…") could be fully reverted and the suite would still pass.

Suggest asserting network_last_seen returns nil when absent (for the feed, top-level Anonymizer, and AnonymousPlus), and that a repeated call still returns nil to pin the memoization.

— Claude, on behalf of Will

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude, on behalf of Greg: Added coverage in commit f9d3fbc (test/test_record.rb): test_anonymizer_feed_network_last_seen_absent, test_anonymizer_network_last_seen_absent, and test_anonymous_plus_network_last_seen_absent each assert network_last_seen is nil (no Date.parse(nil) raise) when the date key is absent, covering Record::AnonymizerFeed, Record::Anonymizer, and Model::AnonymousPlus respectively. Verified the gap by temporarily removing the if !date_string guard in AnonymizerFeed#network_last_seen — 3 of the new tests failed with TypeError: no implicit conversion of nil into String, confirming they would catch a regression of this guard.

@network_last_seen = nil
return nil
end

@network_last_seen = Date.parse(date_string)
end
Comment thread
oschwald marked this conversation as resolved.

# The name of the provider associated with the network in this
# anonymizer feed. This property is only available from Insights.
#
# @return [String, nil]
def provider_name
get('provider_name')
end
end
end
end
end
10 changes: 10 additions & 0 deletions test/test_client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@ class ClientTest < Minitest::Test
'is_tor_exit_node' => false,
'network_last_seen' => '2025-10-15',
'provider_name' => 'nordvpn',
'residential' => {
'confidence' => 82,
'network_last_seen' => '2026-05-11',
'provider_name' => 'quickshift',
},
},
'continent' => {
'code' => 'NA',
Expand Down Expand Up @@ -109,6 +114,11 @@ def test_insights
assert_equal(Date.parse('2025-10-15'), record.anonymizer.network_last_seen)
assert_equal('nordvpn', record.anonymizer.provider_name)

# Test anonymizer.residential object
assert_equal(82, record.anonymizer.residential.confidence)
assert_equal(Date.parse('2026-05-11'), record.anonymizer.residential.network_last_seen)
assert_equal('quickshift', record.anonymizer.residential.provider_name)

# Test traits
assert(record.traits.anycast?)
assert(record.traits.residential_proxy?)
Expand Down
79 changes: 79 additions & 0 deletions test/test_record.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# frozen_string_literal: true

require 'date'
require 'maxmind/geoip2'
require 'minitest/autorun'

class RecordTest < Minitest::Test
# This covers the common case where a web service response has no
# "anonymizer" key at all. Insights#initialize always calls
# Anonymizer.new(record['anonymizer']), so this exercises
# Anonymizer.new(nil) and, in turn, AnonymizerFeed.new(nil).
def test_insights_no_anonymizer
model = MaxMind::GeoIP2::Model::Insights.new({}, ['en'])

assert_nil(model.anonymizer.confidence)
refute(model.anonymizer.anonymous?)
refute(model.anonymizer.anonymous_vpn?)
refute(model.anonymizer.hosting_provider?)
refute(model.anonymizer.public_proxy?)
refute(model.anonymizer.residential_proxy?)
refute(model.anonymizer.tor_exit_node?)
assert_nil(model.anonymizer.network_last_seen)
assert_nil(model.anonymizer.provider_name)

assert_nil(model.anonymizer.residential.confidence)
assert_nil(model.anonymizer.residential.network_last_seen)
assert_nil(model.anonymizer.residential.provider_name)
end

# This covers an "anonymizer" object being present without a nested
# "residential" key, which is the common case when the network is not
# part of the residential proxy feed.
def test_insights_anonymizer_without_residential
model = MaxMind::GeoIP2::Model::Insights.new(
{
'anonymizer' => {
'confidence' => 85,
},
},
['en'],
)

assert_equal(85, model.anonymizer.confidence)

assert_nil(model.anonymizer.residential.confidence)
assert_nil(model.anonymizer.residential.network_last_seen)
assert_nil(model.anonymizer.residential.provider_name)
end

# network_last_seen is parsed lazily and guards against a missing date
# string. This asserts the guard is in place rather than an unconditional
# Date.parse(nil), which would raise.
def test_anonymizer_feed_network_last_seen_absent
feed = MaxMind::GeoIP2::Record::AnonymizerFeed.new(
{ 'confidence' => 82 },
)

assert_nil(feed.network_last_seen)
end

def test_anonymizer_network_last_seen_absent
anonymizer = MaxMind::GeoIP2::Record::Anonymizer.new(
{ 'confidence' => 85 },
)

assert_nil(anonymizer.network_last_seen)
end

def test_anonymous_plus_network_last_seen_absent
model = MaxMind::GeoIP2::Model::AnonymousPlus.new(
{
'ip_address' => '1.2.3.4',
'prefix_length' => 24,
},
)

assert_nil(model.network_last_seen)
end
end