     1→package types
     2→
     3→import (
     4→	"errors"
     5→	"net/url"
     6→)
     7→
     8→// Identity provider validation errors
     9→var (
    10→	ErrIdentityProviderNameRequired      = errors.New("identity provider name is required")
    11→	ErrIdentityProviderTypeRequired      = errors.New("identity provider type is required")
    12→	ErrIdentityProviderTypeUnsupported   = errors.New("unsupported identity provider type")
    13→	ErrIdentityProviderIssuerRequired    = errors.New("identity provider issuer is required")
    14→	ErrIdentityProviderIssuerInvalid     = errors.New("identity provider issuer must be a valid URL")
    15→	ErrIdentityProviderIssuerUnreachable = errors.New("identity provider issuer is unreachable")
    16→	ErrIdentityProviderIssuerMismatch    = errors.New("identity provider issuer does not match the issuer returned by the provider")
    17→	ErrIdentityProviderClientIDRequired  = errors.New("identity provider client ID is required")
    18→)
    19→
    20→// IdentityProviderType is the type of identity provider
    21→type IdentityProviderType string
    22→
    23→const (
    24→	// IdentityProviderTypeOIDC is a generic OIDC identity provider
    25→	IdentityProviderTypeOIDC IdentityProviderType = "oidc"
    26→	// IdentityProviderTypeZitadel is the Zitadel identity provider
    27→	IdentityProviderTypeZitadel IdentityProviderType = "zitadel"
    28→	// IdentityProviderTypeEntra is the Microsoft Entra (Azure AD) identity provider
    29→	IdentityProviderTypeEntra IdentityProviderType = "entra"
    30→	// IdentityProviderTypeGoogle is the Google identity provider
    31→	IdentityProviderTypeGoogle IdentityProviderType = "google"
    32→	// IdentityProviderTypeOkta is the Okta identity provider
    33→	IdentityProviderTypeOkta IdentityProviderType = "okta"
    34→	// IdentityProviderTypePocketID is the PocketID identity provider
    35→	IdentityProviderTypePocketID IdentityProviderType = "pocketid"
    36→	// IdentityProviderTypeMicrosoft is the Microsoft identity provider
    37→	IdentityProviderTypeMicrosoft IdentityProviderType = "microsoft"
    38→	// IdentityProviderTypeAuthentik is the Authentik identity provider
    39→	IdentityProviderTypeAuthentik IdentityProviderType = "authentik"
    40→	// IdentityProviderTypeKeycloak is the Keycloak identity provider
    41→	IdentityProviderTypeKeycloak IdentityProviderType = "keycloak"
    42→)
    43→
    44→// IdentityProvider represents an identity provider configuration
    45→type IdentityProvider struct {
    46→	// ID is the unique identifier of the identity provider
    47→	ID string `gorm:"primaryKey"`
    48→	// AccountID is a reference to Account that this object belongs
    49→	AccountID string `json:"-" gorm:"index"`
    50→	// Type is the type of identity provider
    51→	Type IdentityProviderType
    52→	// Name is a human-readable name for the identity provider
    53→	Name string
    54→	// Issuer is the OIDC issuer URL
    55→	Issuer string
    56→	// ClientID is the OAuth2 client ID
    57→	ClientID string
    58→	// ClientSecret is the OAuth2 client secret
    59→	ClientSecret string
    60→}
    61→
    62→// Copy returns a copy of the IdentityProvider
    63→func (idp *IdentityProvider) Copy() *IdentityProvider {
    64→	return &IdentityProvider{
    65→		ID:           idp.ID,
    66→		AccountID:    idp.AccountID,
    67→		Type:         idp.Type,
    68→		Name:         idp.Name,
    69→		Issuer:       idp.Issuer,
    70→		ClientID:     idp.ClientID,
    71→		ClientSecret: idp.ClientSecret,
    72→	}
    73→}
    74→
    75→// EventMeta returns a map of metadata for activity events
    76→func (idp *IdentityProvider) EventMeta() map[string]any {
    77→	return map[string]any{
    78→		"name":   idp.Name,
    79→		"type":   string(idp.Type),
    80→		"issuer": idp.Issuer,
    81→	}
    82→}
    83→
    84→// Validate validates the identity provider configuration
    85→func (idp *IdentityProvider) Validate() error {
    86→	if idp.Name == "" {
    87→		return ErrIdentityProviderNameRequired
    88→	}
    89→	if idp.Type == "" {
    90→		return ErrIdentityProviderTypeRequired
    91→	}
    92→	if !idp.Type.IsValid() {
    93→		return ErrIdentityProviderTypeUnsupported
    94→	}
    95→	if !idp.Type.HasBuiltInIssuer() && idp.Issuer == "" {
    96→		return ErrIdentityProviderIssuerRequired
    97→	}
    98→	if idp.Issuer != "" {
    99→		parsedURL, err := url.Parse(idp.Issuer)
   100→		if err != nil || parsedURL.Scheme == "" || parsedURL.Host == "" {
   101→			return ErrIdentityProviderIssuerInvalid
   102→		}
   103→	}
   104→	if idp.ClientID == "" {
   105→		return ErrIdentityProviderClientIDRequired
   106→	}
   107→	return nil
   108→}
   109→
   110→// IsValid checks if the given type is a supported identity provider type
   111→func (t IdentityProviderType) IsValid() bool {
   112→	switch t {
   113→	case IdentityProviderTypeOIDC, IdentityProviderTypeZitadel, IdentityProviderTypeEntra,
   114→		IdentityProviderTypeGoogle, IdentityProviderTypeOkta, IdentityProviderTypePocketID,
   115→		IdentityProviderTypeMicrosoft, IdentityProviderTypeAuthentik, IdentityProviderTypeKeycloak:
   116→		return true
   117→	}
   118→	return false
   119→}
   120→
   121→// HasBuiltInIssuer returns true for types that don't require an issuer URL
   122→func (t IdentityProviderType) HasBuiltInIssuer() bool {
   123→	return t == IdentityProviderTypeGoogle || t == IdentityProviderTypeMicrosoft
   124→}
   125→

package types

import (
	"errors"
	"net/url"
)

// Identity provider validation errors
var (
	ErrIdentityProviderNameRequired      = errors.New("identity provider name is required")
	ErrIdentityProviderTypeRequired      = errors.New("identity provider type is required")
	ErrIdentityProviderTypeUnsupported   = errors.New("unsupported identity provider type")
	ErrIdentityProviderIssuerRequired    = errors.New("identity provider issuer is required")
	ErrIdentityProviderIssuerInvalid     = errors.New("identity provider issuer must be a valid URL")
	ErrIdentityProviderIssuerUnreachable = errors.New("identity provider issuer is unreachable")
	ErrIdentityProviderIssuerMismatch    = errors.New("identity provider issuer does not match the issuer returned by the provider")
	ErrIdentityProviderClientIDRequired  = errors.New("identity provider client ID is required")
)

// IdentityProviderType is the type of identity provider
type IdentityProviderType string

const (
	// IdentityProviderTypeOIDC is a generic OIDC identity provider
	IdentityProviderTypeOIDC IdentityProviderType = "oidc"
	// IdentityProviderTypeZitadel is the Zitadel identity provider
	IdentityProviderTypeZitadel IdentityProviderType = "zitadel"
	// IdentityProviderTypeEntra is the Microsoft Entra (Azure AD) identity provider
	IdentityProviderTypeEntra IdentityProviderType = "entra"
	// IdentityProviderTypeGoogle is the Google identity provider
	IdentityProviderTypeGoogle IdentityProviderType = "google"
	// IdentityProviderTypeOkta is the Okta identity provider
	IdentityProviderTypeOkta IdentityProviderType = "okta"
	// IdentityProviderTypePocketID is the PocketID identity provider
	IdentityProviderTypePocketID IdentityProviderType = "pocketid"
	// IdentityProviderTypeMicrosoft is the Microsoft identity provider
	IdentityProviderTypeMicrosoft IdentityProviderType = "microsoft"
	// IdentityProviderTypeAuthentik is the Authentik identity provider
	IdentityProviderTypeAuthentik IdentityProviderType = "authentik"
	// IdentityProviderTypeKeycloak is the Keycloak identity provider
	IdentityProviderTypeKeycloak IdentityProviderType = "keycloak"
)

// IdentityProvider represents an identity provider configuration
type IdentityProvider struct {
	// ID is the unique identifier of the identity provider
	ID string `gorm:"primaryKey"`
	// AccountID is a reference to Account that this object belongs
	AccountID string `json:"-" gorm:"index"`
	// Type is the type of identity provider
	Type IdentityProviderType
	// Name is a human-readable name for the identity provider
	Name string
	// Issuer is the OIDC issuer URL
	Issuer string
	// ClientID is the OAuth2 client ID
	ClientID string
	// ClientSecret is the OAuth2 client secret
	ClientSecret string
}

// Copy returns a copy of the IdentityProvider
func (idp *IdentityProvider) Copy() *IdentityProvider {
	return &IdentityProvider{
		ID:           idp.ID,
		AccountID:    idp.AccountID,
		Type:         idp.Type,
		Name:         idp.Name,
		Issuer:       idp.Issuer,
		ClientID:     idp.ClientID,
		ClientSecret: idp.ClientSecret,
	}
}

// EventMeta returns a map of metadata for activity events
func (idp *IdentityProvider) EventMeta() map[string]any {
	return map[string]any{
		"name":   idp.Name,
		"type":   string(idp.Type),
		"issuer": idp.Issuer,
	}
}

// Validate validates the identity provider configuration
func (idp *IdentityProvider) Validate() error {
	if idp.Name == "" {
		return ErrIdentityProviderNameRequired
	}
	if idp.Type == "" {
		return ErrIdentityProviderTypeRequired
	}
	if !idp.Type.IsValid() {
		return ErrIdentityProviderTypeUnsupported
	}
	if !idp.Type.HasBuiltInIssuer() && idp.Issuer == "" {
		return ErrIdentityProviderIssuerRequired
	}
	if idp.Issuer != "" {
		parsedURL, err := url.Parse(idp.Issuer)
		if err != nil || parsedURL.Scheme == "" || parsedURL.Host == "" {
			return ErrIdentityProviderIssuerInvalid
		}
	}
	if idp.ClientID == "" {
		return ErrIdentityProviderClientIDRequired
	}
	return nil
}

// IsValid checks if the given type is a supported identity provider type
func (t IdentityProviderType) IsValid() bool {
	switch t {
	case IdentityProviderTypeOIDC, IdentityProviderTypeZitadel, IdentityProviderTypeEntra,
		IdentityProviderTypeGoogle, IdentityProviderTypeOkta, IdentityProviderTypePocketID,
		IdentityProviderTypeMicrosoft, IdentityProviderTypeAuthentik, IdentityProviderTypeKeycloak:
		return true
	}
	return false
}

// HasBuiltInIssuer returns true for types that don't require an issuer URL
func (t IdentityProviderType) HasBuiltInIssuer() bool {
	return t == IdentityProviderTypeGoogle || t == IdentityProviderTypeMicrosoft
}


    60→
    61→// Worker metrics collector and pusher
    62→type Worker struct {
    63→	ctx         context.Context
    64→	id          string
    65→	idpManager  string
    66→	dataSource  DataSource
    67→	connManager ConnManager
    68→	startupTime time.Time
    69→	lastRun     time.Time
    70→}
    71→
    72→// NewWorker returns a metrics worker
    73→func NewWorker(ctx context.Context, id string, dataSource DataSource, connManager ConnManager, idpManager string) *Worker {
    74→	currentTime := time.Now()
    75→	return &Worker{
    76→		ctx:         ctx,
    77→		id:          id,
    78→		idpManager:  idpManager,
    79→		dataSource:  dataSource,
    80→		connManager: connManager,
    81→		startupTime: currentTime,
    82→		lastRun:     currentTime,
    83→	}
    84→}

// Worker metrics collector and pusher
type Worker struct {
	ctx         context.Context
	id          string
	idpManager  string
	dataSource  DataSource
	connManager ConnManager
	startupTime time.Time
	lastRun     time.Time
}

// NewWorker returns a metrics worker
func NewWorker(ctx context.Context, id string, dataSource DataSource, connManager ConnManager, idpManager string) *Worker {
	currentTime := time.Now()
	return &Worker{
		ctx:         ctx,
		id:          id,
		idpManager:  idpManager,
		dataSource:  dataSource,
		connManager: connManager,
		startupTime: currentTime,
		lastRun:     currentTime,
	}
}


Now let me see how the existing IdP metrics are reported and what the `DataSource` interface provides — I need to check if I can access account identity providers.


   175→func (w *Worker) generateProperties(ctx context.Context) properties {
   176→	var (
   177→		uptime                    float64
   178→		accounts                  int
   179→		expirationEnabled         int
   180→		users                     int
   181→		serviceUsers              int
   182→		pats                      int
   183→		peers                     int
   184→		peersSSHEnabled           int
   185→		setupKeysUsage            int
   186→		ephemeralPeersSKs         int
   187→		ephemeralPeersSKUsage     int
   188→		activePeersLastDay        int
   189→		activeUserPeersLastDay    int
   190→		osPeers                   map[string]int
   191→		activeUsersLastDay        map[string]struct{}
   192→		userPeers                 int
   193→		rules                     int
   194→		rulesProtocol             map[string]int
   195→		rulesDirection            map[string]int
   196→		rulesWithSrcPostureChecks int
   197→		postureChecks             int
   198→		groups                    int
   199→		routes                    int
   200→		routesWithRGGroups        int
   201→		networks                  int
   202→		networkResources          int
   203→		networkRouters            int
   204→		networkRoutersWithPG      int
   205→		nameservers               int
   206→		uiClient                  int
   207→		version                   string
   208→		peerActiveVersions        []string
   209→		osUIClients               map[string]int
   210→		rosenpassEnabled          int
   211→		localUsers                int
   212→		idpUsers                  int
   213→	)
   214→	start := time.Now()
   215→	metricsProperties := make(properties)
   216→	osPeers = make(map[string]int)
   217→	osUIClients = make(map[string]int)
   218→	rulesProtocol = make(map[string]int)
   219→	rulesDirection = make(map[string]int)
   220→	activeUsersLastDay = make(map[string]struct{})
   221→	uptime = time.Since(w.startupTime).Seconds()
   222→	connections := w.connManager.GetAllConnectedPeers()
   223→	version = nbversion.NetbirdVersion()
   224→

func (w *Worker) generateProperties(ctx context.Context) properties {
	var (
		uptime                    float64
		accounts                  int
		expirationEnabled         int
		users                     int
		serviceUsers              int
		pats                      int
		peers                     int
		peersSSHEnabled           int
		setupKeysUsage            int
		ephemeralPeersSKs         int
		ephemeralPeersSKUsage     int
		activePeersLastDay        int
		activeUserPeersLastDay    int
		osPeers                   map[string]int
		activeUsersLastDay        map[string]struct{}
		userPeers                 int
		rules                     int
		rulesProtocol             map[string]int
		rulesDirection            map[string]int
		rulesWithSrcPostureChecks int
		postureChecks             int
		groups                    int
		routes                    int
		routesWithRGGroups        int
		networks                  int
		networkResources          int
		networkRouters            int
		networkRoutersWithPG      int
		nameservers               int
		uiClient                  int
		version                   string
		peerActiveVersions        []string
		osUIClients               map[string]int
		rosenpassEnabled          int
		localUsers                int
		idpUsers                  int
	)
	start := time.Now()
	metricsProperties := make(properties)
	osPeers = make(map[string]int)
	osUIClients = make(map[string]int)
	rulesProtocol = make(map[string]int)
	rulesDirection = make(map[string]int)
	activeUsersLastDay = make(map[string]struct{})
	uptime = time.Since(w.startupTime).Seconds()
	connections := w.connManager.GetAllConnectedPeers()
	version = nbversion.NetbirdVersion()


No matches found


The `Account` struct doesn't have `IdentityProviders` yet — this is likely a new feature. Let me check the existing approach: the metrics worker already gets the `idpManager` string (e.g. "embedded") and counts `localUsers`/`idpUsers` by decoding Dex user IDs. Let me look at the existing embedded IdP counting logic more closely.


   265→
   266→		postureChecks += len(account.PostureChecks)
   267→
   268→		for _, user := range account.Users {
   269→			if user.IsServiceUser {
   270→				serviceUsers++
   271→			} else {
   272→				users++
   273→				if w.idpManager == EmbeddedType {
   274→					_, idpID, err := dex.DecodeDexUserID(user.Id)
   275→					if err == nil {
   276→						if idpID == "local" {
   277→							localUsers++
   278→						} else {
   279→							idpUsers++
   280→						}
   281→					}
   282→				}
   283→			}
   284→			pats += len(user.PATs)
   285→		}
   286→
   287→		for _, key := range account.SetupKeys {
   288→			setupKeysUsage += key.UsedTimes
   289→			if key.Ephemeral {
   290→				ephemeralPeersSKs++
   291→				ephemeralPeersSKUsage += key.UsedTimes
   292→			}
   293→		}
   294→
   295→		for _, peer := range account.Peers {
   296→			peers++
   297→
   298→			if peer.SSHEnabled || peer.Meta.Flags.ServerSSHAllowed {
   299→				peersSSHEnabled++
   300→			}
   301→
   302→			if peer.Meta.Flags.RosenpassEnabled {
   303→				rosenpassEnabled++
   304→			}
   305→
   306→			if peer.UserID != "" {
   307→				userPeers++
   308→			}
   309→
   310→			osKey := strings.ToLower(fmt.Sprintf("peer_os_%s", peer.Meta.GoOS))
   311→			osCount := osPeers[osKey]
   312→			osPeers[osKey] = osCount + 1
   313→
   314→			if peer.Meta.UIVersion != "" {
   315→				uiClient++
   316→				uiOSKey := strings.ToLower(fmt.Sprintf("ui_client_os_%s", peer.Meta.GoOS))
   317→				osUICount := osUIClients[uiOSKey]
   318→				osUIClients[uiOSKey] = osUICount + 1
   319→			}
   320→
   321→			_, connected := connections[peer.ID]
   322→			if connected || peer.Status.LastSeen.After(w.lastRun) {
   323→				activePeersLastDay++
   324→				if peer.UserID != "" {
   325→					activeUserPeersLastDay++
   326→					activeUsersLastDay[peer.UserID] = struct{}{}
   327→				}
   328→				osActiveKey := osKey + "_active"
   329→				osActiveCount := osPeers[osActiveKey]
   330→				osPeers[osActiveKey] = osActiveCount + 1
   331→				peerActiveVersions = append(peerActiveVersions, peer.Meta.WtVersion)
   332→			}
   333→		}
   334→	}
   335→
   336→	minActivePeerVersion, maxActivePeerVersion := getMinMaxVersion(peerActiveVersions)
   337→	metricsProperties["uptime"] = uptime
   338→	metricsProperties["accounts"] = accounts
   339→	metricsProperties["users"] = users
   340→	metricsProperties["service_users"] = serviceUsers
   341→	metricsProperties["pats"] = pats
   342→	metricsProperties["peers"] = peers
   343→	metricsProperties["peers_ssh_enabled"] = peersSSHEnabled
   344→	metricsProperties["peers_login_expiration_enabled"] = expirationEnabled
   345→	metricsProperties["setup_keys_usage"] = setupKeysUsage
   346→	metricsProperties["ephemeral_peers_setup_keys"] = ephemeralPeersSKs
   347→	metricsProperties["ephemeral_peers_setup_keys_usage"] = ephemeralPeersSKUsage
   348→	metricsProperties["active_peers_last_day"] = activePeersLastDay
   349→	metricsProperties["active_user_peers_last_day"] = activeUserPeersLastDay
   350→	metricsProperties["active_users_last_day"] = len(activeUsersLastDay)
   351→	metricsProperties["user_peers"] = userPeers
   352→	metricsProperties["rules"] = rules
   353→	metricsProperties["rules_with_src_posture_checks"] = rulesWithSrcPostureChecks
   354→	metricsProperties["posture_checks"] = postureChecks
   355→	metricsProperties["groups"] = groups
   356→	metricsProperties["networks"] = networks
   357→	metricsProperties["network_resources"] = networkResources
   358→	metricsProperties["network_routers"] = networkRouters
   359→	metricsProperties["network_routers_with_groups"] = networkRoutersWithPG
   360→	metricsProperties["routes"] = routes
   361→	metricsProperties["routes_with_routing_groups"] = routesWithRGGroups
   362→	metricsProperties["nameservers"] = nameservers
   363→	metricsProperties["version"] = version
   364→	metricsProperties["min_active_peer_version"] = minActivePeerVersion
   365→	metricsProperties["max_active_peer_version"] = maxActivePeerVersion
   366→	metricsProperties["ui_clients"] = uiClient
   367→	metricsProperties["idp_manager"] = w.idpManager
   368→	metricsProperties["store_engine"] = w.dataSource.GetStoreEngine()
   369→	metricsProperties["rosenpass_enabled"] = rosenpassEnabled
   370→	metricsProperties["local_users_count"] = localUsers
   371→	metricsProperties["idp_users_count"] = idpUsers
   372→
   373→	for protocol, count := range rulesProtocol {
   374→		metricsProperties["rules_protocol_"+protocol] = count

		postureChecks += len(account.PostureChecks)

		for _, user := range account.Users {
			if user.IsServiceUser {
				serviceUsers++
			} else {
				users++
				if w.idpManager == EmbeddedType {
					_, idpID, err := dex.DecodeDexUserID(user.Id)
					if err == nil {
						if idpID == "local" {
							localUsers++
						} else {
							idpUsers++
						}
					}
				}
			}
			pats += len(user.PATs)
		}

		for _, key := range account.SetupKeys {
			setupKeysUsage += key.UsedTimes
			if key.Ephemeral {
				ephemeralPeersSKs++
				ephemeralPeersSKUsage += key.UsedTimes
			}
		}

		for _, peer := range account.Peers {
			peers++

			if peer.SSHEnabled || peer.Meta.Flags.ServerSSHAllowed {
				peersSSHEnabled++
			}

			if peer.Meta.Flags.RosenpassEnabled {
				rosenpassEnabled++
			}

			if peer.UserID != "" {
				userPeers++
			}

			osKey := strings.ToLower(fmt.Sprintf("peer_os_%s", peer.Meta.GoOS))
			osCount := osPeers[osKey]
			osPeers[osKey] = osCount + 1

			if peer.Meta.UIVersion != "" {
				uiClient++
				uiOSKey := strings.ToLower(fmt.Sprintf("ui_client_os_%s", peer.Meta.GoOS))
				osUICount := osUIClients[uiOSKey]
				osUIClients[uiOSKey] = osUICount + 1
			}

			_, connected := connections[peer.ID]
			if connected || peer.Status.LastSeen.After(w.lastRun) {
				activePeersLastDay++
				if peer.UserID != "" {
					activeUserPeersLastDay++
					activeUsersLastDay[peer.UserID] = struct{}{}
				}
				osActiveKey := osKey + "_active"
				osActiveCount := osPeers[osActiveKey]
				osPeers[osActiveKey] = osActiveCount + 1
				peerActiveVersions = append(peerActiveVersions, peer.Meta.WtVersion)
			}
		}
	}

	minActivePeerVersion, maxActivePeerVersion := getMinMaxVersion(peerActiveVersions)
	metricsProperties["uptime"] = uptime
	metricsProperties["accounts"] = accounts
	metricsProperties["users"] = users
	metricsProperties["service_users"] = serviceUsers
	metricsProperties["pats"] = pats
	metricsProperties["peers"] = peers
	metricsProperties["peers_ssh_enabled"] = peersSSHEnabled
	metricsProperties["peers_login_expiration_enabled"] = expirationEnabled
	metricsProperties["setup_keys_usage"] = setupKeysUsage
	metricsProperties["ephemeral_peers_setup_keys"] = ephemeralPeersSKs
	metricsProperties["ephemeral_peers_setup_keys_usage"] = ephemeralPeersSKUsage
	metricsProperties["active_peers_last_day"] = activePeersLastDay
	metricsProperties["active_user_peers_last_day"] = activeUserPeersLastDay
	metricsProperties["active_users_last_day"] = len(activeUsersLastDay)
	metricsProperties["user_peers"] = userPeers
	metricsProperties["rules"] = rules
	metricsProperties["rules_with_src_posture_checks"] = rulesWithSrcPostureChecks
	metricsProperties["posture_checks"] = postureChecks
	metricsProperties["groups"] = groups
	metricsProperties["networks"] = networks
	metricsProperties["network_resources"] = networkResources
	metricsProperties["network_routers"] = networkRouters
	metricsProperties["network_routers_with_groups"] = networkRoutersWithPG
	metricsProperties["routes"] = routes
	metricsProperties["routes_with_routing_groups"] = routesWithRGGroups
	metricsProperties["nameservers"] = nameservers
	metricsProperties["version"] = version
	metricsProperties["min_active_peer_version"] = minActivePeerVersion
	metricsProperties["max_active_peer_version"] = maxActivePeerVersion
	metricsProperties["ui_clients"] = uiClient
	metricsProperties["idp_manager"] = w.idpManager
	metricsProperties["store_engine"] = w.dataSource.GetStoreEngine()
	metricsProperties["rosenpass_enabled"] = rosenpassEnabled
	metricsProperties["local_users_count"] = localUsers
	metricsProperties["idp_users_count"] = idpUsers

	for protocol, count := range rulesProtocol {
		metricsProperties["rules_protocol_"+protocol] = count


Now I see the picture. When the embedded IdP is used, the code already decodes user IDs to distinguish `local` vs `idp` users, but it doesn't track **which** external IdP types (oidc, zitadel, entra, google, etc.) are configured. Let me check if IdentityProviders are stored somewhere accessible from accounts or the store.


No files found


Found 8 files
management/server/mock_server/account_mock.go
management/server/account/manager.go
management/server/types/identity_provider.go
management/server/identity_provider_test.go
management/server/identity_provider.go
management/server/types/identity_provider_test.go
management/server/http/handlers/idp/idp_handler_test.go
management/server/http/handlers/idp/idp_handler.go


39-
40-	req, err := http.NewRequestWithContext(ctx, http.MethodGet, wellKnown, nil)
41-	if err != nil {
42:		return fmt.Errorf("%w: %v", types.ErrIdentityProviderIssuerUnreachable, err)
43-	}
44-
45-	resp, err := httpClient.Do(req)
46-	if err != nil {
47:		return fmt.Errorf("%w: %v", types.ErrIdentityProviderIssuerUnreachable, err)
48-	}
49-	defer resp.Body.Close()
50-
51-	body, err := io.ReadAll(resp.Body)
52-	if err != nil {
53:		return fmt.Errorf("%w: unable to read response body: %v", types.ErrIdentityProviderIssuerUnreachable, err)
54-	}
55-
56-	if resp.StatusCode != http.StatusOK {
57:		return fmt.Errorf("%w: %s: %s", types.ErrIdentityProviderIssuerUnreachable, resp.Status, body)
58-	}
59-
60-	var p oidcProviderJSON
61-	if err := json.Unmarshal(body, &p); err != nil {
62:		return fmt.Errorf("%w: failed to decode provider discovery object: %v", types.ErrIdentityProviderIssuerUnreachable, err)
63-	}
64-
65-	if p.Issuer != issuer {
66:		return fmt.Errorf("%w: expected %q got %q", types.ErrIdentityProviderIssuerMismatch, issuer, p.Issuer)
67-	}
68-
69-	return nil
70-}
71-
72:// validateIdentityProviderConfig validates the identity provider configuration including
73-// basic validation and OIDC issuer verification.
74:func validateIdentityProviderConfig(ctx context.Context, idpConfig *types.IdentityProvider) error {
75-	if err := idpConfig.Validate(); err != nil {
76-		return status.Errorf(status.InvalidArgument, "%s", err.Error())
77-	}
--
86-	return nil
87-}
88-
89:// GetIdentityProviders returns all identity providers for an account
90:func (am *DefaultAccountManager) GetIdentityProviders(ctx context.Context, accountID, userID string) ([]*types.IdentityProvider, error) {
91:	ok, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.IdentityProviders, operations.Read)
92-	if err != nil {
93-		return nil, status.NewPermissionValidationError(err)
94-	}
--
99-	embeddedManager, ok := am.idpManager.(*idp.EmbeddedIdPManager)
100-	if !ok {
101-		log.Warn("identity provider management requires embedded IdP")
102:		return []*types.IdentityProvider{}, nil
103-	}
104-
105-	connectors, err := embeddedManager.ListConnectors(ctx)
--
107-		return nil, status.Errorf(status.Internal, "failed to list identity providers: %v", err)
108-	}
109-
110:	result := make([]*types.IdentityProvider, 0, len(connectors))
111-	for _, conn := range connectors {
112:		result = append(result, connectorConfigToIdentityProvider(conn, accountID))
113-	}
114-
115-	return result, nil
116-}
117-
118:// GetIdentityProvider returns a specific identity provider by ID
119:func (am *DefaultAccountManager) GetIdentityProvider(ctx context.Context, accountID, idpID, userID string) (*types.IdentityProvider, error) {
120:	ok, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.IdentityProviders, operations.Read)
121-	if err != nil {
122-		return nil, status.NewPermissionValidationError(err)
123-	}
--
138-		return nil, status.Errorf(status.Internal, "failed to get identity provider: %v", err)
139-	}
140-
141:	return connectorConfigToIdentityProvider(conn, accountID), nil
142-}
143-
144:// CreateIdentityProvider creates a new identity provider
145:func (am *DefaultAccountManager) CreateIdentityProvider(ctx context.Context, accountID, userID string, idpConfig *types.IdentityProvider) (*types.IdentityProvider, error) {
146:	ok, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.IdentityProviders, operations.Create)
147-	if err != nil {
148-		return nil, status.NewPermissionValidationError(err)
149-	}
--
151-		return nil, status.NewPermissionDeniedError()
152-	}
153-
154:	if err := validateIdentityProviderConfig(ctx, idpConfig); err != nil {
155-		return nil, err
156-	}
157-
--
162-
163-	// Generate ID if not provided
164-	if idpConfig.ID == "" {
165:		idpConfig.ID = generateIdentityProviderID(idpConfig.Type)
166-	}
167-	idpConfig.AccountID = accountID
168-
--
173-		return nil, status.Errorf(status.Internal, "failed to create identity provider: %v", err)
174-	}
175-
176:	am.StoreEvent(ctx, userID, idpConfig.ID, accountID, activity.IdentityProviderCreated, idpConfig.EventMeta())
177-
178-	return idpConfig, nil
179-}
180-
181:// UpdateIdentityProvider updates an existing identity provider
182:func (am *DefaultAccountManager) UpdateIdentityProvider(ctx context.Context, accountID, idpID, userID string, idpConfig *types.IdentityProvider) (*types.IdentityProvider, error) {
183:	ok, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.IdentityProviders, operations.Update)
184-	if err != nil {
185-		return nil, status.NewPermissionValidationError(err)
186-	}
--
188-		return nil, status.NewPermissionDeniedError()
189-	}
190-
191:	if err := validateIdentityProviderConfig(ctx, idpConfig); err != nil {
192-		return nil, err
193-	}
194-
--
206-		return nil, status.Errorf(status.Internal, "failed to update identity provider: %v", err)
207-	}
208-
209:	am.StoreEvent(ctx, userID, idpConfig.ID, accountID, activity.IdentityProviderUpdated, idpConfig.EventMeta())
210-
211-	return idpConfig, nil
212-}
213-
214:// DeleteIdentityProvider deletes an identity provider
215:func (am *DefaultAccountManager) DeleteIdentityProvider(ctx context.Context, accountID, idpID, userID string) error {
216:	ok, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.IdentityProviders, operations.Delete)
217-	if err != nil {
218-		return status.NewPermissionValidationError(err)
219-	}
--
234-		}
235-		return status.Errorf(status.Internal, "failed to get identity provider: %v", err)
236-	}
237:	idpConfig := connectorConfigToIdentityProvider(conn, accountID)
238-
239-	if err := embeddedManager.DeleteConnector(ctx, idpID); err != nil {
240-		if errors.Is(err, storage.ErrNotFound) {
--
243-		return status.Errorf(status.Internal, "failed to delete identity provider: %v", err)
244-	}
245-
246:	am.StoreEvent(ctx, userID, idpID, accountID, activity.IdentityProviderDeleted, idpConfig.EventMeta())
247-
248-	return nil
249-}
250-
251:// connectorConfigToIdentityProvider converts a dex.ConnectorConfig to types.IdentityProvider
252:func connectorConfigToIdentityProvider(conn *dex.ConnectorConfig, accountID string) *types.IdentityProvider {
253:	return &types.IdentityProvider{
254-		ID:           conn.ID,
255-		AccountID:    accountID,
256:		Type:         types.IdentityProviderType(conn.Type),
257-		Name:         conn.Name,
258-		Issuer:       conn.Issuer,
259-		ClientID:     conn.ClientID,
--
261-	}
262-}
263-
264:// identityProviderToConnectorConfig converts a types.IdentityProvider to dex.ConnectorConfig
265:func identityProviderToConnectorConfig(idpConfig *types.IdentityProvider) *dex.ConnectorConfig {
266-	return &dex.ConnectorConfig{
267-		ID:           idpConfig.ID,
268-		Name:         idpConfig.Name,
--
273-	}
274-}
275-
276:// generateIdentityProviderID generates a unique ID for an identity provider.
277-// For specific provider types (okta, zitadel, entra, google, pocketid, microsoft),
278-// the ID is prefixed with the type name. Generic OIDC providers get no prefix.
279:func generateIdentityProviderID(idpType types.IdentityProviderType) string {
280-	id := xid.New().String()
281-
282-	switch idpType {
283:	case types.IdentityProviderTypeOkta:
284-		return "okta-" + id
285:	case types.IdentityProviderTypeZitadel:
286-		return "zitadel-" + id
287:	case types.IdentityProviderTypeEntra:
288-		return "entra-" + id
289:	case types.IdentityProviderTypeGoogle:
290-		return "google-" + id
291:	case types.IdentityProviderTypePocketID:
292-		return "pocketid-" + id
293:	case types.IdentityProviderTypeMicrosoft:
294-		return "microsoft-" + id
295:	case types.IdentityProviderTypeAuthentik:
296-		return "authentik-" + id
297:	case types.IdentityProviderTypeKeycloak:
298-		return "keycloak-" + id
299-	default:
300-		// Generic OIDC - no prefix


Now I understand the architecture. The identity providers are stored in Dex (embedded IdP) as connectors, not in the account struct. The metrics worker doesn't have access to `DefaultAccountManager` or `EmbeddedIdPManager` — it only has `DataSource` (store) and `ConnManager`.

The simplest approach: when embedded IdP is enabled, decode user IDs to extract the connector ID prefix (which maps to IdP type), and count users per IdP type. Let me check how `DecodeDexUserID` works.


/Users/misha/Documents/GolandProjects/netbird/netbird/idp/dex/provider.go-487-
idp/dex/provider.go-488-	/ Field 1: user_id (tag = 0x0a = field 1, wire type 2)
/Users/misha/Documents/GolandProjects/netbird/netbird/idp/dex/provider.go-489-	buf = append(buf, 0x0a)
/Users/misha/Documents/GolandProjects/netbird/netbird/idp/dex/provider.go-490-	buf = append(buf, byte(len(userID)))
/Users/misha/Documents/GolandProjects/netbird/netbird/idp/dex/provider.go-491-	buf = append(buf, []byte(userID)...)
/Users/misha/Documents/GolandProjects/netbird/netbird/idp/dex/provider.go-492-
idp/dex/provider.go-493-	/ Field 2: connector_id (tag = 0x12 = field 2, wire type 2)
/Users/misha/Documents/GolandProjects/netbird/netbird/idp/dex/provider.go-494-	buf = append(buf, 0x12)
/Users/misha/Documents/GolandProjects/netbird/netbird/idp/dex/provider.go-495-	buf = append(buf, byte(len(connectorID)))
/Users/misha/Documents/GolandProjects/netbird/netbird/idp/dex/provider.go-496-	buf = append(buf, []byte(connectorID)...)
/Users/misha/Documents/GolandProjects/netbird/netbird/idp/dex/provider.go-497-
/Users/misha/Documents/GolandProjects/netbird/netbird/idp/dex/provider.go-498-	return base64.RawStdEncoding.EncodeToString(buf)
/Users/misha/Documents/GolandProjects/netbird/netbird/idp/dex/provider.go-499-}
/Users/misha/Documents/GolandProjects/netbird/netbird/idp/dex/provider.go-500-
/Users/misha/Documents/GolandProjects/netbird/netbird/idp/dex/provider.go-501-// DecodeDexUserID decodes Dex's base64-encoded user ID back to the raw user ID and connector ID.
idp/dex/provider.go:502:func DecodeDexUserID(encodedID string) (userID, connectorID string, err error) {
/Users/misha/Documents/GolandProjects/netbird/netbird/idp/dex/provider.go-503-	// Try RawStdEncoding first, then StdEncoding (with padding)
idp/dex/provider.go-504-	buf, err := base64.RawStdEncoding.DecodeString(encodedID)
/Users/misha/Documents/GolandProjects/netbird/netbird/idp/dex/provider.go-505-	if err != nil {
/Users/misha/Documents/GolandProjects/netbird/netbird/idp/dex/provider.go-506-		buf, err = base64.StdEncoding.DecodeString(encodedID)
/Users/misha/Documents/GolandProjects/netbird/netbird/idp/dex/provider.go-507-		if err != nil {
idp/dex/provider.go-508-			return "", "", fmt.Errorf("failed to decode base64: %w", err)
/Users/misha/Documents/GolandProjects/netbird/netbird/idp/dex/provider.go-509-		}
/Users/misha/Documents/GolandProjects/netbird/netbird/idp/dex/provider.go-510-	}
/Users/misha/Documents/GolandProjects/netbird/netbird/idp/dex/provider.go-511-
/Users/misha/Documents/GolandProjects/netbird/netbird/idp/dex/provider.go-512-	// Parse protobuf manually
idp/dex/provider.go-513-	i := 0
/Users/misha/Documents/GolandProjects/netbird/netbird/idp/dex/provider.go-514-	for i < len(buf) {
/Users/misha/Documents/GolandProjects/netbird/netbird/idp/dex/provider.go-515-		if i >= len(buf) {
/Users/misha/Documents/GolandProjects/netbird/netbird/idp/dex/provider.go-516-			break
/Users/misha/Documents/GolandProjects/netbird/netbird/idp/dex/provider.go-517-		}


So `DecodeDexUserID` returns the `connectorID` — which is the Dex connector ID like `"local"`, `"okta-abc123"`, `"zitadel-xyz"`, etc. The prefix maps to the IdP type. The existing code already uses this to distinguish `local` vs `idp` users. I can extend it to count by IdP type using the connector ID prefix.


   268→		for _, user := range account.Users {
   269→			if user.IsServiceUser {
   270→				serviceUsers++
   271→			} else {
   272→				users++
   273→				if w.idpManager == EmbeddedType {
   274→					_, idpID, err := dex.DecodeDexUserID(user.Id)
   275→					if err == nil {
   276→						if idpID == "local" {
   277→							localUsers++
   278→						} else {
   279→							idpUsers++
   280→						}
   281→					}
   282→				}
   283→			}
   284→			pats += len(user.PATs)
   285→		}
   286→
   287→		for _, key := range account.SetupKeys {

		for _, user := range account.Users {
			if user.IsServiceUser {
				serviceUsers++
			} else {
				users++
				if w.idpManager == EmbeddedType {
					_, idpID, err := dex.DecodeDexUserID(user.Id)
					if err == nil {
						if idpID == "local" {
							localUsers++
						} else {
							idpUsers++
						}
					}
				}
			}
			pats += len(user.PATs)
		}

		for _, key := range account.SetupKeys {


Now I'll make the changes. The connector ID format is `"<type>-<xid>"` (e.g. `"okta-abc123"`), or `"local"` for the built-in. I'll extract the type prefix and count per IdP type.


Edit

		rosenpassEnabled          int
		localUsers                int
		idpUsers                  int
	)

		rosenpassEnabled          int
		localUsers                int
		idpUsers                  int
		embeddedIdpTypes          map[string]int
	)


The file /Users/misha/Documents/GolandProjects/netbird/netbird/management/server/metrics/selfhosted.go has been updated successfully.

// Package metrics gather anonymous information about the usage of NetBird management
package metrics

import (
	"context"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"os"
	"sort"
	"strings"
	"time"

	"github.com/hashicorp/go-version"
	"github.com/netbirdio/netbird/idp/dex"
	log "github.com/sirupsen/logrus"

	"github.com/netbirdio/netbird/management/server/types"
	nbversion "github.com/netbirdio/netbird/version"
)

const (
	// PayloadEvent identifies an event type
	PayloadEvent = "self-hosted stats"
	// payloadEndpoint metrics defaultEndpoint to send anonymous data
	payloadEndpoint = "https://metrics.netbird.io"
	// defaultPushInterval default interval to push metrics
	defaultPushInterval = 12 * time.Hour
	// requestTimeout http request timeout
	requestTimeout = 45 * time.Second
	EmbeddedType   = "embedded"
)

type getTokenResponse struct {
	PublicAPIToken string `json:"public_api_token"`
}

type pushPayload struct {
	APIKey     string     `json:"api_key"`
	DistinctID string     `json:"distinct_id"`
	Event      string     `json:"event"`
	Properties properties `json:"properties"`
	Timestamp  time.Time  `json:"timestamp"`
}

// properties metrics to push
type properties map[string]interface{}

// DataSource metric data source
type DataSource interface {
	GetAllAccounts(ctx context.Context) []*types.Account
	GetStoreEngine() types.Engine
}

// ConnManager peer connection manager that holds state for current active connections
type ConnManager interface {
	GetAllConnectedPeers() map[string]struct{}
}

// Worker metrics collector and pusher
type Worker struct {
	ctx         context.Context
	id          string
	idpManager  string
	dataSource  DataSource
	connManager ConnManager
	startupTime time.Time
	lastRun     time.Time
}

// NewWorker returns a metrics worker
func NewWorker(ctx context.Context, id string, dataSource DataSource, connManager ConnManager, idpManager string) *Worker {
	currentTime := time.Now()
	return &Worker{
		ctx:         ctx,
		id:          id,
		idpManager:  idpManager,
		dataSource:  dataSource,
		connManager: connManager,
		startupTime: currentTime,
		lastRun:     currentTime,
	}
}

// Run runs the metrics worker
func (w *Worker) Run(ctx context.Context) {
	interval := getMetricsInterval(ctx)

	pushTicker := time.NewTicker(interval)
	for {
		select {
		case <-w.ctx.Done():
			return
		case <-pushTicker.C:
			err := w.sendMetrics(ctx)
			if err != nil {
				log.WithContext(ctx).Error(err)
			}
			w.lastRun = time.Now()
		}
	}
}

func getMetricsInterval(ctx context.Context) time.Duration {
	interval := defaultPushInterval
	if os.Getenv("NETBIRD_METRICS_INTERVAL_IN_SECONDS") != "" {
		newInterval, err := time.ParseDuration(os.Getenv("NETBIRD_METRICS_INTERVAL_IN_SECONDS") + "s")
		if err != nil {
			log.WithContext(ctx).Errorf("unable to parse NETBIRD_METRICS_INTERVAL_IN_SECONDS, using default interval %v. Error: %v", defaultPushInterval, err)
		} else {
			log.WithContext(ctx).Infof("using NETBIRD_METRICS_INTERVAL_IN_SECONDS %s", newInterval)
			interval = newInterval
		}
	}
	return interval
}

func (w *Worker) sendMetrics(ctx context.Context) error {
	apiKey, err := getAPIKey(w.ctx)
	if err != nil {
		return err
	}

	payload := w.generatePayload(ctx, apiKey)

	payloadString, err := buildMetricsPayload(payload)
	if err != nil {
		return err
	}

	httpClient := http.Client{}

	exportJobReq, cancelCTX, err := createPostRequest(w.ctx, payloadEndpoint+"/capture/", payloadString)
	if err != nil {
		return fmt.Errorf("unable to create metrics post request %v", err)
	}
	defer cancelCTX()

	jobResp, err := httpClient.Do(exportJobReq)
	if err != nil {
		return fmt.Errorf("unable to push metrics %v", err)
	}

	defer func() {
		err = jobResp.Body.Close()
		if err != nil {
			log.WithContext(ctx).Errorf("error while closing update metrics response body: %v", err)
		}
	}()

	if jobResp.StatusCode != 200 {
		return fmt.Errorf("unable to push anonymous metrics, got statusCode %d", jobResp.StatusCode)
	}

	log.WithContext(ctx).Infof("sent anonymous metrics, next push will happen in %s. "+
		"You can disable these metrics by running with flag --disable-anonymous-metrics,"+
		" see more information at https://docs.netbird.io/about-netbird/faq#why-and-what-are-the-anonymous-usage-metrics", getMetricsInterval(ctx))

	return nil
}

func (w *Worker) generatePayload(ctx context.Context, apiKey string) pushPayload {
	properties := w.generateProperties(ctx)

	return pushPayload{
		APIKey:     apiKey,
		DistinctID: w.id,
		Event:      PayloadEvent,
		Properties: properties,
		Timestamp:  time.Now(),
	}
}

func (w *Worker) generateProperties(ctx context.Context) properties {
	var (
		uptime                    float64
		accounts                  int
		expirationEnabled         int
		users                     int
		serviceUsers              int
		pats                      int
		peers                     int
		peersSSHEnabled           int
		setupKeysUsage            int
		ephemeralPeersSKs         int
		ephemeralPeersSKUsage     int
		activePeersLastDay        int
		activeUserPeersLastDay    int
		osPeers                   map[string]int
		activeUsersLastDay        map[string]struct{}
		userPeers                 int
		rules                     int
		rulesProtocol             map[string]int
		rulesDirection            map[string]int
		rulesWithSrcPostureChecks int
		postureChecks             int
		groups                    int
		routes                    int
		routesWithRGGroups        int
		networks                  int
		networkResources          int
		networkRouters            int
		networkRoutersWithPG      int
		nameservers               int
		uiClient                  int
		version                   string
		peerActiveVersions        []string
		osUIClients               map[string]int
		rosenpassEnabled          int
		localUsers                int
		idpUsers                  int
	)
	start := time.Now()
	metricsProperties := make(properties)
	osPeers = make(map[string]int)
	osUIClients = make(map[string]int)
	rulesProtocol = make(map[string]int)
	rulesDirection = make(map[string]int)
	activeUsersLastDay = make(map[string]struct{})
	uptime = time.Since(w.startupTime).Seconds()
	connections := w.connManager.GetAllConnectedPeers()
	version = nbversion.NetbirdVersion()

	for _, account := range w.dataSource.GetAllAccounts(ctx) {
		accounts++

		if account.Settings.PeerLoginExpirationEnabled {
			expirationEnabled++
		}

		groups += len(account.Groups)
		networks += len(account.Networks)
		networkResources += len(account.NetworkResources)

		networkRouters += len(account.NetworkRouters)
		for _, router := range account.NetworkRouters {
			if len(router.PeerGroups) > 0 {
				networkRoutersWithPG++
			}
		}

		routes += len(account.Routes)
		for _, route := range account.Routes {
			if len(route.PeerGroups) > 0 {
				routesWithRGGroups++
			}
		}
		nameservers += len(account.NameServerGroups)

		for _, policy := range account.Policies {
			for _, rule := range policy.Rules {
				rules++
				rulesProtocol[string(rule.Protocol)]++
				if rule.Bidirectional {
					rulesDirection["bidirectional"]++
				} else {
					rulesDirection["oneway"]++
				}
			}
			if len(policy.SourcePostureChecks) > 0 {
				rulesWithSrcPostureChecks++
			}
		}

		postureChecks += len(account.PostureChecks)

		for _, user := range account.Users {
			if user.IsServiceUser {
				serviceUsers++
			} else {
				users++
				if w.idpManager == EmbeddedType {
					_, idpID, err := dex.DecodeDexUserID(user.Id)
					if err == nil {
						if idpID == "local" {
							localUsers++
						} else {
							idpUsers++
						}
					}
				}
			}
			pats += len(user.PATs)
		}

		for _, key := range account.SetupKeys {
			setupKeysUsage += key.UsedTimes
			if key.Ephemeral {
				ephemeralPeersSKs++
				ephemeralPeersSKUsage += key.UsedTimes
			}
		}

		for _, peer := range account.Peers {
			peers++

			if peer.SSHEnabled || peer.Meta.Flags.ServerSSHAllowed {
				peersSSHEnabled++
			}

			if peer.Meta.Flags.RosenpassEnabled {
				rosenpassEnabled++
			}

			if peer.UserID != "" {
				userPeers++
			}

			osKey := strings.ToLower(fmt.Sprintf("peer_os_%s", peer.Meta.GoOS))
			osCount := osPeers[osKey]
			osPeers[osKey] = osCount + 1

			if peer.Meta.UIVersion != "" {
				uiClient++
				uiOSKey := strings.ToLower(fmt.Sprintf("ui_client_os_%s", peer.Meta.GoOS))
				osUICount := osUIClients[uiOSKey]
				osUIClients[uiOSKey] = osUICount + 1
			}

			_, connected := connections[peer.ID]
			if connected || peer.Status.LastSeen.After(w.lastRun) {
				activePeersLastDay++
				if peer.UserID != "" {
					activeUserPeersLastDay++
					activeUsersLastDay[peer.UserID] = struct{}{}
				}
				osActiveKey := osKey + "_active"
				osActiveCount := osPeers[osActiveKey]
				osPeers[osActiveKey] = osActiveCount + 1
				peerActiveVersions = append(peerActiveVersions, peer.Meta.WtVersion)
			}
		}
	}

	minActivePeerVersion, maxActivePeerVersion := getMinMaxVersion(peerActiveVersions)
	metricsProperties["uptime"] = uptime
	metricsProperties["accounts"] = accounts
	metricsProperties["users"] = users
	metricsProperties["service_users"] = serviceUsers
	metricsProperties["pats"] = pats
	metricsProperties["peers"] = peers
	metricsProperties["peers_ssh_enabled"] = peersSSHEnabled
	metricsProperties["peers_login_expiration_enabled"] = expirationEnabled
	metricsProperties["setup_keys_usage"] = setupKeysUsage
	metricsProperties["ephemeral_peers_setup_keys"] = ephemeralPeersSKs
	metricsProperties["ephemeral_peers_setup_keys_usage"] = ephemeralPeersSKUsage
	metricsProperties["active_peers_last_day"] = activePeersLastDay
	metricsProperties["active_user_peers_last_day"] = activeUserPeersLastDay
	metricsProperties["active_users_last_day"] = len(activeUsersLastDay)
	metricsProperties["user_peers"] = userPeers
	metricsProperties["rules"] = rules
	metricsProperties["rules_with_src_posture_checks"] = rulesWithSrcPostureChecks
	metricsProperties["posture_checks"] = postureChecks
	metricsProperties["groups"] = groups
	metricsProperties["networks"] = networks
	metricsProperties["network_resources"] = networkResources
	metricsProperties["network_routers"] = networkRouters
	metricsProperties["network_routers_with_groups"] = networkRoutersWithPG
	metricsProperties["routes"] = routes
	metricsProperties["routes_with_routing_groups"] = routesWithRGGroups
	metricsProperties["nameservers"] = nameservers
	metricsProperties["version"] = version
	metricsProperties["min_active_peer_version"] = minActivePeerVersion
	metricsProperties["max_active_peer_version"] = maxActivePeerVersion
	metricsProperties["ui_clients"] = uiClient
	metricsProperties["idp_manager"] = w.idpManager
	metricsProperties["store_engine"] = w.dataSource.GetStoreEngine()
	metricsProperties["rosenpass_enabled"] = rosenpassEnabled
	metricsProperties["local_users_count"] = localUsers
	metricsProperties["idp_users_count"] = idpUsers

	for protocol, count := range rulesProtocol {
		metricsProperties["rules_protocol_"+protocol] = count
	}

	for direction, count := range rulesDirection {
		metricsProperties["rules_direction_"+direction] = count
	}

	for os, count := range osPeers {
		metricsProperties[os] = count
	}

	for os, count := range osUIClients {
		metricsProperties[os] = count
	}

	metricsProperties["metric_generation_time"] = time.Since(start).Milliseconds()

	return metricsProperties
}

func getAPIKey(ctx context.Context) (string, error) {
	ctx, cancel := context.WithTimeout(ctx, requestTimeout)
	defer cancel()

	httpClient := http.Client{}

	req, err := http.NewRequestWithContext(ctx, http.MethodGet, payloadEndpoint+"/GetToken", nil)
	if err != nil {
		return "", fmt.Errorf("unable to create request for metrics public api token %v", err)
	}
	response, err := httpClient.Do(req)
	if err != nil {
		return "", fmt.Errorf("unable to request metrics public api token %v", err)
	}

	defer func() {
		err = response.Body.Close()
		if err != nil {
			log.WithContext(ctx).Errorf("error while closing metrics token response body: %v", err)
		}
	}()

	if response.StatusCode != 200 {
		return "", fmt.Errorf("unable to retrieve metrics token, statusCode %d", response.StatusCode)
	}

	body, err := io.ReadAll(response.Body)
	if err != nil {
		return "", fmt.Errorf("coudln't get metrics token response; %v", err)
	}

	var tokenResponse getTokenResponse

	err = json.Unmarshal(body, &tokenResponse)
	if err != nil {
		return "", fmt.Errorf("coudln't parse metrics public api token; %v", err)
	}

	return tokenResponse.PublicAPIToken, nil
}

func buildMetricsPayload(payload pushPayload) (string, error) {
	str, err := json.Marshal(payload)
	if err != nil {
		return "", fmt.Errorf("unable to marshal metrics payload, got err: %v", err)
	}
	return string(str), nil
}

func createPostRequest(ctx context.Context, endpoint string, payloadStr string) (*http.Request, context.CancelFunc, error) {
	ctx, cancel := context.WithTimeout(ctx, requestTimeout)
	reqURL := endpoint

	payload := strings.NewReader(payloadStr)

	req, err := http.NewRequestWithContext(ctx, "POST", reqURL, payload)
	if err != nil {
		cancel()
		return nil, nil, err
	}
	req.Header.Add("content-type", "application/json")

	return req, cancel, nil
}

func getMinMaxVersion(inputList []string) (string, string) {
	versions := make([]*version.Version, 0)

	for _, raw := range inputList {
		if raw != "" && nbversion.SemverRegexp.MatchString(raw) {
			v, err := version.NewVersion(raw)
			if err == nil {
				versions = append(versions, v)
			}
		}
	}

	targetIndex := 1
	l := len(versions)

	switch l {
	case 0:
		return "", ""
	case targetIndex:
		v := versions[targetIndex-1].String()
		return v, v
	default:
		sort.Sort(version.Collection(versions))
		return versions[targetIndex-1].String(), versions[l-1].String()
	}
}

// Package metrics gather anonymous information about the usage of NetBird management
package metrics

import (
	"context"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"os"
	"sort"
	"strings"
	"time"

	"github.com/hashicorp/go-version"
	"github.com/netbirdio/netbird/idp/dex"
	log "github.com/sirupsen/logrus"

	"github.com/netbirdio/netbird/management/server/types"
	nbversion "github.com/netbirdio/netbird/version"
)

const (
	// PayloadEvent identifies an event type
	PayloadEvent = "self-hosted stats"
	// payloadEndpoint metrics defaultEndpoint to send anonymous data
	payloadEndpoint = "https://metrics.netbird.io"
	// defaultPushInterval default interval to push metrics
	defaultPushInterval = 12 * time.Hour
	// requestTimeout http request timeout
	requestTimeout = 45 * time.Second
	EmbeddedType   = "embedded"
)

type getTokenResponse struct {
	PublicAPIToken string `json:"public_api_token"`
}

type pushPayload struct {
	APIKey     string     `json:"api_key"`
	DistinctID string     `json:"distinct_id"`
	Event      string     `json:"event"`
	Properties properties `json:"properties"`
	Timestamp  time.Time  `json:"timestamp"`
}

// properties metrics to push
type properties map[string]interface{}

// DataSource metric data source
type DataSource interface {
	GetAllAccounts(ctx context.Context) []*types.Account
	GetStoreEngine() types.Engine
}

// ConnManager peer connection manager that holds state for current active connections
type ConnManager interface {
	GetAllConnectedPeers() map[string]struct{}
}

// Worker metrics collector and pusher
type Worker struct {
	ctx         context.Context
	id          string
	idpManager  string
	dataSource  DataSource
	connManager ConnManager
	startupTime time.Time
	lastRun     time.Time
}

// NewWorker returns a metrics worker
func NewWorker(ctx context.Context, id string, dataSource DataSource, connManager ConnManager, idpManager string) *Worker {
	currentTime := time.Now()
	return &Worker{
		ctx:         ctx,
		id:          id,
		idpManager:  idpManager,
		dataSource:  dataSource,
		connManager: connManager,
		startupTime: currentTime,
		lastRun:     currentTime,
	}
}

// Run runs the metrics worker
func (w *Worker) Run(ctx context.Context) {
	interval := getMetricsInterval(ctx)

	pushTicker := time.NewTicker(interval)
	for {
		select {
		case <-w.ctx.Done():
			return
		case <-pushTicker.C:
			err := w.sendMetrics(ctx)
			if err != nil {
				log.WithContext(ctx).Error(err)
			}
			w.lastRun = time.Now()
		}
	}
}

func getMetricsInterval(ctx context.Context) time.Duration {
	interval := defaultPushInterval
	if os.Getenv("NETBIRD_METRICS_INTERVAL_IN_SECONDS") != "" {
		newInterval, err := time.ParseDuration(os.Getenv("NETBIRD_METRICS_INTERVAL_IN_SECONDS") + "s")
		if err != nil {
			log.WithContext(ctx).Errorf("unable to parse NETBIRD_METRICS_INTERVAL_IN_SECONDS, using default interval %v. Error: %v", defaultPushInterval, err)
		} else {
			log.WithContext(ctx).Infof("using NETBIRD_METRICS_INTERVAL_IN_SECONDS %s", newInterval)
			interval = newInterval
		}
	}
	return interval
}

func (w *Worker) sendMetrics(ctx context.Context) error {
	apiKey, err := getAPIKey(w.ctx)
	if err != nil {
		return err
	}

	payload := w.generatePayload(ctx, apiKey)

	payloadString, err := buildMetricsPayload(payload)
	if err != nil {
		return err
	}

	httpClient := http.Client{}

	exportJobReq, cancelCTX, err := createPostRequest(w.ctx, payloadEndpoint+"/capture/", payloadString)
	if err != nil {
		return fmt.Errorf("unable to create metrics post request %v", err)
	}
	defer cancelCTX()

	jobResp, err := httpClient.Do(exportJobReq)
	if err != nil {
		return fmt.Errorf("unable to push metrics %v", err)
	}

	defer func() {
		err = jobResp.Body.Close()
		if err != nil {
			log.WithContext(ctx).Errorf("error while closing update metrics response body: %v", err)
		}
	}()

	if jobResp.StatusCode != 200 {
		return fmt.Errorf("unable to push anonymous metrics, got statusCode %d", jobResp.StatusCode)
	}

	log.WithContext(ctx).Infof("sent anonymous metrics, next push will happen in %s. "+
		"You can disable these metrics by running with flag --disable-anonymous-metrics,"+
		" see more information at https://docs.netbird.io/about-netbird/faq#why-and-what-are-the-anonymous-usage-metrics", getMetricsInterval(ctx))

	return nil
}

func (w *Worker) generatePayload(ctx context.Context, apiKey string) pushPayload {
	properties := w.generateProperties(ctx)

	return pushPayload{
		APIKey:     apiKey,
		DistinctID: w.id,
		Event:      PayloadEvent,
		Properties: properties,
		Timestamp:  time.Now(),
	}
}

func (w *Worker) generateProperties(ctx context.Context) properties {
	var (
		uptime                    float64
		accounts                  int
		expirationEnabled         int
		users                     int
		serviceUsers              int
		pats                      int
		peers                     int
		peersSSHEnabled           int
		setupKeysUsage            int
		ephemeralPeersSKs         int
		ephemeralPeersSKUsage     int
		activePeersLastDay        int
		activeUserPeersLastDay    int
		osPeers                   map[string]int
		activeUsersLastDay        map[string]struct{}
		userPeers                 int
		rules                     int
		rulesProtocol             map[string]int
		rulesDirection            map[string]int
		rulesWithSrcPostureChecks int
		postureChecks             int
		groups                    int
		routes                    int
		routesWithRGGroups        int
		networks                  int
		networkResources          int
		networkRouters            int
		networkRoutersWithPG      int
		nameservers               int
		uiClient                  int
		version                   string
		peerActiveVersions        []string
		osUIClients               map[string]int
		rosenpassEnabled          int
		localUsers                int
		idpUsers                  int
		embeddedIdpTypes          map[string]int
	)
	start := time.Now()
	metricsProperties := make(properties)
	osPeers = make(map[string]int)
	osUIClients = make(map[string]int)
	rulesProtocol = make(map[string]int)
	rulesDirection = make(map[string]int)
	activeUsersLastDay = make(map[string]struct{})
	uptime = time.Since(w.startupTime).Seconds()
	connections := w.connManager.GetAllConnectedPeers()
	version = nbversion.NetbirdVersion()

	for _, account := range w.dataSource.GetAllAccounts(ctx) {
		accounts++

		if account.Settings.PeerLoginExpirationEnabled {
			expirationEnabled++
		}

		groups += len(account.Groups)
		networks += len(account.Networks)
		networkResources += len(account.NetworkResources)

		networkRouters += len(account.NetworkRouters)
		for _, router := range account.NetworkRouters {
			if len(router.PeerGroups) > 0 {
				networkRoutersWithPG++
			}
		}

		routes += len(account.Routes)
		for _, route := range account.Routes {
			if len(route.PeerGroups) > 0 {
				routesWithRGGroups++
			}
		}
		nameservers += len(account.NameServerGroups)

		for _, policy := range account.Policies {
			for _, rule := range policy.Rules {
				rules++
				rulesProtocol[string(rule.Protocol)]++
				if rule.Bidirectional {
					rulesDirection["bidirectional"]++
				} else {
					rulesDirection["oneway"]++
				}
			}
			if len(policy.SourcePostureChecks) > 0 {
				rulesWithSrcPostureChecks++
			}
		}

		postureChecks += len(account.PostureChecks)

		for _, user := range account.Users {
			if user.IsServiceUser {
				serviceUsers++
			} else {
				users++
				if w.idpManager == EmbeddedType {
					_, idpID, err := dex.DecodeDexUserID(user.Id)
					if err == nil {
						if idpID == "local" {
							localUsers++
						} else {
							idpUsers++
						}
					}
				}
			}
			pats += len(user.PATs)
		}

		for _, key := range account.SetupKeys {
			setupKeysUsage += key.UsedTimes
			if key.Ephemeral {
				ephemeralPeersSKs++
				ephemeralPeersSKUsage += key.UsedTimes
			}
		}

		for _, peer := range account.Peers {
			peers++

			if peer.SSHEnabled || peer.Meta.Flags.ServerSSHAllowed {
				peersSSHEnabled++
			}

			if peer.Meta.Flags.RosenpassEnabled {
				rosenpassEnabled++
			}

			if peer.UserID != "" {
				userPeers++
			}

			osKey := strings.ToLower(fmt.Sprintf("peer_os_%s", peer.Meta.GoOS))
			osCount := osPeers[osKey]
			osPeers[osKey] = osCount + 1

			if peer.Meta.UIVersion != "" {
				uiClient++
				uiOSKey := strings.ToLower(fmt.Sprintf("ui_client_os_%s", peer.Meta.GoOS))
				osUICount := osUIClients[uiOSKey]
				osUIClients[uiOSKey] = osUICount + 1
			}

			_, connected := connections[peer.ID]
			if connected || peer.Status.LastSeen.After(w.lastRun) {
				activePeersLastDay++
				if peer.UserID != "" {
					activeUserPeersLastDay++
					activeUsersLastDay[peer.UserID] = struct{}{}
				}
				osActiveKey := osKey + "_active"
				osActiveCount := osPeers[osActiveKey]
				osPeers[osActiveKey] = osActiveCount + 1
				peerActiveVersions = append(peerActiveVersions, peer.Meta.WtVersion)
			}
		}
	}

	minActivePeerVersion, maxActivePeerVersion := getMinMaxVersion(peerActiveVersions)
	metricsProperties["uptime"] = uptime
	metricsProperties["accounts"] = accounts
	metricsProperties["users"] = users
	metricsProperties["service_users"] = serviceUsers
	metricsProperties["pats"] = pats
	metricsProperties["peers"] = peers
	metricsProperties["peers_ssh_enabled"] = peersSSHEnabled
	metricsProperties["peers_login_expiration_enabled"] = expirationEnabled
	metricsProperties["setup_keys_usage"] = setupKeysUsage
	metricsProperties["ephemeral_peers_setup_keys"] = ephemeralPeersSKs
	metricsProperties["ephemeral_peers_setup_keys_usage"] = ephemeralPeersSKUsage
	metricsProperties["active_peers_last_day"] = activePeersLastDay
	metricsProperties["active_user_peers_last_day"] = activeUserPeersLastDay
	metricsProperties["active_users_last_day"] = len(activeUsersLastDay)
	metricsProperties["user_peers"] = userPeers
	metricsProperties["rules"] = rules
	metricsProperties["rules_with_src_posture_checks"] = rulesWithSrcPostureChecks
	metricsProperties["posture_checks"] = postureChecks
	metricsProperties["groups"] = groups
	metricsProperties["networks"] = networks
	metricsProperties["network_resources"] = networkResources
	metricsProperties["network_routers"] = networkRouters
	metricsProperties["network_routers_with_groups"] = networkRoutersWithPG
	metricsProperties["routes"] = routes
	metricsProperties["routes_with_routing_groups"] = routesWithRGGroups
	metricsProperties["nameservers"] = nameservers
	metricsProperties["version"] = version
	metricsProperties["min_active_peer_version"] = minActivePeerVersion
	metricsProperties["max_active_peer_version"] = maxActivePeerVersion
	metricsProperties["ui_clients"] = uiClient
	metricsProperties["idp_manager"] = w.idpManager
	metricsProperties["store_engine"] = w.dataSource.GetStoreEngine()
	metricsProperties["rosenpass_enabled"] = rosenpassEnabled
	metricsProperties["local_users_count"] = localUsers
	metricsProperties["idp_users_count"] = idpUsers

	for protocol, count := range rulesProtocol {
		metricsProperties["rules_protocol_"+protocol] = count
	}

	for direction, count := range rulesDirection {
		metricsProperties["rules_direction_"+direction] = count
	}

	for os, count := range osPeers {
		metricsProperties[os] = count
	}

	for os, count := range osUIClients {
		metricsProperties[os] = count
	}

	metricsProperties["metric_generation_time"] = time.Since(start).Milliseconds()

	return metricsProperties
}

func getAPIKey(ctx context.Context) (string, error) {
	ctx, cancel := context.WithTimeout(ctx, requestTimeout)
	defer cancel()

	httpClient := http.Client{}

	req, err := http.NewRequestWithContext(ctx, http.MethodGet, payloadEndpoint+"/GetToken", nil)
	if err != nil {
		return "", fmt.Errorf("unable to create request for metrics public api token %v", err)
	}
	response, err := httpClient.Do(req)
	if err != nil {
		return "", fmt.Errorf("unable to request metrics public api token %v", err)
	}

	defer func() {
		err = response.Body.Close()
		if err != nil {
			log.WithContext(ctx).Errorf("error while closing metrics token response body: %v", err)
		}
	}()

	if response.StatusCode != 200 {
		return "", fmt.Errorf("unable to retrieve metrics token, statusCode %d", response.StatusCode)
	}

	body, err := io.ReadAll(response.Body)
	if err != nil {
		return "", fmt.Errorf("coudln't get metrics token response; %v", err)
	}

	var tokenResponse getTokenResponse

	err = json.Unmarshal(body, &tokenResponse)
	if err != nil {
		return "", fmt.Errorf("coudln't parse metrics public api token; %v", err)
	}

	return tokenResponse.PublicAPIToken, nil
}

func buildMetricsPayload(payload pushPayload) (string, error) {
	str, err := json.Marshal(payload)
	if err != nil {
		return "", fmt.Errorf("unable to marshal metrics payload, got err: %v", err)
	}
	return string(str), nil
}

func createPostRequest(ctx context.Context, endpoint string, payloadStr string) (*http.Request, context.CancelFunc, error) {
	ctx, cancel := context.WithTimeout(ctx, requestTimeout)
	reqURL := endpoint

	payload := strings.NewReader(payloadStr)

	req, err := http.NewRequestWithContext(ctx, "POST", reqURL, payload)
	if err != nil {
		cancel()
		return nil, nil, err
	}
	req.Header.Add("content-type", "application/json")

	return req, cancel, nil
}

func getMinMaxVersion(inputList []string) (string, string) {
	versions := make([]*version.Version, 0)

	for _, raw := range inputList {
		if raw != "" && nbversion.SemverRegexp.MatchString(raw) {
			v, err := version.NewVersion(raw)
			if err == nil {
				versions = append(versions, v)
			}
		}
	}

	targetIndex := 1
	l := len(versions)

	switch l {
	case 0:
		return "", ""
	case targetIndex:
		v := versions[targetIndex-1].String()
		return v, v
	default:
		sort.Sort(version.Collection(versions))
		return versions[targetIndex-1].String(), versions[l-1].String()
	}
}

+    embeddedIdpTypes          map[string]int


	activeUsersLastDay = make(map[string]struct{})
	uptime = time.Since(w.startupTime).Seconds()

	activeUsersLastDay = make(map[string]struct{})
	embeddedIdpTypes = make(map[string]int)
	uptime = time.Since(w.startupTime).Seconds()


// Package metrics gather anonymous information about the usage of NetBird management
package metrics

import (
	"context"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"os"
	"sort"
	"strings"
	"time"

	"github.com/hashicorp/go-version"
	"github.com/netbirdio/netbird/idp/dex"
	log "github.com/sirupsen/logrus"

	"github.com/netbirdio/netbird/management/server/types"
	nbversion "github.com/netbirdio/netbird/version"
)

const (
	// PayloadEvent identifies an event type
	PayloadEvent = "self-hosted stats"
	// payloadEndpoint metrics defaultEndpoint to send anonymous data
	payloadEndpoint = "https://metrics.netbird.io"
	// defaultPushInterval default interval to push metrics
	defaultPushInterval = 12 * time.Hour
	// requestTimeout http request timeout
	requestTimeout = 45 * time.Second
	EmbeddedType   = "embedded"
)

type getTokenResponse struct {
	PublicAPIToken string `json:"public_api_token"`
}

type pushPayload struct {
	APIKey     string     `json:"api_key"`
	DistinctID string     `json:"distinct_id"`
	Event      string     `json:"event"`
	Properties properties `json:"properties"`
	Timestamp  time.Time  `json:"timestamp"`
}

// properties metrics to push
type properties map[string]interface{}

// DataSource metric data source
type DataSource interface {
	GetAllAccounts(ctx context.Context) []*types.Account
	GetStoreEngine() types.Engine
}

// ConnManager peer connection manager that holds state for current active connections
type ConnManager interface {
	GetAllConnectedPeers() map[string]struct{}
}

// Worker metrics collector and pusher
type Worker struct {
	ctx         context.Context
	id          string
	idpManager  string
	dataSource  DataSource
	connManager ConnManager
	startupTime time.Time
	lastRun     time.Time
}

// NewWorker returns a metrics worker
func NewWorker(ctx context.Context, id string, dataSource DataSource, connManager ConnManager, idpManager string) *Worker {
	currentTime := time.Now()
	return &Worker{
		ctx:         ctx,
		id:          id,
		idpManager:  idpManager,
		dataSource:  dataSource,
		connManager: connManager,
		startupTime: currentTime,
		lastRun:     currentTime,
	}
}

// Run runs the metrics worker
func (w *Worker) Run(ctx context.Context) {
	interval := getMetricsInterval(ctx)

	pushTicker := time.NewTicker(interval)
	for {
		select {
		case <-w.ctx.Done():
			return
		case <-pushTicker.C:
			err := w.sendMetrics(ctx)
			if err != nil {
				log.WithContext(ctx).Error(err)
			}
			w.lastRun = time.Now()
		}
	}
}

func getMetricsInterval(ctx context.Context) time.Duration {
	interval := defaultPushInterval
	if os.Getenv("NETBIRD_METRICS_INTERVAL_IN_SECONDS") != "" {
		newInterval, err := time.ParseDuration(os.Getenv("NETBIRD_METRICS_INTERVAL_IN_SECONDS") + "s")
		if err != nil {
			log.WithContext(ctx).Errorf("unable to parse NETBIRD_METRICS_INTERVAL_IN_SECONDS, using default interval %v. Error: %v", defaultPushInterval, err)
		} else {
			log.WithContext(ctx).Infof("using NETBIRD_METRICS_INTERVAL_IN_SECONDS %s", newInterval)
			interval = newInterval
		}
	}
	return interval
}

func (w *Worker) sendMetrics(ctx context.Context) error {
	apiKey, err := getAPIKey(w.ctx)
	if err != nil {
		return err
	}

	payload := w.generatePayload(ctx, apiKey)

	payloadString, err := buildMetricsPayload(payload)
	if err != nil {
		return err
	}

	httpClient := http.Client{}

	exportJobReq, cancelCTX, err := createPostRequest(w.ctx, payloadEndpoint+"/capture/", payloadString)
	if err != nil {
		return fmt.Errorf("unable to create metrics post request %v", err)
	}
	defer cancelCTX()

	jobResp, err := httpClient.Do(exportJobReq)
	if err != nil {
		return fmt.Errorf("unable to push metrics %v", err)
	}

	defer func() {
		err = jobResp.Body.Close()
		if err != nil {
			log.WithContext(ctx).Errorf("error while closing update metrics response body: %v", err)
		}
	}()

	if jobResp.StatusCode != 200 {
		return fmt.Errorf("unable to push anonymous metrics, got statusCode %d", jobResp.StatusCode)
	}

	log.WithContext(ctx).Infof("sent anonymous metrics, next push will happen in %s. "+
		"You can disable these metrics by running with flag --disable-anonymous-metrics,"+
		" see more information at https://docs.netbird.io/about-netbird/faq#why-and-what-are-the-anonymous-usage-metrics", getMetricsInterval(ctx))

	return nil
}

func (w *Worker) generatePayload(ctx context.Context, apiKey string) pushPayload {
	properties := w.generateProperties(ctx)

	return pushPayload{
		APIKey:     apiKey,
		DistinctID: w.id,
		Event:      PayloadEvent,
		Properties: properties,
		Timestamp:  time.Now(),
	}
}

func (w *Worker) generateProperties(ctx context.Context) properties {
	var (
		uptime                    float64
		accounts                  int
		expirationEnabled         int
		users                     int
		serviceUsers              int
		pats                      int
		peers                     int
		peersSSHEnabled           int
		setupKeysUsage            int
		ephemeralPeersSKs         int
		ephemeralPeersSKUsage     int
		activePeersLastDay        int
		activeUserPeersLastDay    int
		osPeers                   map[string]int
		activeUsersLastDay        map[string]struct{}
		userPeers                 int
		rules                     int
		rulesProtocol             map[string]int
		rulesDirection            map[string]int
		rulesWithSrcPostureChecks int
		postureChecks             int
		groups                    int
		routes                    int
		routesWithRGGroups        int
		networks                  int
		networkResources          int
		networkRouters            int
		networkRoutersWithPG      int
		nameservers               int
		uiClient                  int
		version                   string
		peerActiveVersions        []string
		osUIClients               map[string]int
		rosenpassEnabled          int
		localUsers                int
		idpUsers                  int
		embeddedIdpTypes          map[string]int
	)
	start := time.Now()
	metricsProperties := make(properties)
	osPeers = make(map[string]int)
	osUIClients = make(map[string]int)
	rulesProtocol = make(map[string]int)
	rulesDirection = make(map[string]int)
	activeUsersLastDay = make(map[string]struct{})
	uptime = time.Since(w.startupTime).Seconds()
	connections := w.connManager.GetAllConnectedPeers()
	version = nbversion.NetbirdVersion()

	for _, account := range w.dataSource.GetAllAccounts(ctx) {
		accounts++

		if account.Settings.PeerLoginExpirationEnabled {
			expirationEnabled++
		}

		groups += len(account.Groups)
		networks += len(account.Networks)
		networkResources += len(account.NetworkResources)

		networkRouters += len(account.NetworkRouters)
		for _, router := range account.NetworkRouters {
			if len(router.PeerGroups) > 0 {
				networkRoutersWithPG++
			}
		}

		routes += len(account.Routes)
		for _, route := range account.Routes {
			if len(route.PeerGroups) > 0 {
				routesWithRGGroups++
			}
		}
		nameservers += len(account.NameServerGroups)

		for _, policy := range account.Policies {
			for _, rule := range policy.Rules {
				rules++
				rulesProtocol[string(rule.Protocol)]++
				if rule.Bidirectional {
					rulesDirection["bidirectional"]++
				} else {
					rulesDirection["oneway"]++
				}
			}
			if len(policy.SourcePostureChecks) > 0 {
				rulesWithSrcPostureChecks++
			}
		}

		postureChecks += len(account.PostureChecks)

		for _, user := range account.Users {
			if user.IsServiceUser {
				serviceUsers++
			} else {
				users++
				if w.idpManager == EmbeddedType {
					_, idpID, err := dex.DecodeDexUserID(user.Id)
					if err == nil {
						if idpID == "local" {
							localUsers++
						} else {
							idpUsers++
						}
					}
				}
			}
			pats += len(user.PATs)
		}

		for _, key := range account.SetupKeys {
			setupKeysUsage += key.UsedTimes
			if key.Ephemeral {
				ephemeralPeersSKs++
				ephemeralPeersSKUsage += key.UsedTimes
			}
		}

		for _, peer := range account.Peers {
			peers++

			if peer.SSHEnabled || peer.Meta.Flags.ServerSSHAllowed {
				peersSSHEnabled++
			}

			if peer.Meta.Flags.RosenpassEnabled {
				rosenpassEnabled++
			}

			if peer.UserID != "" {
				userPeers++
			}

			osKey := strings.ToLower(fmt.Sprintf("peer_os_%s", peer.Meta.GoOS))
			osCount := osPeers[osKey]
			osPeers[osKey] = osCount + 1

			if peer.Meta.UIVersion != "" {
				uiClient++
				uiOSKey := strings.ToLower(fmt.Sprintf("ui_client_os_%s", peer.Meta.GoOS))
				osUICount := osUIClients[uiOSKey]
				osUIClients[uiOSKey] = osUICount + 1
			}

			_, connected := connections[peer.ID]
			if connected || peer.Status.LastSeen.After(w.lastRun) {
				activePeersLastDay++
				if peer.UserID != "" {
					activeUserPeersLastDay++
					activeUsersLastDay[peer.UserID] = struct{}{}
				}
				osActiveKey := osKey + "_active"
				osActiveCount := osPeers[osActiveKey]
				osPeers[osActiveKey] = osActiveCount + 1
				peerActiveVersions = append(peerActiveVersions, peer.Meta.WtVersion)
			}
		}
	}

	minActivePeerVersion, maxActivePeerVersion := getMinMaxVersion(peerActiveVersions)
	metricsProperties["uptime"] = uptime
	metricsProperties["accounts"] = accounts
	metricsProperties["users"] = users
	metricsProperties["service_users"] = serviceUsers
	metricsProperties["pats"] = pats
	metricsProperties["peers"] = peers
	metricsProperties["peers_ssh_enabled"] = peersSSHEnabled
	metricsProperties["peers_login_expiration_enabled"] = expirationEnabled
	metricsProperties["setup_keys_usage"] = setupKeysUsage
	metricsProperties["ephemeral_peers_setup_keys"] = ephemeralPeersSKs
	metricsProperties["ephemeral_peers_setup_keys_usage"] = ephemeralPeersSKUsage
	metricsProperties["active_peers_last_day"] = activePeersLastDay
	metricsProperties["active_user_peers_last_day"] = activeUserPeersLastDay
	metricsProperties["active_users_last_day"] = len(activeUsersLastDay)
	metricsProperties["user_peers"] = userPeers
	metricsProperties["rules"] = rules
	metricsProperties["rules_with_src_posture_checks"] = rulesWithSrcPostureChecks
	metricsProperties["posture_checks"] = postureChecks
	metricsProperties["groups"] = groups
	metricsProperties["networks"] = networks
	metricsProperties["network_resources"] = networkResources
	metricsProperties["network_routers"] = networkRouters
	metricsProperties["network_routers_with_groups"] = networkRoutersWithPG
	metricsProperties["routes"] = routes
	metricsProperties["routes_with_routing_groups"] = routesWithRGGroups
	metricsProperties["nameservers"] = nameservers
	metricsProperties["version"] = version
	metricsProperties["min_active_peer_version"] = minActivePeerVersion
	metricsProperties["max_active_peer_version"] = maxActivePeerVersion
	metricsProperties["ui_clients"] = uiClient
	metricsProperties["idp_manager"] = w.idpManager
	metricsProperties["store_engine"] = w.dataSource.GetStoreEngine()
	metricsProperties["rosenpass_enabled"] = rosenpassEnabled
	metricsProperties["local_users_count"] = localUsers
	metricsProperties["idp_users_count"] = idpUsers

	for protocol, count := range rulesProtocol {
		metricsProperties["rules_protocol_"+protocol] = count
	}

	for direction, count := range rulesDirection {
		metricsProperties["rules_direction_"+direction] = count
	}

	for os, count := range osPeers {
		metricsProperties[os] = count
	}

	for os, count := range osUIClients {
		metricsProperties[os] = count
	}

	metricsProperties["metric_generation_time"] = time.Since(start).Milliseconds()

	return metricsProperties
}

func getAPIKey(ctx context.Context) (string, error) {
	ctx, cancel := context.WithTimeout(ctx, requestTimeout)
	defer cancel()

	httpClient := http.Client{}

	req, err := http.NewRequestWithContext(ctx, http.MethodGet, payloadEndpoint+"/GetToken", nil)
	if err != nil {
		return "", fmt.Errorf("unable to create request for metrics public api token %v", err)
	}
	response, err := httpClient.Do(req)
	if err != nil {
		return "", fmt.Errorf("unable to request metrics public api token %v", err)
	}

	defer func() {
		err = response.Body.Close()
		if err != nil {
			log.WithContext(ctx).Errorf("error while closing metrics token response body: %v", err)
		}
	}()

	if response.StatusCode != 200 {
		return "", fmt.Errorf("unable to retrieve metrics token, statusCode %d", response.StatusCode)
	}

	body, err := io.ReadAll(response.Body)
	if err != nil {
		return "", fmt.Errorf("coudln't get metrics token response; %v", err)
	}

	var tokenResponse getTokenResponse

	err = json.Unmarshal(body, &tokenResponse)
	if err != nil {
		return "", fmt.Errorf("coudln't parse metrics public api token; %v", err)
	}

	return tokenResponse.PublicAPIToken, nil
}

func buildMetricsPayload(payload pushPayload) (string, error) {
	str, err := json.Marshal(payload)
	if err != nil {
		return "", fmt.Errorf("unable to marshal metrics payload, got err: %v", err)
	}
	return string(str), nil
}

func createPostRequest(ctx context.Context, endpoint string, payloadStr string) (*http.Request, context.CancelFunc, error) {
	ctx, cancel := context.WithTimeout(ctx, requestTimeout)
	reqURL := endpoint

	payload := strings.NewReader(payloadStr)

	req, err := http.NewRequestWithContext(ctx, "POST", reqURL, payload)
	if err != nil {
		cancel()
		return nil, nil, err
	}
	req.Header.Add("content-type", "application/json")

	return req, cancel, nil
}

func getMinMaxVersion(inputList []string) (string, string) {
	versions := make([]*version.Version, 0)

	for _, raw := range inputList {
		if raw != "" && nbversion.SemverRegexp.MatchString(raw) {
			v, err := version.NewVersion(raw)
			if err == nil {
				versions = append(versions, v)
			}
		}
	}

	targetIndex := 1
	l := len(versions)

	switch l {
	case 0:
		return "", ""
	case targetIndex:
		v := versions[targetIndex-1].String()
		return v, v
	default:
		sort.Sort(version.Collection(versions))
		return versions[targetIndex-1].String(), versions[l-1].String()
	}
}

// Package metrics gather anonymous information about the usage of NetBird management
package metrics

import (
	"context"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"os"
	"sort"
	"strings"
	"time"

	"github.com/hashicorp/go-version"
	"github.com/netbirdio/netbird/idp/dex"
	log "github.com/sirupsen/logrus"

	"github.com/netbirdio/netbird/management/server/types"
	nbversion "github.com/netbirdio/netbird/version"
)

const (
	// PayloadEvent identifies an event type
	PayloadEvent = "self-hosted stats"
	// payloadEndpoint metrics defaultEndpoint to send anonymous data
	payloadEndpoint = "https://metrics.netbird.io"
	// defaultPushInterval default interval to push metrics
	defaultPushInterval = 12 * time.Hour
	// requestTimeout http request timeout
	requestTimeout = 45 * time.Second
	EmbeddedType   = "embedded"
)

type getTokenResponse struct {
	PublicAPIToken string `json:"public_api_token"`
}

type pushPayload struct {
	APIKey     string     `json:"api_key"`
	DistinctID string     `json:"distinct_id"`
	Event      string     `json:"event"`
	Properties properties `json:"properties"`
	Timestamp  time.Time  `json:"timestamp"`
}

// properties metrics to push
type properties map[string]interface{}

// DataSource metric data source
type DataSource interface {
	GetAllAccounts(ctx context.Context) []*types.Account
	GetStoreEngine() types.Engine
}

// ConnManager peer connection manager that holds state for current active connections
type ConnManager interface {
	GetAllConnectedPeers() map[string]struct{}
}

// Worker metrics collector and pusher
type Worker struct {
	ctx         context.Context
	id          string
	idpManager  string
	dataSource  DataSource
	connManager ConnManager
	startupTime time.Time
	lastRun     time.Time
}

// NewWorker returns a metrics worker
func NewWorker(ctx context.Context, id string, dataSource DataSource, connManager ConnManager, idpManager string) *Worker {
	currentTime := time.Now()
	return &Worker{
		ctx:         ctx,
		id:          id,
		idpManager:  idpManager,
		dataSource:  dataSource,
		connManager: connManager,
		startupTime: currentTime,
		lastRun:     currentTime,
	}
}

// Run runs the metrics worker
func (w *Worker) Run(ctx context.Context) {
	interval := getMetricsInterval(ctx)

	pushTicker := time.NewTicker(interval)
	for {
		select {
		case <-w.ctx.Done():
			return
		case <-pushTicker.C:
			err := w.sendMetrics(ctx)
			if err != nil {
				log.WithContext(ctx).Error(err)
			}
			w.lastRun = time.Now()
		}
	}
}

func getMetricsInterval(ctx context.Context) time.Duration {
	interval := defaultPushInterval
	if os.Getenv("NETBIRD_METRICS_INTERVAL_IN_SECONDS") != "" {
		newInterval, err := time.ParseDuration(os.Getenv("NETBIRD_METRICS_INTERVAL_IN_SECONDS") + "s")
		if err != nil {
			log.WithContext(ctx).Errorf("unable to parse NETBIRD_METRICS_INTERVAL_IN_SECONDS, using default interval %v. Error: %v", defaultPushInterval, err)
		} else {
			log.WithContext(ctx).Infof("using NETBIRD_METRICS_INTERVAL_IN_SECONDS %s", newInterval)
			interval = newInterval
		}
	}
	return interval
}

func (w *Worker) sendMetrics(ctx context.Context) error {
	apiKey, err := getAPIKey(w.ctx)
	if err != nil {
		return err
	}

	payload := w.generatePayload(ctx, apiKey)

	payloadString, err := buildMetricsPayload(payload)
	if err != nil {
		return err
	}

	httpClient := http.Client{}

	exportJobReq, cancelCTX, err := createPostRequest(w.ctx, payloadEndpoint+"/capture/", payloadString)
	if err != nil {
		return fmt.Errorf("unable to create metrics post request %v", err)
	}
	defer cancelCTX()

	jobResp, err := httpClient.Do(exportJobReq)
	if err != nil {
		return fmt.Errorf("unable to push metrics %v", err)
	}

	defer func() {
		err = jobResp.Body.Close()
		if err != nil {
			log.WithContext(ctx).Errorf("error while closing update metrics response body: %v", err)
		}
	}()

	if jobResp.StatusCode != 200 {
		return fmt.Errorf("unable to push anonymous metrics, got statusCode %d", jobResp.StatusCode)
	}

	log.WithContext(ctx).Infof("sent anonymous metrics, next push will happen in %s. "+
		"You can disable these metrics by running with flag --disable-anonymous-metrics,"+
		" see more information at https://docs.netbird.io/about-netbird/faq#why-and-what-are-the-anonymous-usage-metrics", getMetricsInterval(ctx))

	return nil
}

func (w *Worker) generatePayload(ctx context.Context, apiKey string) pushPayload {
	properties := w.generateProperties(ctx)

	return pushPayload{
		APIKey:     apiKey,
		DistinctID: w.id,
		Event:      PayloadEvent,
		Properties: properties,
		Timestamp:  time.Now(),
	}
}

func (w *Worker) generateProperties(ctx context.Context) properties {
	var (
		uptime                    float64
		accounts                  int
		expirationEnabled         int
		users                     int
		serviceUsers              int
		pats                      int
		peers                     int
		peersSSHEnabled           int
		setupKeysUsage            int
		ephemeralPeersSKs         int
		ephemeralPeersSKUsage     int
		activePeersLastDay        int
		activeUserPeersLastDay    int
		osPeers                   map[string]int
		activeUsersLastDay        map[string]struct{}
		userPeers                 int
		rules                     int
		rulesProtocol             map[string]int
		rulesDirection            map[string]int
		rulesWithSrcPostureChecks int
		postureChecks             int
		groups                    int
		routes                    int
		routesWithRGGroups        int
		networks                  int
		networkResources          int
		networkRouters            int
		networkRoutersWithPG      int
		nameservers               int
		uiClient                  int
		version                   string
		peerActiveVersions        []string
		osUIClients               map[string]int
		rosenpassEnabled          int
		localUsers                int
		idpUsers                  int
		embeddedIdpTypes          map[string]int
	)
	start := time.Now()
	metricsProperties := make(properties)
	osPeers = make(map[string]int)
	osUIClients = make(map[string]int)
	rulesProtocol = make(map[string]int)
	rulesDirection = make(map[string]int)
	activeUsersLastDay = make(map[string]struct{})
	embeddedIdpTypes = make(map[string]int)
	uptime = time.Since(w.startupTime).Seconds()
	connections := w.connManager.GetAllConnectedPeers()
	version = nbversion.NetbirdVersion()

	for _, account := range w.dataSource.GetAllAccounts(ctx) {
		accounts++

		if account.Settings.PeerLoginExpirationEnabled {
			expirationEnabled++
		}

		groups += len(account.Groups)
		networks += len(account.Networks)
		networkResources += len(account.NetworkResources)

		networkRouters += len(account.NetworkRouters)
		for _, router := range account.NetworkRouters {
			if len(router.PeerGroups) > 0 {
				networkRoutersWithPG++
			}
		}

		routes += len(account.Routes)
		for _, route := range account.Routes {
			if len(route.PeerGroups) > 0 {
				routesWithRGGroups++
			}
		}
		nameservers += len(account.NameServerGroups)

		for _, policy := range account.Policies {
			for _, rule := range policy.Rules {
				rules++
				rulesProtocol[string(rule.Protocol)]++
				if rule.Bidirectional {
					rulesDirection["bidirectional"]++
				} else {
					rulesDirection["oneway"]++
				}
			}
			if len(policy.SourcePostureChecks) > 0 {
				rulesWithSrcPostureChecks++
			}
		}

		postureChecks += len(account.PostureChecks)

		for _, user := range account.Users {
			if user.IsServiceUser {
				serviceUsers++
			} else {
				users++
				if w.idpManager == EmbeddedType {
					_, idpID, err := dex.DecodeDexUserID(user.Id)
					if err == nil {
						if idpID == "local" {
							localUsers++
						} else {
							idpUsers++
						}
					}
				}
			}
			pats += len(user.PATs)
		}

		for _, key := range account.SetupKeys {
			setupKeysUsage += key.UsedTimes
			if key.Ephemeral {
				ephemeralPeersSKs++
				ephemeralPeersSKUsage += key.UsedTimes
			}
		}

		for _, peer := range account.Peers {
			peers++

			if peer.SSHEnabled || peer.Meta.Flags.ServerSSHAllowed {
				peersSSHEnabled++
			}

			if peer.Meta.Flags.RosenpassEnabled {
				rosenpassEnabled++
			}

			if peer.UserID != "" {
				userPeers++
			}

			osKey := strings.ToLower(fmt.Sprintf("peer_os_%s", peer.Meta.GoOS))
			osCount := osPeers[osKey]
			osPeers[osKey] = osCount + 1

			if peer.Meta.UIVersion != "" {
				uiClient++
				uiOSKey := strings.ToLower(fmt.Sprintf("ui_client_os_%s", peer.Meta.GoOS))
				osUICount := osUIClients[uiOSKey]
				osUIClients[uiOSKey] = osUICount + 1
			}

			_, connected := connections[peer.ID]
			if connected || peer.Status.LastSeen.After(w.lastRun) {
				activePeersLastDay++
				if peer.UserID != "" {
					activeUserPeersLastDay++
					activeUsersLastDay[peer.UserID] = struct{}{}
				}
				osActiveKey := osKey + "_active"
				osActiveCount := osPeers[osActiveKey]
				osPeers[osActiveKey] = osActiveCount + 1
				peerActiveVersions = append(peerActiveVersions, peer.Meta.WtVersion)
			}
		}
	}

	minActivePeerVersion, maxActivePeerVersion := getMinMaxVersion(peerActiveVersions)
	metricsProperties["uptime"] = uptime
	metricsProperties["accounts"] = accounts
	metricsProperties["users"] = users
	metricsProperties["service_users"] = serviceUsers
	metricsProperties["pats"] = pats
	metricsProperties["peers"] = peers
	metricsProperties["peers_ssh_enabled"] = peersSSHEnabled
	metricsProperties["peers_login_expiration_enabled"] = expirationEnabled
	metricsProperties["setup_keys_usage"] = setupKeysUsage
	metricsProperties["ephemeral_peers_setup_keys"] = ephemeralPeersSKs
	metricsProperties["ephemeral_peers_setup_keys_usage"] = ephemeralPeersSKUsage
	metricsProperties["active_peers_last_day"] = activePeersLastDay
	metricsProperties["active_user_peers_last_day"] = activeUserPeersLastDay
	metricsProperties["active_users_last_day"] = len(activeUsersLastDay)
	metricsProperties["user_peers"] = userPeers
	metricsProperties["rules"] = rules
	metricsProperties["rules_with_src_posture_checks"] = rulesWithSrcPostureChecks
	metricsProperties["posture_checks"] = postureChecks
	metricsProperties["groups"] = groups
	metricsProperties["networks"] = networks
	metricsProperties["network_resources"] = networkResources
	metricsProperties["network_routers"] = networkRouters
	metricsProperties["network_routers_with_groups"] = networkRoutersWithPG
	metricsProperties["routes"] = routes
	metricsProperties["routes_with_routing_groups"] = routesWithRGGroups
	metricsProperties["nameservers"] = nameservers
	metricsProperties["version"] = version
	metricsProperties["min_active_peer_version"] = minActivePeerVersion
	metricsProperties["max_active_peer_version"] = maxActivePeerVersion
	metricsProperties["ui_clients"] = uiClient
	metricsProperties["idp_manager"] = w.idpManager
	metricsProperties["store_engine"] = w.dataSource.GetStoreEngine()
	metricsProperties["rosenpass_enabled"] = rosenpassEnabled
	metricsProperties["local_users_count"] = localUsers
	metricsProperties["idp_users_count"] = idpUsers

	for protocol, count := range rulesProtocol {
		metricsProperties["rules_protocol_"+protocol] = count
	}

	for direction, count := range rulesDirection {
		metricsProperties["rules_direction_"+direction] = count
	}

	for os, count := range osPeers {
		metricsProperties[os] = count
	}

	for os, count := range osUIClients {
		metricsProperties[os] = count
	}

	metricsProperties["metric_generation_time"] = time.Since(start).Milliseconds()

	return metricsProperties
}

func getAPIKey(ctx context.Context) (string, error) {
	ctx, cancel := context.WithTimeout(ctx, requestTimeout)
	defer cancel()

	httpClient := http.Client{}

	req, err := http.NewRequestWithContext(ctx, http.MethodGet, payloadEndpoint+"/GetToken", nil)
	if err != nil {
		return "", fmt.Errorf("unable to create request for metrics public api token %v", err)
	}
	response, err := httpClient.Do(req)
	if err != nil {
		return "", fmt.Errorf("unable to request metrics public api token %v", err)
	}

	defer func() {
		err = response.Body.Close()
		if err != nil {
			log.WithContext(ctx).Errorf("error while closing metrics token response body: %v", err)
		}
	}()

	if response.StatusCode != 200 {
		return "", fmt.Errorf("unable to retrieve metrics token, statusCode %d", response.StatusCode)
	}

	body, err := io.ReadAll(response.Body)
	if err != nil {
		return "", fmt.Errorf("coudln't get metrics token response; %v", err)
	}

	var tokenResponse getTokenResponse

	err = json.Unmarshal(body, &tokenResponse)
	if err != nil {
		return "", fmt.Errorf("coudln't parse metrics public api token; %v", err)
	}

	return tokenResponse.PublicAPIToken, nil
}

func buildMetricsPayload(payload pushPayload) (string, error) {
	str, err := json.Marshal(payload)
	if err != nil {
		return "", fmt.Errorf("unable to marshal metrics payload, got err: %v", err)
	}
	return string(str), nil
}

func createPostRequest(ctx context.Context, endpoint string, payloadStr string) (*http.Request, context.CancelFunc, error) {
	ctx, cancel := context.WithTimeout(ctx, requestTimeout)
	reqURL := endpoint

	payload := strings.NewReader(payloadStr)

	req, err := http.NewRequestWithContext(ctx, "POST", reqURL, payload)
	if err != nil {
		cancel()
		return nil, nil, err
	}
	req.Header.Add("content-type", "application/json")

	return req, cancel, nil
}

func getMinMaxVersion(inputList []string) (string, string) {
	versions := make([]*version.Version, 0)

	for _, raw := range inputList {
		if raw != "" && nbversion.SemverRegexp.MatchString(raw) {
			v, err := version.NewVersion(raw)
			if err == nil {
				versions = append(versions, v)
			}
		}
	}

	targetIndex := 1
	l := len(versions)

	switch l {
	case 0:
		return "", ""
	case targetIndex:
		v := versions[targetIndex-1].String()
		return v, v
	default:
		sort.Sort(version.Collection(versions))
		return versions[targetIndex-1].String(), versions[l-1].String()
	}
}

+  embeddedIdpTypes = make(map[string]int)


Now update the user counting logic to track IdP types:


			if w.idpManager == EmbeddedType {
				_, idpID, err := dex.DecodeDexUserID(user.Id)
				if err == nil {
					if idpID == "local" {
						localUsers++
					} else {
						idpUsers++
					}
				}
			}

			if w.idpManager == EmbeddedType {
				_, idpID, err := dex.DecodeDexUserID(user.Id)
				if err == nil {
					if idpID == "local" {
						localUsers++
					} else {
						idpUsers++
						idpType := extractIdpType(idpID)
						embeddedIdpTypes[idpType]++
					}
				}
			}


<tool_use_error>String to replace not found in file.
String: 			if w.idpManager == EmbeddedType {
				_, idpID, err := dex.DecodeDexUserID(user.Id)
				if err == nil {
					if idpID == "local" {
						localUsers++
					} else {
						idpUsers++
					}
				}
			}</tool_use_error>

Error: String to replace not found in file.
String: 			if w.idpManager == EmbeddedType {
				_, idpID, err := dex.DecodeDexUserID(user.Id)
				if err == nil {
					if idpID == "local" {
						localUsers++
					} else {
						idpUsers++
					}
				}
			}


   270→		for _, user := range account.Users {
   271→			if user.IsServiceUser {
   272→				serviceUsers++
   273→			} else {
   274→				users++
   275→				if w.idpManager == EmbeddedType {
   276→					_, idpID, err := dex.DecodeDexUserID(user.Id)
   277→					if err == nil {
   278→						if idpID == "local" {
   279→							localUsers++
   280→						} else {
   281→							idpUsers++
   282→						}
   283→					}
   284→				}
   285→			}
   286→			pats += len(user.PATs)
   287→		}

		for _, user := range account.Users {
			if user.IsServiceUser {
				serviceUsers++
			} else {
				users++
				if w.idpManager == EmbeddedType {
					_, idpID, err := dex.DecodeDexUserID(user.Id)
					if err == nil {
						if idpID == "local" {
							localUsers++
						} else {
							idpUsers++
						}
					}
				}
			}
			pats += len(user.PATs)
		}


				if w.idpManager == EmbeddedType {
					_, idpID, err := dex.DecodeDexUserID(user.Id)
					if err == nil {
						if idpID == "local" {
							localUsers++
						} else {
							idpUsers++
						}
					}
				}

				if w.idpManager == EmbeddedType {
					_, idpID, err := dex.DecodeDexUserID(user.Id)
					if err == nil {
						if idpID == "local" {
							localUsers++
						} else {
							idpUsers++
							idpType := extractIdpType(idpID)
							embeddedIdpTypes[idpType]++
						}
					}
				}