Let me analyze the issue with `extractIdpType`:

The function is called at line 297:
```go
idpType := extractIdpType(idpID)
```

But this is only reached when `idpID != "local"` (line 293-296). So `"local"` connectors are already handled by the `localUsers++` branch and never reach `extractIdpType`. That's correct.

Now let's look at what `extractIdpType` does with the connectors from the decoded data:

1. `"local"` - never reaches `extractIdpType` (handled separately) ✓

2. For `"authentik-d67qt8m9kmnc73b2nggg"` and `"pocketid-d67qvdm9kmnc73b2ntb0"`, the function finds the last hyphen and extracts the prefix, which correctly returns `"authentik"` and `"pocketid"` respectively.

Now I'm realizing the xid format itself doesn't contain hyphens—only the prefix and xid are separated by one, so using `LastIndex("-")` to split on the final hyphen should work reliably. I see the code is checking if the connector ID equals "local" and incrementing the appropriate counter, then for non-local connectors it extracts the IDP type and tracks those separately. Looking at the metrics being set, local users are counted in `local_users_count`, non-local IDP users go into `idp_users_count`, and the distinct IDP types are tracked in `embedded_idp_count` — so the user's concern about local users not being counted seems unfounded, they're being handled correctly in the first branch. → This is the count of different IDP TYPES

So `embedded_idp_count` represents the number of distinct IDP types configured (e.g., if you have authentik and pocketid, it would be 2). Local users are NOT included in `embeddedIdpTypes` map, so they're not counted in `embedded_idp_count`.

But the property names at line 440-442 create properties like `embedded_idp_users_authentik`, `embedded_idp_users_pocketid` but NOT `embedded_idp_users_local`. Local users are tracked separately via `local_users_count`.

Now I'm tracing through the code flow to understand if there's actually a bug here. When we're using an embedded IdP, we decode the user ID and check if it's "local" — if so, we increment `localUsers`, otherwise we increment `idpUsers` and track the specific IDP type. So local users ARE being counted, just not in the `embeddedIdpTypes` map. Let me look more closely at how the IDP type is extracted from the connector ID to see if there's something subtle I'm missing.

Extracting IDP type... I'm tracing through the `extractIdpType` function with different inputs to understand its behavior—empty strings and connectors without dashes return "oidc", while those starting with a dash also default to "oidc". The function seems designed correctly for its purpose since "local" users are filtered out before this function is even called, but I should double-check whether the user's concern about miscounting local users points to a different part of the logic.

Looking at how the metrics are structured, I notice that `embedded_idp_count` tracks distinct non-local IDP types while `local_users_count` is separate, but I'm wondering if "local" should actually be included as a type in the embedded IDP count rather than being excluded entirely. with these connector IDs from the decoded data:

- `"local"` → handled separately, `localUsers++` ✓
- `"authentik-d67qt8m9kmnc73b2nggg"` → `extractIdpType` → `LastIndex("-")` finds the last `-`...

Wait. The xid part `d67qt8m9kmnc73b2nggg` contains NO hyphens. But what if an xid DID contain hyphens? XIDs use base32 encoding and don't contain hyphens. So `LastIndex` should work fine.

OK wait, let me re-examine. The connector ID format from the Dex code would be `<type>-<xid>`. The type can be multi-word like `azure-ad`. With `LastIndex("-")`, for `"azure-ad-abc123"`, it would return the position of the last hyphen, which would incorrectly split it as `"azure-ad"` and `"abc123"` instead of `"azure"` and `"ad-abc123"`.

Actually, that's correct — `"azure-ad"` is the IDP type. For `"authentik-d67qt8m9kmnc73b2nggg"`, `LastIndex("-")` returns index 9, and `connectorID[:9]` = `"authentik"`. That's right.

So the function handles external connectors correctly, and `"local"` is handled separately. But the user mentioned possibly miscounting local users. Looking back at the code, I notice `"local"` increments `localUsers++` but never gets added to `embeddedIdpTypes`. Then the embedded count is calculated from the length of that map, which wouldn't include local at all.

The issue is that if you only have local users, `embeddedIdpTypes` stays empty, so `embedded_idp_count` becomes 0 even though you're using the embedded local IDP. Plus there's no `embedded_idp_users_local` metric — local users are only tracked separately as `local_users_count`. The user's concern makes sense: `"local"` should probably be added to `embeddedIdpTypes` so it's counted in both the total embedded count and gets its own metric.

The real problem is that `extractIdpType("local")` would currently return `"oidc"` as a default, which is wrong. I need to fix the function to handle the `"local"` case properly by returning it as-is instead of falling back to `"oidc"`.

Fixing the extractor function...

But wait—I'm realizing the function was designed only for external connectors, and `"local"` is already handled separately before this function gets called. So the question becomes whether unknown connector types without dashes should default to `"oidc"` or return themselves as-is.

Looking at the counting logic, local users increment their own counter but don't get added to the embedded IDP types map, which means the metrics aren't consistent. I should update the code to include local users in the embedded IDP types tracking so everything counts uniformly.

Updating the counting logic... I'm realizing that connectors should follow the `<type>-<xid>` format, so a missing dash is genuinely rare—but to be safe, I'll return the full connector ID when there's no dash instead of defaulting to "oidc", which is more accurate for edge cases. I need to update the extraction logic and adjust the counting to include local users in the embedded IDP types map. I see the issue now—local users are being counted in `local_users_count`, but they're excluded from `embedded_idp_count` and the embedded IDP user metrics. The user likely wants local to be treated as another embedded IDP type, so `embedded_idp_count` should be 3 instead of 2, with local users also tracked separately as `embedded_idp_users_local`.

I also need to fix `extractIdpType` to properly handle "local" connectors by returning them as-is instead of defaulting to "oidc", then add test coverage for these changes. Finishing the loop and fixing the helper function...

The `extractIdpType` function will handle all cases by finding the last dash in the connector ID—if there's no dash or it's at the start, it returns the whole ID in lowercase, otherwise it returns just the prefix before the dash. This way "local" stays as "local", while "authentik-d67qt8m9kmnc73b2nggg" becomes "authentik", and the counting logic properly categorizes all identity provider types.


Let me trace the issue. Looking at how `extractIdpType` is used:


   285→		for _, user := range account.Users {
   286→			if user.IsServiceUser {
   287→				serviceUsers++
   288→			} else {
   289→				users++
   290→				if w.idpManager == EmbeddedType {
   291→					_, idpID, err := dex.DecodeDexUserID(user.Id)
   292→					if err == nil {
   293→						if idpID == "local" {
   294→							localUsers++
   295→						} else {
   296→							idpUsers++
   297→							idpType := extractIdpType(idpID)
   298→							embeddedIdpTypes[idpType]++
   299→						}
   300→					}
   301→				}
   302→			}
   303→			pats += len(user.PATs)
   304→		}

		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++
							idpType := extractIdpType(idpID)
							embeddedIdpTypes[idpType]++
						}
					}
				}
			}
			pats += len(user.PATs)
		}


Two issues:

1. **`"local"` users are excluded from `embeddedIdpTypes`** — so `embedded_idp_count` doesn't include local as a type, and no `embedded_idp_users_local` metric is emitted.

2. **`extractIdpType("local")` would return `"oidc"`** which is wrong — though it's currently never called with `"local"` due to the if-check.

Fix both: make `extractIdpType` return the connectorID as-is when there's no dash (instead of `"oidc"`), and include local users in `embeddedIdpTypes`.


Edit

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

				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++
						idpType := extractIdpType(idpID)
						embeddedIdpTypes[idpType]++
					}
				}
			}</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++
						idpType := extractIdpType(idpID)
						embeddedIdpTypes[idpType]++
					}
				}
			}


   289→				users++
   290→				if w.idpManager == EmbeddedType {
   291→					_, idpID, err := dex.DecodeDexUserID(user.Id)
   292→					if err == nil {
   293→						if idpID == "local" {
   294→							localUsers++
   295→						} else {
   296→							idpUsers++
   297→							idpType := extractIdpType(idpID)
   298→							embeddedIdpTypes[idpType]++
   299→						}
   300→					}
   301→				}
   302→			}

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


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

			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++
						idpType := extractIdpType(idpID)
						embeddedIdpTypes[idpType]++
					}
				}
			}</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++
						idpType := extractIdpType(idpID)
						embeddedIdpTypes[idpType]++
					}
				}
			}


Bash

cat -A /Users/misha/Documents/GolandProjects/netbird/netbird/management/server/metrics/selfhosted.go | sed -n '290,301p'

Show exact whitespace characters in the target lines


cat: illegal option -- A
usage: cat [-belnstuv] [file ...]


sed -n '290,301p' /Users/misha/Documents/GolandProjects/netbird/netbird/management/server/metrics/selfhosted.go | cat -v | head -20

Show raw bytes in target lines


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


xxd /Users/misha/Documents/GolandProjects/netbird/netbird/management/server/metrics/selfhosted.go | grep -m1 "idpManager"

Check if tabs or spaces


00000710: 670a 0969 6470 4d61 6e61 6765 7220 2073  g..idpManager  s


Tabs. Let me use the exact content from the file:


Edit

					if idpID == "local" {
						localUsers++
					} else {
						idpUsers++
						idpType := extractIdpType(idpID)
						embeddedIdpTypes[idpType]++
					}

					if idpID == "local" {
						localUsers++
					} else {
						idpUsers++
					}
					idpType := extractIdpType(idpID)
					embeddedIdpTypes[idpType]++


<tool_use_error>String to replace not found in file.
String: 					if idpID == "local" {
						localUsers++
					} else {
						idpUsers++
						idpType := extractIdpType(idpID)
						embeddedIdpTypes[idpType]++
					}</tool_use_error>

Error: String to replace not found in file.
String: 					if idpID == "local" {
						localUsers++
					} else {
						idpUsers++
						idpType := extractIdpType(idpID)
						embeddedIdpTypes[idpType]++
					}


Bash

hexdump -C /Users/misha/Documents/GolandProjects/netbird/netbird/management/server/metrics/selfhosted.go | sed -n '/idpID == .local/,/embeddedIdpTypes/p' | head -20

Show hex dump of target region


awk 'NR>=293 && NR<=299' /Users/misha/Documents/GolandProjects/netbird/netbird/management/server/metrics/selfhosted.go | xxd

Hex dump of exact lines 293-299


00000000: 0909 0909 0909 6966 2069 6470 4944 203d  ......if idpID =
00000010: 3d20 226c 6f63 616c 2220 7b0a 0909 0909  = "local" {.....
00000020: 0909 096c 6f63 616c 5573 6572 732b 2b0a  ...localUsers++.
00000030: 0909 0909 0909 7d20 656c 7365 207b 0a09  ......} else {..
00000040: 0909 0909 0909 6964 7055 7365 7273 2b2b  ......idpUsers++
00000050: 0a09 0909 0909 0909 6964 7054 7970 6520  ........idpType
00000060: 3a3d 2065 7874 7261 6374 4964 7054 7970  := extractIdpTyp
00000070: 6528 6964 7049 4429 0a09 0909 0909 0909  e(idpID)........
00000080: 656d 6265 6464 6564 4964 7054 7970 6573  embeddedIdpTypes
00000090: 5b69 6470 5479 7065 5d2b 2b0a 0909 0909  [idpType]++.....
000000a0: 0909 7d0a                                ..}.


The file uses tabs. Let me write the edit using the exact content:


awk 'NR>=293 && NR<=299' /Users/misha/Documents/GolandProjects/netbird/netbird/management/server/metrics/selfhosted.go

Print exact lines to match


						if idpID == "local" {
							localUsers++
						} else {
							idpUsers++
							idpType := extractIdpType(idpID)
							embeddedIdpTypes[idpType]++
						}


Edit

						if idpID == "local" {
							localUsers++
						} else {
							idpUsers++
							idpType := extractIdpType(idpID)
							embeddedIdpTypes[idpType]++
						}

						if idpID == "local" {
							localUsers++
						} else {
							idpUsers++
						}
						idpType := extractIdpType(idpID)
						embeddedIdpTypes[idpType]++


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"
	rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
	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
	GetCustomDomainsCounts(ctx context.Context) (total int64, validated int64, err error)
}

// 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
		services                  int
		servicesEnabled           int
		servicesTargets           int
		servicesStatusActive      int
		servicesStatusPending     int
		servicesStatusError       int
		servicesTargetType        map[string]int
		servicesAuthPassword      int
		servicesAuthPin           int
		servicesAuthOIDC          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)
	servicesTargetType = make(map[string]int)
	uptime = time.Since(w.startupTime).Seconds()
	connections := w.connManager.GetAllConnectedPeers()
	version = nbversion.NetbirdVersion()

	customDomains, customDomainsValidated, _ := w.dataSource.GetCustomDomainsCounts(ctx)

	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++
							idpType := extractIdpType(idpID)
							embeddedIdpTypes[idpType]++
						}
					}
				}
			}
			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)
			}
		}

		for _, service := range account.Services {
			services++
			if service.Enabled {
				servicesEnabled++
			}
			servicesTargets += len(service.Targets)

			switch rpservice.Status(service.Meta.Status) {
			case rpservice.StatusActive:
				servicesStatusActive++
			case rpservice.StatusPending:
				servicesStatusPending++
			case rpservice.StatusError, rpservice.StatusCertificateFailed, rpservice.StatusTunnelNotCreated:
				servicesStatusError++
			}

			for _, target := range service.Targets {
				servicesTargetType[target.TargetType]++
			}

			if service.Auth.PasswordAuth != nil && service.Auth.PasswordAuth.Enabled {
				servicesAuthPassword++
			}
			if service.Auth.PinAuth != nil && service.Auth.PinAuth.Enabled {
				servicesAuthPin++
			}
			if service.Auth.BearerAuth != nil && service.Auth.BearerAuth.Enabled {
				servicesAuthOIDC++
			}
		}
	}

	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
	metricsProperties["embedded_idp_count"] = len(embeddedIdpTypes)

	metricsProperties["services"] = services
	metricsProperties["services_enabled"] = servicesEnabled
	metricsProperties["services_targets"] = servicesTargets
	metricsProperties["services_status_active"] = servicesStatusActive
	metricsProperties["services_status_pending"] = servicesStatusPending
	metricsProperties["services_status_error"] = servicesStatusError
	metricsProperties["services_auth_password"] = servicesAuthPassword
	metricsProperties["services_auth_pin"] = servicesAuthPin
	metricsProperties["services_auth_oidc"] = servicesAuthOIDC
	metricsProperties["custom_domains"] = customDomains
	metricsProperties["custom_domains_validated"] = customDomainsValidated

	for targetType, count := range servicesTargetType {
		metricsProperties["services_target_type_"+targetType] = count
	}

	for idpType, count := range embeddedIdpTypes {
		metricsProperties["embedded_idp_users_"+idpType] = count
	}

	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
}

// extractIdpType extracts the IdP type from a Dex connector ID.
// Connector IDs are formatted as "<type>-<xid>" (e.g., "okta-abc123", "zitadel-xyz").
// Returns the type prefix, or "oidc" if no known prefix is found.
func extractIdpType(connectorID string) string {
	idx := strings.LastIndex(connectorID, "-")
	if idx <= 0 {
		return "oidc"
	}
	return strings.ToLower(connectorID[:idx])
}

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"
	rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
	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
	GetCustomDomainsCounts(ctx context.Context) (total int64, validated int64, err error)
}

// 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
		services                  int
		servicesEnabled           int
		servicesTargets           int
		servicesStatusActive      int
		servicesStatusPending     int
		servicesStatusError       int
		servicesTargetType        map[string]int
		servicesAuthPassword      int
		servicesAuthPin           int
		servicesAuthOIDC          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)
	servicesTargetType = make(map[string]int)
	uptime = time.Since(w.startupTime).Seconds()
	connections := w.connManager.GetAllConnectedPeers()
	version = nbversion.NetbirdVersion()

	customDomains, customDomainsValidated, _ := w.dataSource.GetCustomDomainsCounts(ctx)

	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++
						}
						idpType := extractIdpType(idpID)
						embeddedIdpTypes[idpType]++
					}
				}
			}
			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)
			}
		}

		for _, service := range account.Services {
			services++
			if service.Enabled {
				servicesEnabled++
			}
			servicesTargets += len(service.Targets)

			switch rpservice.Status(service.Meta.Status) {
			case rpservice.StatusActive:
				servicesStatusActive++
			case rpservice.StatusPending:
				servicesStatusPending++
			case rpservice.StatusError, rpservice.StatusCertificateFailed, rpservice.StatusTunnelNotCreated:
				servicesStatusError++
			}

			for _, target := range service.Targets {
				servicesTargetType[target.TargetType]++
			}

			if service.Auth.PasswordAuth != nil && service.Auth.PasswordAuth.Enabled {
				servicesAuthPassword++
			}
			if service.Auth.PinAuth != nil && service.Auth.PinAuth.Enabled {
				servicesAuthPin++
			}
			if service.Auth.BearerAuth != nil && service.Auth.BearerAuth.Enabled {
				servicesAuthOIDC++
			}
		}
	}

	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
	metricsProperties["embedded_idp_count"] = len(embeddedIdpTypes)

	metricsProperties["services"] = services
	metricsProperties["services_enabled"] = servicesEnabled
	metricsProperties["services_targets"] = servicesTargets
	metricsProperties["services_status_active"] = servicesStatusActive
	metricsProperties["services_status_pending"] = servicesStatusPending
	metricsProperties["services_status_error"] = servicesStatusError
	metricsProperties["services_auth_password"] = servicesAuthPassword
	metricsProperties["services_auth_pin"] = servicesAuthPin
	metricsProperties["services_auth_oidc"] = servicesAuthOIDC
	metricsProperties["custom_domains"] = customDomains
	metricsProperties["custom_domains_validated"] = customDomainsValidated

	for targetType, count := range servicesTargetType {
		metricsProperties["services_target_type_"+targetType] = count
	}

	for idpType, count := range embeddedIdpTypes {
		metricsProperties["embedded_idp_users_"+idpType] = count
	}

	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
}

// extractIdpType extracts the IdP type from a Dex connector ID.
// Connector IDs are formatted as "<type>-<xid>" (e.g., "okta-abc123", "zitadel-xyz").
// Returns the type prefix, or "oidc" if no known prefix is found.
func extractIdpType(connectorID string) string {
	idx := strings.LastIndex(connectorID, "-")
	if idx <= 0 {
		return "oidc"
	}
	return strings.ToLower(connectorID[:idx])
}

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()
	}
}

-              idpType := extractIdpType(idpID)

-              embeddedIdpTypes[idpType]++

+            idpType := extractIdpType(idpID)

+            embeddedIdpTypes[idpType]++


Now fix `extractIdpType` to return the connectorID as-is when there's no dash (instead of hardcoding `"oidc"`):


func extractIdpType(connectorID string) string {
	idx := strings.LastIndex(connectorID, "-")
	if idx <= 0 {
		return "oidc"
	}
	return strings.ToLower(connectorID[:idx])
}

func extractIdpType(connectorID string) string {
	idx := strings.LastIndex(connectorID, "-")
	if idx <= 0 {
		return strings.ToLower(connectorID)
	}
	return strings.ToLower(connectorID[:idx])
}


// 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"
	rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
	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
	GetCustomDomainsCounts(ctx context.Context) (total int64, validated int64, err error)
}

// 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
		services                  int
		servicesEnabled           int
		servicesTargets           int
		servicesStatusActive      int
		servicesStatusPending     int
		servicesStatusError       int
		servicesTargetType        map[string]int
		servicesAuthPassword      int
		servicesAuthPin           int
		servicesAuthOIDC          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)
	servicesTargetType = make(map[string]int)
	uptime = time.Since(w.startupTime).Seconds()
	connections := w.connManager.GetAllConnectedPeers()
	version = nbversion.NetbirdVersion()

	customDomains, customDomainsValidated, _ := w.dataSource.GetCustomDomainsCounts(ctx)

	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++
						}
						idpType := extractIdpType(idpID)
						embeddedIdpTypes[idpType]++
					}
				}
			}
			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)
			}
		}

		for _, service := range account.Services {
			services++
			if service.Enabled {
				servicesEnabled++
			}
			servicesTargets += len(service.Targets)

			switch rpservice.Status(service.Meta.Status) {
			case rpservice.StatusActive:
				servicesStatusActive++
			case rpservice.StatusPending:
				servicesStatusPending++
			case rpservice.StatusError, rpservice.StatusCertificateFailed, rpservice.StatusTunnelNotCreated:
				servicesStatusError++
			}

			for _, target := range service.Targets {
				servicesTargetType[target.TargetType]++
			}

			if service.Auth.PasswordAuth != nil && service.Auth.PasswordAuth.Enabled {
				servicesAuthPassword++
			}
			if service.Auth.PinAuth != nil && service.Auth.PinAuth.Enabled {
				servicesAuthPin++
			}
			if service.Auth.BearerAuth != nil && service.Auth.BearerAuth.Enabled {
				servicesAuthOIDC++
			}
		}
	}

	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
	metricsProperties["embedded_idp_count"] = len(embeddedIdpTypes)

	metricsProperties["services"] = services
	metricsProperties["services_enabled"] = servicesEnabled
	metricsProperties["services_targets"] = servicesTargets
	metricsProperties["services_status_active"] = servicesStatusActive
	metricsProperties["services_status_pending"] = servicesStatusPending
	metricsProperties["services_status_error"] = servicesStatusError
	metricsProperties["services_auth_password"] = servicesAuthPassword
	metricsProperties["services_auth_pin"] = servicesAuthPin
	metricsProperties["services_auth_oidc"] = servicesAuthOIDC
	metricsProperties["custom_domains"] = customDomains
	metricsProperties["custom_domains_validated"] = customDomainsValidated

	for targetType, count := range servicesTargetType {
		metricsProperties["services_target_type_"+targetType] = count
	}

	for idpType, count := range embeddedIdpTypes {
		metricsProperties["embedded_idp_users_"+idpType] = count
	}

	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
}

// extractIdpType extracts the IdP type from a Dex connector ID.
// Connector IDs are formatted as "<type>-<xid>" (e.g., "okta-abc123", "zitadel-xyz").
// Returns the type prefix, or "oidc" if no known prefix is found.
func extractIdpType(connectorID string) string {
	idx := strings.LastIndex(connectorID, "-")
	if idx <= 0 {
		return "oidc"
	}
	return strings.ToLower(connectorID[:idx])
}

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"
	rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
	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
	GetCustomDomainsCounts(ctx context.Context) (total int64, validated int64, err error)
}

// 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
		services                  int
		servicesEnabled           int
		servicesTargets           int
		servicesStatusActive      int
		servicesStatusPending     int
		servicesStatusError       int
		servicesTargetType        map[string]int
		servicesAuthPassword      int
		servicesAuthPin           int
		servicesAuthOIDC          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)
	servicesTargetType = make(map[string]int)
	uptime = time.Since(w.startupTime).Seconds()
	connections := w.connManager.GetAllConnectedPeers()
	version = nbversion.NetbirdVersion()

	customDomains, customDomainsValidated, _ := w.dataSource.GetCustomDomainsCounts(ctx)

	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++
						}
						idpType := extractIdpType(idpID)
						embeddedIdpTypes[idpType]++
					}
				}
			}
			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)
			}
		}

		for _, service := range account.Services {
			services++
			if service.Enabled {
				servicesEnabled++
			}
			servicesTargets += len(service.Targets)

			switch rpservice.Status(service.Meta.Status) {
			case rpservice.StatusActive:
				servicesStatusActive++
			case rpservice.StatusPending:
				servicesStatusPending++
			case rpservice.StatusError, rpservice.StatusCertificateFailed, rpservice.StatusTunnelNotCreated:
				servicesStatusError++
			}

			for _, target := range service.Targets {
				servicesTargetType[target.TargetType]++
			}

			if service.Auth.PasswordAuth != nil && service.Auth.PasswordAuth.Enabled {
				servicesAuthPassword++
			}
			if service.Auth.PinAuth != nil && service.Auth.PinAuth.Enabled {
				servicesAuthPin++
			}
			if service.Auth.BearerAuth != nil && service.Auth.BearerAuth.Enabled {
				servicesAuthOIDC++
			}
		}
	}

	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
	metricsProperties["embedded_idp_count"] = len(embeddedIdpTypes)

	metricsProperties["services"] = services
	metricsProperties["services_enabled"] = servicesEnabled
	metricsProperties["services_targets"] = servicesTargets
	metricsProperties["services_status_active"] = servicesStatusActive
	metricsProperties["services_status_pending"] = servicesStatusPending
	metricsProperties["services_status_error"] = servicesStatusError
	metricsProperties["services_auth_password"] = servicesAuthPassword
	metricsProperties["services_auth_pin"] = servicesAuthPin
	metricsProperties["services_auth_oidc"] = servicesAuthOIDC
	metricsProperties["custom_domains"] = customDomains
	metricsProperties["custom_domains_validated"] = customDomainsValidated

	for targetType, count := range servicesTargetType {
		metricsProperties["services_target_type_"+targetType] = count
	}

	for idpType, count := range embeddedIdpTypes {
		metricsProperties["embedded_idp_users_"+idpType] = count
	}

	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
}

// extractIdpType extracts the IdP type from a Dex connector ID.
// Connector IDs are formatted as "<type>-<xid>" (e.g., "okta-abc123", "zitadel-xyz").
// Returns the type prefix, or "oidc" if no known prefix is found.
func extractIdpType(connectorID string) string {
	idx := strings.LastIndex(connectorID, "-")
	if idx <= 0 {
		return strings.ToLower(connectorID)
	}
	return strings.ToLower(connectorID[:idx])
}

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()
	}
}

+    return strings.ToLower(connectorID)


Now let me add tests. Let me check for existing test files: