diff --git a/httpx/client_info.go b/httpx/client_info.go index 8d6c3a98..14f915d5 100644 --- a/httpx/client_info.go +++ b/httpx/client_info.go @@ -9,6 +9,12 @@ import ( "strings" ) +type GeoLocation struct { + City string + Region string + Country string +} + func GetClientIPAddressesWithoutInternalIPs(ipAddresses []string) (string, error) { var res string @@ -36,3 +42,11 @@ func ClientIP(r *http.Request) string { return r.RemoteAddr } } + +func ClientGeoLocation(r *http.Request) *GeoLocation { + return &GeoLocation{ + City: r.Header.Get("Cf-Ipcity"), + Region: r.Header.Get("Cf-Region-Code"), + Country: r.Header.Get("Cf-Ipcountry"), + } +} diff --git a/httpx/client_info_test.go b/httpx/client_info_test.go index f3682f4d..b7a1dba9 100644 --- a/httpx/client_info_test.go +++ b/httpx/client_info_test.go @@ -58,3 +58,35 @@ func TestClientIP(t *testing.T) { assert.Equal(t, "1.0.0.4", ClientIP(req)) }) } + +func TestClientGeoLocation(t *testing.T) { + req := http.Request{ + Header: http.Header{}, + } + req.Header.Add("cf-ipcity", "Berlin") + req.Header.Add("cf-ipcountry", "Germany") + req.Header.Add("cf-region-code", "BE") + + t.Run("cf-ipcity", func(t *testing.T) { + req := req.Clone(context.Background()) + assert.Equal(t, "Berlin", ClientGeoLocation(req).City) + }) + + t.Run("cf-ipcountry", func(t *testing.T) { + req := req.Clone(context.Background()) + assert.Equal(t, "Germany", ClientGeoLocation(req).Country) + }) + + t.Run("cf-region-code", func(t *testing.T) { + req := req.Clone(context.Background()) + assert.Equal(t, "BE", ClientGeoLocation(req).Region) + }) + + t.Run("empty", func(t *testing.T) { + req := req.Clone(context.Background()) + req.Header.Del("cf-ipcity") + req.Header.Del("cf-ipcountry") + req.Header.Del("cf-region-code") + assert.Equal(t, GeoLocation{}, *ClientGeoLocation(req)) + }) +} diff --git a/otelx/semconv/context.go b/otelx/semconv/context.go index 07c0768b..a67bfd42 100644 --- a/otelx/semconv/context.go +++ b/otelx/semconv/context.go @@ -36,7 +36,14 @@ func AttributesFromContext(ctx context.Context) []attribute.KeyValue { } func Middleware(rw http.ResponseWriter, r *http.Request, next http.HandlerFunc) { - next(rw, r.WithContext(ContextWithAttributes(r.Context(), AttrClientIP(httpx.ClientIP(r))))) + ctx := ContextWithAttributes(r.Context(), + append( + AttrGeoLocation(*httpx.ClientGeoLocation(r)), + AttrClientIP(httpx.ClientIP(r)), + )..., + ) + + next(rw, r.WithContext(ctx)) } func reverse[S ~[]E, E any](s S) { diff --git a/otelx/semconv/context_test.go b/otelx/semconv/context_test.go index e0a7b311..a68882fb 100644 --- a/otelx/semconv/context_test.go +++ b/otelx/semconv/context_test.go @@ -10,6 +10,8 @@ import ( "github.com/gofrs/uuid" "github.com/stretchr/testify/assert" "go.opentelemetry.io/otel/attribute" + + "github.com/ory/x/httpx" ) func TestAttributesFromContext(t *testing.T) { @@ -21,11 +23,19 @@ func TestAttributesFromContext(t *testing.T) { assert.Len(t, AttributesFromContext(ctx), 1) uid1, uid2 := uuid.Must(uuid.NewV4()), uuid.Must(uuid.NewV4()) - ctx = ContextWithAttributes(ctx, AttrIdentityID(uid1), AttrClientIP("127.0.0.1"), AttrIdentityID(uid2)) + location := httpx.GeoLocation{ + City: "Berlin", + Country: "Germany", + Region: "BE", + } + ctx = ContextWithAttributes(ctx, append(AttrGeoLocation(location), AttrIdentityID(uid1), AttrClientIP("127.0.0.1"), AttrIdentityID(uid2))...) attrs := AttributesFromContext(ctx) - assert.Len(t, attrs, 3, "should deduplicate") + assert.Len(t, attrs, 6, "should deduplicate") assert.Equal(t, []attribute.KeyValue{ attribute.String(AttributeKeyNID.String(), nid.String()), + attribute.String(AttributeKeyGeoLocationCity.String(), "Berlin"), + attribute.String(AttributeKeyGeoLocationCountry.String(), "Germany"), + attribute.String(AttributeKeyGeoLocationRegion.String(), "BE"), attribute.String(AttributeKeyClientIP.String(), "127.0.0.1"), attribute.String(AttributeKeyIdentityID.String(), uid2.String()), }, attrs, "last duplicate attribute wins") diff --git a/otelx/semconv/events.go b/otelx/semconv/events.go index cece6fb6..05e5e4b9 100644 --- a/otelx/semconv/events.go +++ b/otelx/semconv/events.go @@ -7,6 +7,8 @@ package semconv import ( "github.com/gofrs/uuid" otelattr "go.opentelemetry.io/otel/attribute" + + "github.com/ory/x/httpx" ) type Event string @@ -22,9 +24,12 @@ func (a AttributeKey) String() string { } const ( - AttributeKeyIdentityID AttributeKey = "IdentityID" - AttributeKeyNID AttributeKey = "ProjectID" - AttributeKeyClientIP AttributeKey = "ClientIP" + AttributeKeyIdentityID AttributeKey = "IdentityID" + AttributeKeyNID AttributeKey = "ProjectID" + AttributeKeyClientIP AttributeKey = "ClientIP" + AttributeKeyGeoLocationCity AttributeKey = "GeoLocationCity" + AttributeKeyGeoLocationRegion AttributeKey = "GeoLocationRegion" + AttributeKeyGeoLocationCountry AttributeKey = "GeoLocationCountry" ) func AttrIdentityID(val uuid.UUID) otelattr.KeyValue { @@ -38,3 +43,19 @@ func AttrNID(val uuid.UUID) otelattr.KeyValue { func AttrClientIP(val string) otelattr.KeyValue { return otelattr.String(AttributeKeyClientIP.String(), val) } + +func AttrGeoLocation(val httpx.GeoLocation) []otelattr.KeyValue { + geoLocationAttributes := make([]otelattr.KeyValue, 0, 3) + + if val.City != "" { + geoLocationAttributes = append(geoLocationAttributes, otelattr.String(AttributeKeyGeoLocationCity.String(), val.City)) + } + if val.Country != "" { + geoLocationAttributes = append(geoLocationAttributes, otelattr.String(AttributeKeyGeoLocationCountry.String(), val.Country)) + } + if val.Region != "" { + geoLocationAttributes = append(geoLocationAttributes, otelattr.String(AttributeKeyGeoLocationRegion.String(), val.Region)) + } + + return geoLocationAttributes +}