Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
Loading