Added trace sampler to use OTEL in prod. (#46595)

<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** Resolves #44652 

Docs: https://github.com/fleetdm/fleet/pull/46631

# Checklist for submitter

If some of the following don't apply, delete the relevant line.

- [x] Changes file added for user-visible changes in `changes/`,
`orbit/changes/` or `ee/fleetd-chrome/changes`.
See [Changes
files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files)
for more information.

- [x] Input data is properly validated, `SELECT *` is avoided, SQL
injection is prevented (using placeholders for values in statements), JS
inline code is prevented especially for url redirects, and untrusted
data interpolated into shell scripts/commands is validated against shell
metacharacters.

## Testing

- [x] Added/updated automated tests
- [x] QA'd all new/changed functionality manually

## Database migrations

- [x] Ensured the correct collation is explicitly set for character
columns (`COLLATE utf8mb4_unicode_ci`).

## New Fleet configuration settings

- [x] Setting(s) is/are explicitly excluded from GitOps

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Route-aware OpenTelemetry trace sampling with tiered default ratios
(very low for select high-volume routes, reduced rate for admin reads,
full sampling otherwise).
* Admin-only GET/PATCH /debug/trace_sampler to view and update sampling
ratios and a runtime "force full" toggle.
* Liveness probe endpoints (/healthz, /version, /metrics) are excluded
from tracing; settings propagate to replicas at runtime without restart.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Victor Lyuboslavsky
2026-06-02 18:53:00 -05:00
committed by GitHub
parent 54871e25d1
commit 59a673bc15
24 changed files with 1529 additions and 29 deletions
+2
View File
@@ -0,0 +1,2 @@
- Added route-aware head sampling for OpenTelemetry trace export. When `tracing_enabled` is on, agent firehose endpoints (osquery distributed read/write, orbit ping/config, device desktop/ping) are sampled at 0.1% by default, admin reads at 2%, and everything else (enroll, SCEP, MDM checkin, cron jobs, GitOps batch) at 100%. Liveness probes (`/healthz`, `/version`, `/metrics`) are dropped unconditionally.
- Added `GET`/`PATCH /debug/trace_sampler` (admin only, behind the existing `/debug` auth) for adjusting ratios or flipping a 100% `force_full` debug window at runtime. Each Fleet replica polls the new `trace_sampler_settings` row every 60 seconds and applies changes without a restart.
+38 -21
View File
@@ -4,6 +4,7 @@ import (
"context"
"github.com/fleetdm/fleet/v4/server/config"
"github.com/fleetdm/fleet/v4/server/platform/tracing"
"github.com/fleetdm/fleet/v4/server/version"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc"
@@ -17,26 +18,25 @@ import (
semconv "go.opentelemetry.io/otel/semconv/v1.40.0"
)
// initOTELProviders constructs the OpenTelemetry trace, metric, and (when
// log export is enabled) log providers. Returns nil providers when OTEL is
// disabled in the configuration. Fatal errors during exporter setup are
// reported through initFatal so the server can fail fast at startup.
// initOTELProviders constructs the OpenTelemetry trace, metric, and (when log export is enabled) log providers. Returns nil
// providers when OTEL is disabled in the configuration. Fatal errors during exporter setup are reported through initFatal so
// the server can fail fast at startup.
//
// As a side effect, the constructed tracer and meter providers are registered
// as the OTEL globals via otel.SetTracerProvider and otel.SetMeterProvider —
// matching the original inline behavior in runServeCmd.
func initOTELProviders(cfg config.FleetConfig, initFatal func(err error, msg string)) (
// The traceRegistry is consulted on every sampling decision. Populating it (with route to tier mappings) is the caller's
// responsibility. The canonical pattern is for each bounded context to register its own routes at startup. See
// server/service/tracing_tiers.go for the legacy non modularized routes.
func initOTELProviders(cfg config.FleetConfig, traceRegistry *tracing.Registry, initFatal func(err error, msg string)) (
*otelsdklog.LoggerProvider,
*sdktrace.TracerProvider,
*sdkmetric.MeterProvider,
*tracing.RouteTierSampler,
) {
if !cfg.OTELEnabled() {
return nil, nil, nil
return nil, nil, nil, nil
}
// Create shared resource with service identification attributes.
// OTEL_SERVICE_NAME and OTEL_RESOURCE_ATTRIBUTES env vars can override
// the defaults below.
// Create shared resource with service identification attributes. OTEL_SERVICE_NAME and OTEL_RESOURCE_ATTRIBUTES env vars
// can override the defaults below.
res, err := resource.New(context.Background(),
resource.WithSchemaURL(semconv.SchemaURL),
resource.WithAttributes(
@@ -48,9 +48,8 @@ func initOTELProviders(cfg config.FleetConfig, initFatal func(err error, msg str
)
if err != nil {
initFatal(err, "Failed to create OTEL resource")
// Returning here makes the function safe even if a caller's
// initFatal does not terminate (e.g., tests using a recorder).
return nil, nil, nil
// Returning here makes the function safe even if a caller's initFatal does not terminate (e.g. tests using a recorder).
return nil, nil, nil, nil
}
// Initialize OTEL traces.
@@ -59,16 +58,34 @@ func initOTELProviders(cfg config.FleetConfig, initFatal func(err error, msg str
))
if err != nil {
initFatal(err, "Failed to initialize OTEL trace exporter")
return nil, nil, nil
return nil, nil, nil, nil
}
// Configure batch span processor with smaller batch size to avoid exceeding
// message size limits (4MB default limit).
// Configure batch span processor with smaller batch size to avoid exceeding message size limits (4MB default limit).
batchSpanProcessor := sdktrace.NewBatchSpanProcessor(otlpTraceExporter,
sdktrace.WithMaxExportBatchSize(256), // Reduce from default 512 to 256
)
// Route aware head sampler. The sampler starts with compile time defaults. The settings poller re-reads trace_sampler_settings
// every 60s and calls Apply on change. We wrap with ParentBased so a remote sampled parent (e.g. an upstream span) keeps the
// whole trace coherent even on the hot agent path.
sampler := tracing.NewRouteTierSampler(traceRegistry)
// ParentBased semantics:
//
// Remote parent SAMPLED: honor the upstream decision via AlwaysSample.
//
// Remote parent NOT SAMPLED: honor it via NeverSample, keeping trace coherence across services. Defaulting to our local
// sampler here would let us locally sample a span whose upstream parent was explicitly dropped, breaking distributed
// traces.
//
// No remote parent: use our route aware sampler.
parentBased := sdktrace.ParentBased(
sampler,
sdktrace.WithRemoteParentSampled(sdktrace.AlwaysSample()),
sdktrace.WithRemoteParentNotSampled(sdktrace.NeverSample()),
)
tracerProvider := sdktrace.NewTracerProvider(
sdktrace.WithResource(res),
sdktrace.WithSpanProcessor(batchSpanProcessor),
sdktrace.WithSampler(parentBased),
)
otel.SetTracerProvider(tracerProvider)
@@ -78,7 +95,7 @@ func initOTELProviders(cfg config.FleetConfig, initFatal func(err error, msg str
)
if err != nil {
initFatal(err, "Failed to initialize OTEL metrics exporter")
return nil, nil, nil
return nil, nil, nil, nil
}
// Create views to rename otelsql metrics to match what OpenTelemetry Signoz expects.
@@ -125,7 +142,7 @@ func initOTELProviders(cfg config.FleetConfig, initFatal func(err error, msg str
)
if err != nil {
initFatal(err, "Failed to initialize OTEL log exporter")
return nil, nil, nil
return nil, nil, nil, nil
}
loggerProvider = otelsdklog.NewLoggerProvider(
otelsdklog.WithResource(res),
@@ -133,5 +150,5 @@ func initOTELProviders(cfg config.FleetConfig, initFatal func(err error, msg str
)
}
return loggerProvider, tracerProvider, meterProvider
return loggerProvider, tracerProvider, meterProvider, sampler
}
+7 -3
View File
@@ -6,6 +6,7 @@ import (
"time"
"github.com/fleetdm/fleet/v4/server/config"
"github.com/fleetdm/fleet/v4/server/platform/tracing"
otelsdklog "go.opentelemetry.io/otel/sdk/log"
sdkmetric "go.opentelemetry.io/otel/sdk/metric"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
@@ -41,12 +42,13 @@ func TestInitOTELProviders_DisabledReturnsNilProviders(t *testing.T) {
require.False(t, cfg.OTELEnabled(), "precondition: default config must have OTEL disabled")
called := false
lp, tp, mp := initOTELProviders(cfg, func(err error, msg string) { called = true })
lp, tp, mp, sampler := initOTELProviders(cfg, tracing.NewRegistry(), func(err error, msg string) { called = true })
require.False(t, called, "initFatal must not be called when OTEL is disabled")
require.Nil(t, lp)
require.Nil(t, tp)
require.Nil(t, mp)
require.Nil(t, sampler, "sampler must be nil when OTEL is disabled")
}
func TestInitOTELProviders_EnabledReturnsTracerAndMeterProviders(t *testing.T) {
@@ -60,13 +62,14 @@ func TestInitOTELProviders_EnabledReturnsTracerAndMeterProviders(t *testing.T) {
require.True(t, cfg.OTELEnabled(), "precondition: tracing-enabled config must have OTEL enabled")
called := false
lp, tp, mp := initOTELProviders(cfg, func(err error, msg string) { called = true })
lp, tp, mp, sampler := initOTELProviders(cfg, tracing.NewRegistry(), func(err error, msg string) { called = true })
shutdownOTELProviders(t, lp, tp, mp)
require.False(t, called, "initFatal must not be called for a healthy enabled config")
require.Nil(t, lp, "logger provider should be nil when OtelLogsEnabled is false")
require.NotNil(t, tp)
require.NotNil(t, mp)
require.NotNil(t, sampler, "sampler must be returned when OTEL is enabled")
}
func TestInitOTELProviders_LogExportEnabledReturnsLoggerProvider(t *testing.T) {
@@ -75,11 +78,12 @@ func TestInitOTELProviders_LogExportEnabledReturnsLoggerProvider(t *testing.T) {
}
called := false
lp, tp, mp := initOTELProviders(cfg, func(err error, msg string) { called = true })
lp, tp, mp, sampler := initOTELProviders(cfg, tracing.NewRegistry(), func(err error, msg string) { called = true })
shutdownOTELProviders(t, lp, tp, mp)
require.False(t, called)
require.NotNil(t, lp, "logger provider should be set when OtelLogsEnabled is true")
require.NotNil(t, tp)
require.NotNil(t, mp)
require.NotNil(t, sampler)
}
+25 -2
View File
@@ -85,6 +85,7 @@ import (
platform_http "github.com/fleetdm/fleet/v4/server/platform/http"
platform_logging "github.com/fleetdm/fleet/v4/server/platform/logging"
common_mysql "github.com/fleetdm/fleet/v4/server/platform/mysql"
"github.com/fleetdm/fleet/v4/server/platform/tracing"
"github.com/fleetdm/fleet/v4/server/pubsub"
"github.com/fleetdm/fleet/v4/server/service"
"github.com/fleetdm/fleet/v4/server/service/async"
@@ -181,8 +182,16 @@ func runServeCmd(cmd *cobra.Command, configManager configpkg.Manager, debug, dev
// Validate OTEL server options
config.Logging.Validate(initFatal)
// Init OTEL providers (traces, metrics, logs)
loggerProvider, tracerProvider, meterProvider := initOTELProviders(config, initFatal)
// Trace sampler tier registry. Bounded contexts register their own route to tier classifications below. The
// platform/tracing package stays free of cross context coupling. Infra paths (/healthz, /version, /metrics) are registered
// alongside their WrapHandler calls further down in this function.
traceRegistry := tracing.NewRegistry()
service.RegisterTracingTiers(traceRegistry)
activity_bootstrap.RegisterTracingTiers(traceRegistry)
// Future bounded contexts: each exposes its own RegisterTracingTiers.
// Init OTEL providers (traces, metrics, logs) and the route aware sampler.
loggerProvider, tracerProvider, meterProvider, traceSampler := initOTELProviders(config, traceRegistry, initFatal)
logger := initLogger(config, loggerProvider)
@@ -1037,6 +1046,13 @@ func runServeCmd(cmd *cobra.Command, configManager configpkg.Manager, debug, dev
}
}()
// Trace sampler runtime control. The poller re-reads trace_sampler_settings every 60s and atomically swaps the sampler's
// ratios and force_full so support can flip a 100% debug window via PATCH /debug/trace_sampler without restarting any
// replicas. No-op when OTEL is disabled.
if traceSampler != nil {
go tracing.StartSettingsPoller(ctx, traceSampler, ds, logger)
}
if softwareInstallStore != nil {
if err := cronSchedules.StartCronSchedule(
func() (fleet.CronSchedule, error) {
@@ -1474,6 +1490,13 @@ func runServeCmd(cmd *cobra.Command, configManager configpkg.Manager, debug, dev
), healthCheckers)
rootMux := http.NewServeMux()
// Infra paths (liveness, version, metrics) are platform owned and have zero diagnostic value as traces once their metric
// counterparts exist. Drop them unconditionally, even under ForceFull, since a 100% debug window should not flood SigNoz
// with probe spans. Register /metrics here even though its handler is mounted conditionally below. Registering a route
// whose handler isn't installed is a harmless lookup table entry and keeps the policy in one place.
traceRegistry.Register(http.MethodGet, "/healthz", tracing.TierNever)
traceRegistry.Register(http.MethodGet, "/version", tracing.TierNever)
traceRegistry.Register(http.MethodGet, "/metrics", tracing.TierNever)
rootMux.Handle("/healthz", service.PrometheusMetricsHandler("healthz", otelmw.WrapHandler(health.Handler(httpLogger, healthCheckers), "/healthz", config)))
rootMux.Handle("/version", service.PrometheusMetricsHandler("version", otelmw.WrapHandler(version.Handler(), "/version", config)))
rootMux.Handle("/assets/", service.PrometheusMetricsHandler("static_assets", otelmw.WrapHandlerDynamic(service.ServeStaticAssets("/assets/", serveCSP), config)))
+6
View File
@@ -12,6 +12,7 @@ import (
platform_authz "github.com/fleetdm/fleet/v4/server/platform/authz"
eu "github.com/fleetdm/fleet/v4/server/platform/endpointer"
platform_mysql "github.com/fleetdm/fleet/v4/server/platform/mysql"
"github.com/fleetdm/fleet/v4/server/platform/tracing"
"github.com/go-kit/kit/endpoint"
)
@@ -31,3 +32,8 @@ func New(
return svc, routesFn
}
// RegisterTracingTiers classifies the activity context's routes for trace sampling.
func RegisterTracingTiers(registry *tracing.Registry) {
service.RegisterTracingTiers(registry)
}
@@ -2,11 +2,13 @@ package service
import (
"context"
"net/http"
"github.com/fleetdm/fleet/v4/server/activity/api"
api_http "github.com/fleetdm/fleet/v4/server/activity/api/http"
eu "github.com/fleetdm/fleet/v4/server/platform/endpointer"
platform_http "github.com/fleetdm/fleet/v4/server/platform/http"
"github.com/fleetdm/fleet/v4/server/platform/tracing"
"github.com/go-kit/kit/endpoint"
kithttp "github.com/go-kit/kit/transport/http"
"github.com/gorilla/mux"
@@ -27,6 +29,15 @@ func attachFleetAPIRoutes(r *mux.Router, svc api.Service, authMiddleware endpoin
ue.GET("/api/_version_/fleet/hosts/{id:[0-9]+}/activities", listHostPastActivitiesEndpoint, api_http.ListHostPastActivitiesRequest{})
}
// RegisterTracingTiers classifies this context's routes for trace sampling. Both activity list endpoints are admin reads, so
// they belong in the standard tier (default 2%). Kept next to the route registrations above so the two stay in sync. The
// sampler's version normalizer collapses the {fleetversion:...} segment, so the "_version_" placeholder matches the rendered
// span name regardless of the configured API versions.
func RegisterTracingTiers(registry *tracing.Registry) {
registry.Register(http.MethodGet, "/api/_version_/fleet/activities", tracing.TierStandard)
registry.Register(http.MethodGet, "/api/_version_/fleet/hosts/{id}/activities", tracing.TierStandard)
}
func apiVersions() []string {
return []string{"v1", "latest"}
}
@@ -0,0 +1,39 @@
package tables
import (
"database/sql"
)
func init() {
MigrationClient.AddMigration(Up_20260601200727, Down_20260601200727)
}
func Up_20260601200727(tx *sql.Tx) error {
// Singleton settings row holding the runtime tunable trace sampling configuration. Operators flip these via PATCH
// /debug/trace_sampler. The /debug auth log and the PATCH access log already record who made the change.
_, err := tx.Exec(`
CREATE TABLE trace_sampler_settings (
id TINYINT UNSIGNED NOT NULL PRIMARY KEY,
high_volume_ratio DOUBLE NOT NULL DEFAULT 0.001,
standard_ratio DOUBLE NOT NULL DEFAULT 0.02,
force_full TINYINT(1) NOT NULL DEFAULT 0,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT ck_trace_sampler_settings_singleton CHECK (id = 1),
CONSTRAINT ck_trace_sampler_settings_high_range CHECK (high_volume_ratio BETWEEN 0 AND 1),
CONSTRAINT ck_trace_sampler_settings_std_range CHECK (standard_ratio BETWEEN 0 AND 1)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`)
if err != nil {
return err
}
// Insert the seed row with an explicit fixed updated_at instead of letting the column default to CURRENT_TIMESTAMP.
// schema.sql captures the row's data; if updated_at varied per migration run, every developer would get a different schema
// dump and `make test-schema` would never pass.
_, err = tx.Exec(`INSERT INTO trace_sampler_settings (id, updated_at) VALUES (1, '2026-06-01 00:00:00')`)
return err
}
func Down_20260601200727(tx *sql.Tx) error {
return nil
}
File diff suppressed because one or more lines are too long
+56
View File
@@ -0,0 +1,56 @@
package mysql
import (
"context"
"database/sql"
"errors"
"fmt"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
"github.com/fleetdm/fleet/v4/server/platform/tracing"
"github.com/jmoiron/sqlx"
)
const traceSamplerSettingsID = 1
func (ds *Datastore) GetTraceSamplerSettings(ctx context.Context) (*tracing.Settings, error) {
const stmt = `
SELECT high_volume_ratio, standard_ratio, force_full, updated_at
FROM trace_sampler_settings
WHERE id = ?
`
var settings tracing.Settings
if err := sqlx.GetContext(ctx, ds.reader(ctx), &settings, stmt, traceSamplerSettingsID); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ctxerr.Wrap(ctx, notFound("TraceSamplerSettings"))
}
return nil, ctxerr.Wrap(ctx, err, "get trace_sampler_settings")
}
return &settings, nil
}
func (ds *Datastore) SetTraceSamplerSettings(ctx context.Context, settings *tracing.Settings) error {
const stmt = `
UPDATE trace_sampler_settings
SET high_volume_ratio = ?, standard_ratio = ?, force_full = ?
WHERE id = ?
`
res, err := ds.writer(ctx).ExecContext(ctx, stmt,
settings.HighVolumeRatio,
settings.StandardRatio,
settings.ForceFull,
traceSamplerSettingsID,
)
if err != nil {
return ctxerr.Wrap(ctx, err, "set trace_sampler_settings")
}
rows, err := res.RowsAffected()
if err != nil {
return ctxerr.Wrap(ctx, err, "set trace_sampler_settings: rows affected")
}
// The singleton row is seeded by the migration. A missing row means the invariant is broken.
if rows != 1 {
return ctxerr.Wrap(ctx, fmt.Errorf("set trace_sampler_settings: expected 1 row updated, got %d", rows))
}
return nil
}
@@ -0,0 +1,65 @@
package mysql
import (
"testing"
"github.com/fleetdm/fleet/v4/server/platform/tracing"
"github.com/stretchr/testify/require"
)
func TestTraceSamplerSettings(t *testing.T) {
ds := CreateMySQLDS(t)
ctx := t.Context()
t.Run("seeded defaults are returned", func(t *testing.T) {
got, err := ds.GetTraceSamplerSettings(ctx)
require.NoError(t, err)
require.NotNil(t, got)
require.InDelta(t, 0.001, got.HighVolumeRatio, 1e-9)
require.InDelta(t, 0.02, got.StandardRatio, 1e-9)
require.False(t, got.ForceFull)
})
t.Run("round trip persists changes", func(t *testing.T) {
err := ds.SetTraceSamplerSettings(ctx, &tracing.Settings{
HighVolumeRatio: 0.005,
StandardRatio: 0.1,
ForceFull: true,
})
require.NoError(t, err)
got, err := ds.GetTraceSamplerSettings(ctx)
require.NoError(t, err)
require.InDelta(t, 0.005, got.HighVolumeRatio, 1e-9)
require.InDelta(t, 0.1, got.StandardRatio, 1e-9)
require.True(t, got.ForceFull)
})
t.Run("out of range ratio rejected by CHECK constraint", func(t *testing.T) {
err := ds.SetTraceSamplerSettings(ctx, &tracing.Settings{
HighVolumeRatio: 1.5,
StandardRatio: 0.02,
ForceFull: false,
})
require.Error(t, err)
})
t.Run("set fails when singleton row is missing", func(t *testing.T) {
// Locks in the RowsAffected != 1 guard in SetTraceSamplerSettings. If the seeded singleton row is missing (DB invariant
// broken), Set must surface a loud error rather than silently no-op.
_, err := ds.writer(ctx).ExecContext(ctx, `DELETE FROM trace_sampler_settings`)
require.NoError(t, err)
t.Cleanup(func() {
_, err := ds.writer(ctx).ExecContext(ctx, `INSERT INTO trace_sampler_settings (id) VALUES (1)`)
require.NoError(t, err)
})
err = ds.SetTraceSamplerSettings(ctx, &tracing.Settings{
HighVolumeRatio: 0.01,
StandardRatio: 0.05,
ForceFull: false,
})
require.Error(t, err)
require.Contains(t, err.Error(), "expected 1 row updated")
})
}
+11
View File
@@ -20,6 +20,7 @@ import (
"github.com/fleetdm/fleet/v4/server/mdm/nanomdm/storage"
platform_errors "github.com/fleetdm/fleet/v4/server/platform/errors"
platform_http "github.com/fleetdm/fleet/v4/server/platform/http"
"github.com/fleetdm/fleet/v4/server/platform/tracing"
"github.com/jmoiron/sqlx"
)
@@ -3344,6 +3345,16 @@ type Datastore interface {
// HasWindowsUpdateConfigProfileConfigured checks if a profile for the team already exists in the update_settings table.
HasWindowsUpdateConfigProfileConfigured(ctx context.Context, teamID uint) (bool, error)
///////////////////////////////////////////////////////////////////////////////
// TraceSamplerStore
// GetTraceSamplerSettings returns the singleton trace_sampler_settings row.
GetTraceSamplerSettings(ctx context.Context) (*tracing.Settings, error)
// SetTraceSamplerSettings updates the singleton trace_sampler_settings row. The caller is responsible for validating that
// ratios are in [0, 1]. The DB CHECK constraints reject out of range writes as a backstop.
SetTraceSamplerSettings(ctx context.Context, settings *tracing.Settings) error
}
type AndroidDatastore interface {
+25
View File
@@ -18,6 +18,7 @@ import (
"github.com/fleetdm/fleet/v4/server/mdm/apple/mobileconfig"
"github.com/fleetdm/fleet/v4/server/mdm/nanodep/godep"
"github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm"
"github.com/fleetdm/fleet/v4/server/platform/tracing"
"github.com/jmoiron/sqlx"
)
@@ -2047,6 +2048,10 @@ type HasAppleUpdateConfigProfileConfiguredFunc func(ctx context.Context, teamID
type HasWindowsUpdateConfigProfileConfiguredFunc func(ctx context.Context, teamID uint) (bool, error)
type GetTraceSamplerSettingsFunc func(ctx context.Context) (*tracing.Settings, error)
type SetTraceSamplerSettingsFunc func(ctx context.Context, settings *tracing.Settings) error
type DataStore struct {
AppConfigFunc AppConfigFunc
AppConfigFuncInvoked bool
@@ -5084,6 +5089,12 @@ type DataStore struct {
HasWindowsUpdateConfigProfileConfiguredFunc HasWindowsUpdateConfigProfileConfiguredFunc
HasWindowsUpdateConfigProfileConfiguredFuncInvoked bool
GetTraceSamplerSettingsFunc GetTraceSamplerSettingsFunc
GetTraceSamplerSettingsFuncInvoked bool
SetTraceSamplerSettingsFunc SetTraceSamplerSettingsFunc
SetTraceSamplerSettingsFuncInvoked bool
mu sync.Mutex
}
@@ -12170,3 +12181,17 @@ func (s *DataStore) HasWindowsUpdateConfigProfileConfigured(ctx context.Context,
s.mu.Unlock()
return s.HasWindowsUpdateConfigProfileConfiguredFunc(ctx, teamID)
}
func (s *DataStore) GetTraceSamplerSettings(ctx context.Context) (*tracing.Settings, error) {
s.mu.Lock()
s.GetTraceSamplerSettingsFuncInvoked = true
s.mu.Unlock()
return s.GetTraceSamplerSettingsFunc(ctx)
}
func (s *DataStore) SetTraceSamplerSettings(ctx context.Context, settings *tracing.Settings) error {
s.mu.Lock()
s.SetTraceSamplerSettingsFuncInvoked = true
s.mu.Unlock()
return s.SetTraceSamplerSettingsFunc(ctx, settings)
}
+13
View File
@@ -0,0 +1,13 @@
// Package tracing implements a route aware head sampler for Fleet's OTEL trace export. The sampler classifies each span via a
// Registry. Each bounded context populates the Registry at startup with its own routes. The sampler then applies per tier ratio
// sampling. The goal is to prevent noisy hot agent paths from drowning out the rare load bearing paths (enroll, MDM command
// flows, cron jobs).
//
// Runtime control lives in the trace_sampler_settings MySQL row. Each Fleet replica runs StartSettingsPoller which re-reads the
// row every 60 seconds and atomically swaps the sampler's state. No restart is required to flip force_full during an incident
// debug window.
//
// Architecture: platform/tracing owns the mechanism (sampler, tier enum, registry). Each bounded context owns the policy: which
// of its routes belong in which tier. Each context registers them at startup. This keeps the platform package free of cross
// context coupling.
package tracing
+92
View File
@@ -0,0 +1,92 @@
package tracing
import (
"regexp"
"sync"
)
// Tier classifies a span for sampling. Higher value spans (TierAlways) always get sampled. High volume noise (TierHighVolume)
// is downsampled aggressively. Liveness probes (TierNever) are dropped unconditionally.
type Tier int
const (
// TierAlways is the catch all. Spans not registered with the Registry fall here, and are sampled at 100%. Cron jobs, enroll,
// MDM checkin, SCEP, GitOps batch (all the load bearing flows) should never need an explicit registration. Their absence from
// the noisy lists is what keeps them safe by default.
TierAlways Tier = iota
// TierHighVolume is for routes that dominate request volume without being individually interesting (osquery distributed
// read/write, orbit ping/config, device desktop/ping). Sampled at the configured high volume ratio (default 0.1%).
TierHighVolume
// TierStandard is for routes with moderate volume and moderate diagnostic value (admin reads, dashboard endpoints, asset
// paths). Sampled at the configured standard ratio (default 2%).
TierStandard
// TierNever drops the span unconditionally, even under ForceFull. Reserved for high volume zero diagnostic value paths like
// liveness probes, the version endpoint, and the Prometheus scrape path.
TierNever
)
// Registry maps normalized route names to tiers. Bounded contexts register their own routes at startup via Register. The
// sampler reads via Lookup on every span. Routes not present in the registry fall to TierAlways.
//
// The Registry is the policy seam. platform/tracing provides the mechanism (sampler and tier enum). Each bounded context
// provides the policy: which of its routes belong in which tier.
type Registry struct {
mu sync.RWMutex
routes map[string]Tier
}
// NewRegistry returns an empty Registry. Routes are added via Register.
func NewRegistry() *Registry {
return &Registry{routes: make(map[string]Tier)}
}
// Register classifies a method and path. Paths are normalized on write the same way they are on Lookup. The gorilla/mux
// version segment is collapsed to "_version_". Regex constrained params (e.g. "{id:[0-9]+}") are stripped to "{id}". Callers
// can therefore register in either the readable form ("/api/_version_/fleet/hosts/{id}") or the raw template form, and lookups
// will still match. Both alternate path forms (e.g. "/api/v1/osquery/..." and "/api/osquery/...") still need separate
// registrations.
//
// Re-registering the same normalized method+path overwrites the prior tier.
func (r *Registry) Register(method, path string, tier Tier) {
r.mu.Lock()
defer r.mu.Unlock()
r.routes[normalizeSpanName(spanKey(method, path))] = tier
}
// Lookup returns the tier for a span name and a bool indicating whether the route was found in the registry. Span names are
// normalized before lookup so the gorilla version regex is stripped to "_version_". Cron and other non HTTP span names (no
// leading "METHOD ") simply won't be in the registry and return (TierAlways, false).
func (r *Registry) Lookup(spanName string) (Tier, bool) {
normalized := normalizeSpanName(spanName)
r.mu.RLock()
defer r.mu.RUnlock()
t, ok := r.routes[normalized]
return t, ok
}
// versionTemplatePattern matches the gorilla/mux version segment Fleet inserts via /_version_/, replacing it with
// /{fleetversion:(?:v1|2022-04|latest)}/ at registration time. We normalize it back to /_version_/ so the registry stays
// decoupled from the configured version list.
var versionTemplatePattern = regexp.MustCompile(`\{fleetversion:[^}]*\}`)
// muxParamRegexPattern strips regex constraints from gorilla/mux path params. For example {id:[0-9]+} becomes {id}, and
// {fleet_id:[0-9]+} becomes {fleet_id}. Fleet uses this style extensively, and the route tier policy in
// server/service/tracing_tiers.go registers the simpler {id} form. Without this normalization, spans whose mux template
// includes the regex would miss the registry and silently fall to TierAlways at 100% sampling.
var muxParamRegexPattern = regexp.MustCompile(`\{([a-zA-Z0-9_]+):[^}]+\}`)
func normalizeSpanName(name string) string {
// Apply versionTemplatePattern first because it strips both the param name and the surrounding braces (replacing with
// `_version_`). muxParamRegexPattern preserves the braces. Running it first would turn {fleetversion:...} into
// {fleetversion}, which would then no longer match versionTemplatePattern.
name = versionTemplatePattern.ReplaceAllString(name, "_version_")
name = muxParamRegexPattern.ReplaceAllString(name, "{$1}")
return name
}
func spanKey(method, path string) string {
return method + " " + path
}
+176
View File
@@ -0,0 +1,176 @@
package tracing
import (
"net/http"
"strconv"
"sync"
"testing"
"github.com/stretchr/testify/require"
)
func TestRegistry_LookupNormalizes(t *testing.T) {
// One fixture covers all input shapes that should resolve to the same registered route: fleetversion templates (multi
// version and single version), mux regex constrained params, and already normalized inputs.
r := NewRegistry()
r.Register(http.MethodGet, "/api/_version_/fleet/hosts", TierStandard)
r.Register(http.MethodGet, "/api/_version_/fleet/hosts/{id}", TierStandard)
r.Register(http.MethodPatch, "/api/_version_/fleet/fleets/{fleet_id}/secrets", TierAlways)
cases := []struct {
name string
input string
want Tier
wantOK bool
}{
{
name: "regex version template",
input: "GET /api/{fleetversion:(?:v1|2022-04|latest)}/fleet/hosts",
want: TierStandard,
wantOK: true,
},
{
name: "single version regex template",
input: "GET /api/{fleetversion:(?:latest)}/fleet/hosts",
want: TierStandard,
wantOK: true,
},
{
name: "already normalized form",
input: "GET /api/_version_/fleet/hosts",
want: TierStandard,
wantOK: true,
},
{
name: "mux regex param {id:[0-9]+}",
input: "GET /api/{fleetversion:(?:v1|2022-04|latest)}/fleet/hosts/{id:[0-9]+}",
want: TierStandard,
wantOK: true,
},
{
name: "mux regex param {fleet_id:[0-9]+}",
input: "PATCH /api/{fleetversion:(?:v1|2022-04|latest)}/fleet/fleets/{fleet_id:[0-9]+}/secrets",
want: TierAlways,
wantOK: true,
},
{
name: "unregistered route",
input: "POST /not/in/registry",
want: TierAlways, // zero value. Sampler interprets the !ok as the catch all.
wantOK: false,
},
{
name: "cron span name is not in registry",
input: "vuln.update_host_counts",
want: TierAlways,
wantOK: false,
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got, ok := r.Lookup(c.input)
require.Equal(t, c.wantOK, ok)
require.Equal(t, c.want, got)
})
}
}
func TestRegistry_RegisterNormalizesSymmetrically(t *testing.T) {
// Inverse of TestRegistry_LookupNormalizes: registering with the regex form must also resolve via lookup of the bare form.
// This is the invariant the Register side normalization delivers.
r := NewRegistry()
r.Register(http.MethodGet, "/api/_version_/fleet/hosts/{id:[0-9]+}", TierStandard)
got, ok := r.Lookup("GET /api/{fleetversion:(?:latest)}/fleet/hosts/{id}")
require.True(t, ok, "regex form registration must be findable via simple form lookup")
require.Equal(t, TierStandard, got)
}
func TestRegistry_RegisterOverwrites(t *testing.T) {
t.Run("same form", func(t *testing.T) {
r := NewRegistry()
r.Register(http.MethodPost, "/foo", TierStandard)
r.Register(http.MethodPost, "/foo", TierHighVolume)
got, ok := r.Lookup("POST /foo")
require.True(t, ok)
require.Equal(t, TierHighVolume, got)
})
t.Run("different forms collide on the same normalized key", func(t *testing.T) {
// {id} and {id:[0-9]+} are the same logical route after normalization. The second Register must overwrite the first
// regardless of which surface form was used.
r := NewRegistry()
r.Register(http.MethodGet, "/api/_version_/fleet/hosts/{id}", TierStandard)
r.Register(http.MethodGet, "/api/_version_/fleet/hosts/{id:[0-9]+}", TierHighVolume)
got, _ := r.Lookup("GET /api/_version_/fleet/hosts/{id}")
require.Equal(t, TierHighVolume, got)
})
}
func TestRegistry_ConcurrentReadersAndWriters(t *testing.T) {
// Exercise the RWMutex under -race. Late arriving registrations must not corrupt concurrent lookups. This is what makes it
// safe for bounded contexts to register at startup while the tracer provider is already serving spans.
r := NewRegistry()
const writerCount = 4
const readerCount = 8
const iterations = 5000
var wg sync.WaitGroup
for w := range writerCount {
wg.Go(func() {
for i := range iterations {
path := "/path/writer/" + strconv.Itoa(w) + "/" + strconv.Itoa(i)
r.Register(http.MethodGet, path, TierStandard)
}
})
}
for range readerCount {
wg.Go(func() {
for range iterations {
_, _ = r.Lookup("GET /path/writer/0/0")
}
})
}
wg.Wait()
}
func TestNormalizeSpanName(t *testing.T) {
// Unit test for the helper. Lookup integration is covered separately. These cases pin the helper's behavior independent of
// the map lookup.
cases := []struct {
in, want string
}{
{"GET /healthz", "GET /healthz"},
{
"GET /api/{fleetversion:(?:v1|2022-04|latest)}/fleet/hosts",
"GET /api/_version_/fleet/hosts",
},
{
"GET /api/_version_/fleet/queries",
"GET /api/_version_/fleet/queries",
},
{"vuln.update_host_counts", "vuln.update_host_counts"},
// Mux regex constraints on path params are stripped to the bare {name} form so the registry can stay decoupled from the
// constraint syntax.
{
"GET /api/_version_/fleet/hosts/{id:[0-9]+}",
"GET /api/_version_/fleet/hosts/{id}",
},
{
"PATCH /api/{fleetversion:(?:v1|2022-04|latest)}/fleet/fleets/{fleet_id:[0-9]+}/secrets",
"PATCH /api/_version_/fleet/fleets/{fleet_id}/secrets",
},
// Params without a regex constraint pass through unchanged.
{
"GET /api/_version_/fleet/device/{token}/desktop",
"GET /api/_version_/fleet/device/{token}/desktop",
},
}
for _, c := range cases {
t.Run(c.in, func(t *testing.T) {
require.Equal(t, c.want, normalizeSpanName(c.in))
})
}
}
+105
View File
@@ -0,0 +1,105 @@
package tracing
import (
"fmt"
"sync/atomic"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
)
// Default sampling ratios. These match the seeded trace_sampler_settings row so a freshly started server uses the same ratios
// as one that has polled the DB.
const (
DefaultHighVolumeRatio = 0.001
DefaultStandardRatio = 0.02
)
// RouteTierSampler implements sdktrace.Sampler. The configured ratios live in an atomic.Pointer so SettingsPoller can swap them
// under a hot reader without locking. Tier classification is delegated to the Registry passed at construction time.
type RouteTierSampler struct {
state atomic.Pointer[samplerState]
registry *Registry
}
type samplerState struct {
highVolume sdktrace.Sampler
standard sdktrace.Sampler
always sdktrace.Sampler
never sdktrace.Sampler
forceFull bool
}
// NewRouteTierSampler returns a sampler initialized with the default ratios and force_full=false. The poller (if running)
// overwrites these on the first tick once it reads the DB row. The registry is consulted on every ShouldSample call. Routes
// added to the registry after construction are picked up immediately.
func NewRouteTierSampler(registry *Registry) *RouteTierSampler {
s := &RouteTierSampler{registry: registry}
s.state.Store(buildState(DefaultHighVolumeRatio, DefaultStandardRatio, false))
return s
}
// Apply replaces the sampler's state atomically. Out of range ratios are clamped to [0, 1] as a defensive backstop. The DB
// CHECK constraints and the PATCH handler validation reject these earlier in the pipeline.
func (s *RouteTierSampler) Apply(highVolume, standard float64, forceFull bool) {
s.state.Store(buildState(clamp01(highVolume), clamp01(standard), forceFull))
}
func clamp01(v float64) float64 {
switch {
case v < 0:
return 0
case v > 1:
return 1
}
return v
}
func buildState(highVolume, standard float64, forceFull bool) *samplerState {
return &samplerState{
highVolume: sdktrace.TraceIDRatioBased(highVolume),
standard: sdktrace.TraceIDRatioBased(standard),
always: sdktrace.AlwaysSample(),
never: sdktrace.NeverSample(),
forceFull: forceFull,
}
}
// ShouldSample implements sdktrace.Sampler. TierNever wins over ForceFull. Liveness probes should never trace, even during a
// 100% debug window. Unregistered spans (cron, MDM checkin, enroll, etc.) fall to TierAlways.
func (s *RouteTierSampler) ShouldSample(p sdktrace.SamplingParameters) sdktrace.SamplingResult {
st := s.state.Load()
tier, _ := s.registry.Lookup(p.Name)
if tier == TierNever {
return st.never.ShouldSample(p)
}
if st.forceFull {
return st.always.ShouldSample(p)
}
switch tier {
case TierHighVolume:
return st.highVolume.ShouldSample(p)
case TierStandard:
return st.standard.ShouldSample(p)
case TierAlways:
return st.always.ShouldSample(p)
}
return st.always.ShouldSample(p)
}
// Description implements sdktrace.Sampler. OTel uses it for diagnostic logging. The value should describe the sampler's
// behavior unambiguously.
func (s *RouteTierSampler) Description() string {
st := s.state.Load()
return fmt.Sprintf("RouteTierSampler{highVolume=%g,standard=%g,forceFull=%t}",
samplerRatio(st.highVolume), samplerRatio(st.standard), st.forceFull)
}
// samplerRatio extracts the configured ratio from a TraceIDRatioBased sampler for description purposes only. The SDK does not
// expose the ratio directly, so we parse its description ("TraceIDRatioBased{0.001}").
func samplerRatio(s sdktrace.Sampler) float64 {
var r float64
if _, err := fmt.Sscanf(s.Description(), "TraceIDRatioBased{%f}", &r); err != nil {
return -1
}
return r
}
+200
View File
@@ -0,0 +1,200 @@
package tracing
import (
"encoding/binary"
"math/rand/v2"
"net/http"
"sync"
"sync/atomic"
"testing"
"github.com/stretchr/testify/require"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
"go.opentelemetry.io/otel/trace"
)
// testRegistry returns a Registry pre-populated with the routes the sampler tests exercise. It lives next to its only consumer
// so registry_test.go can stay focused on Registry semantics.
func testRegistry() *Registry {
r := NewRegistry()
r.Register(http.MethodGet, "/healthz", TierNever)
r.Register(http.MethodGet, "/version", TierNever)
r.Register(http.MethodGet, "/metrics", TierNever)
r.Register(http.MethodPost, "/api/osquery/distributed/read", TierHighVolume)
r.Register(http.MethodPost, "/api/v1/osquery/distributed/read", TierHighVolume)
r.Register(http.MethodPost, "/api/osquery/distributed/write", TierHighVolume)
r.Register(http.MethodPost, "/api/fleet/orbit/config", TierHighVolume)
r.Register(http.MethodHead, "/api/fleet/orbit/ping", TierHighVolume)
r.Register(http.MethodHead, "/api/_version_/fleet/device/{token}/ping", TierHighVolume)
r.Register(http.MethodGet, "/api/_version_/fleet/device/{token}/desktop", TierHighVolume)
r.Register(http.MethodGet, "/api/_version_/fleet/hosts", TierStandard)
r.Register(http.MethodGet, "/api/_version_/fleet/hosts/{id}", TierStandard)
r.Register(http.MethodGet, "/api/_version_/fleet/queries", TierStandard)
return r
}
// sample is a tiny helper that asks the sampler whether a span with the given name should be recorded. A pseudo random trace
// ID is used so the TraceIDRatioBased sampler's decision varies per call.
//
//nolint:gosec // test trace IDs, not security sensitive
func sample(t *testing.T, s *RouteTierSampler, name string) bool {
t.Helper()
var tid trace.TraceID
binary.LittleEndian.PutUint64(tid[0:8], rand.Uint64())
binary.LittleEndian.PutUint64(tid[8:16], rand.Uint64())
res := s.ShouldSample(sdktrace.SamplingParameters{
TraceID: tid,
Name: name,
Kind: trace.SpanKindServer,
})
return res.Decision == sdktrace.RecordAndSample
}
// sampleRate runs N trials and returns the observed sample rate.
func sampleRate(t *testing.T, s *RouteTierSampler, name string, n int) float64 {
t.Helper()
hits := 0
for range n {
if sample(t, s, name) {
hits++
}
}
return float64(hits) / float64(n)
}
// TestRouteTierSampler_NeverTierDropsUnconditionally locks in the invariant that TierNever paths are never sampled, both at
// default config and under the most aggressive override (force_full=true with ratios maxed). The force_full subtest is the
// stronger guarantee. The default config case is kept to make the absence of any default-time leak explicit.
func TestRouteTierSampler_NeverTierDropsUnconditionally(t *testing.T) {
paths := []string{"GET /healthz", "GET /version", "GET /metrics"}
cases := []struct {
name string
apply func(*RouteTierSampler)
}{
{name: "default config", apply: func(*RouteTierSampler) {}},
{name: "force_full with max ratios", apply: func(s *RouteTierSampler) { s.Apply(1.0, 1.0, true) }},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
s := NewRouteTierSampler(testRegistry())
c.apply(s)
for _, p := range paths {
for range 1000 {
require.False(t, sample(t, s, p), "tierNever must drop %s", p)
}
}
})
}
}
// TestRouteTierSampler_AlwaysTierKeepsUnconditionally locks in the invariant that unclassified spans (cron, novel routes)
// always sample, both at default config and when ratios are forced to zero. The ratios=0 subtest is the stronger guarantee.
func TestRouteTierSampler_AlwaysTierKeepsUnconditionally(t *testing.T) {
names := []string{
"vuln.update_host_counts", // cron
"POST /api/_version_/fleet/mdm/profiles/batch", // GitOps batch
"POST /api/fleet/orbit/enroll", // enroll
"some-future-endpoint-not-in-any-list", // unknown
}
cases := []struct {
name string
apply func(*RouteTierSampler)
}{
{name: "default config", apply: func(*RouteTierSampler) {}},
{name: "ratios forced to zero", apply: func(s *RouteTierSampler) { s.Apply(0.0, 0.0, false) }},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
s := NewRouteTierSampler(testRegistry())
c.apply(s)
for _, n := range names {
for range 1000 {
require.True(t, sample(t, s, n), "tierAlways must keep %s", n)
}
}
})
}
}
func TestRouteTierSampler_RatioSampling(t *testing.T) {
const n = 100_000
const tolerance = 0.005 // 0.5pp absolute, generous for 100k trials
s := NewRouteTierSampler(testRegistry())
s.Apply(0.1, 0.5, false) // visible ratios for stable comparisons
highRate := sampleRate(t, s, "POST /api/osquery/distributed/read", n)
require.InDelta(t, 0.1, highRate, tolerance, "high volume tier should track its configured ratio")
stdRate := sampleRate(t, s, "GET /api/_version_/fleet/hosts", n)
require.InDelta(t, 0.5, stdRate, tolerance, "standard tier should track its configured ratio")
}
func TestRouteTierSampler_ForceFull(t *testing.T) {
s := NewRouteTierSampler(testRegistry())
s.Apply(0.0, 0.0, true) // ratios zero, force_full should override
for _, name := range []string{
"POST /api/osquery/distributed/read", // would be 0% via high volume
"GET /api/_version_/fleet/hosts", // would be 0% via standard
"POST /api/fleet/orbit/enroll", // already always
} {
t.Run(name, func(t *testing.T) {
for range 1000 {
require.True(t, sample(t, s, name),
"force_full must override ratio based tiers")
}
})
}
}
func TestRouteTierSampler_ApplyRaceFree(t *testing.T) {
s := NewRouteTierSampler(testRegistry())
var (
stop atomic.Bool
readers sync.WaitGroup
writers sync.WaitGroup
)
// One writer flips ratios continuously.
writers.Go(func() {
for !stop.Load() {
//nolint:gosec // test fuzz inputs, not security sensitive
s.Apply(rand.Float64(), rand.Float64(), rand.IntN(2) == 0)
}
})
// Several readers hammer ShouldSample.
const readerCount = 8
for range readerCount {
readers.Go(func() {
for !stop.Load() {
_ = sample(t, s, "POST /api/osquery/distributed/read")
_ = sample(t, s, "GET /healthz")
_ = sample(t, s, "vuln.update_host_counts")
}
})
}
// Run for a tight window. The race detector will fail if there's a torn read.
for range 50_000 {
_ = sample(t, s, "POST /api/_version_/fleet/spec/teams")
}
stop.Store(true)
readers.Wait()
writers.Wait()
}
func TestRouteTierSampler_ClampOutOfRange(t *testing.T) {
// Apply should clamp defensively even if a caller passes out of range ratios. The DB CHECK rejects these in practice.
s := NewRouteTierSampler(testRegistry())
s.Apply(-1.0, 5.0, false)
st := s.state.Load()
require.Equal(t, "TraceIDRatioBased{0}", st.highVolume.Description())
require.Equal(t, "TraceIDRatioBased{1}", st.standard.Description())
}
+13
View File
@@ -0,0 +1,13 @@
package tracing
import "time"
// Settings is the runtime tunable head sampling configuration. The settings live in the trace_sampler_settings singleton row
// and are polled by each Fleet replica so support can adjust sampling without a restart.
type Settings struct {
HighVolumeRatio float64 `json:"high_volume_ratio" db:"high_volume_ratio"`
StandardRatio float64 `json:"standard_ratio" db:"standard_ratio"`
ForceFull bool `json:"force_full" db:"force_full"`
// UpdatedAt uses omitzero so the PATCH handler can zero it before echoing the response.
UpdatedAt time.Time `json:"updated_at,omitzero" db:"updated_at"`
}
@@ -0,0 +1,72 @@
package tracing
import (
"context"
"log/slog"
"time"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/trace"
)
// settingsPollInterval is how often each Fleet replica re-reads trace_sampler_settings. 60s matches industry defaults for
// feature flag style runtime config.
const settingsPollInterval = 60 * time.Second
// pollTracer instruments the poll loop. When OTEL is not configured the global provider returns a no-op tracer, so this is
// free when tracing is disabled.
var pollTracer = otel.Tracer("github.com/fleetdm/fleet/v4/server/platform/tracing")
// settingsReader is the minimal datastore surface the poller needs. The full fleet.Datastore is large. Depending only on this
// interface keeps the poller's tests cheap and the package free of cross context coupling.
type settingsReader interface {
GetTraceSamplerSettings(ctx context.Context) (*Settings, error)
}
// StartSettingsPoller runs the polling loop until ctx is cancelled. On each tick it reads the trace_sampler_settings row,
// compares the values to the last applied state, and calls sampler. Apply only when something changed.
//
// On startup it does one immediate read so the sampler picks up the row's current values without waiting a full interval. If
// the first read fails, the sampler keeps its compile time defaults and a warning is logged. The next tick will try again.
func StartSettingsPoller(ctx context.Context, sampler *RouteTierSampler, ds settingsReader, logger *slog.Logger) {
pollAndApply := func(last *Settings) *Settings {
spanCtx, span := pollTracer.Start(ctx, "tracing.poll_settings",
trace.WithNewRoot(),
trace.WithSpanKind(trace.SpanKindInternal),
)
defer span.End()
got, err := ds.GetTraceSamplerSettings(spanCtx)
if err != nil {
logger.ErrorContext(spanCtx, "trace sampler settings poll failed", "err", err)
return last
}
if last != nil &&
got.HighVolumeRatio == last.HighVolumeRatio &&
got.StandardRatio == last.StandardRatio &&
got.ForceFull == last.ForceFull {
return last
}
sampler.Apply(got.HighVolumeRatio, got.StandardRatio, got.ForceFull)
logger.InfoContext(spanCtx, "trace sampler settings applied",
"high_volume_ratio", got.HighVolumeRatio,
"standard_ratio", got.StandardRatio,
"force_full", got.ForceFull,
)
return got
}
var last *Settings
last = pollAndApply(last)
ticker := time.NewTicker(settingsPollInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
last = pollAndApply(last)
}
}
}
@@ -0,0 +1,139 @@
package tracing
import (
"context"
"errors"
"io"
"log/slog"
"sync/atomic"
"testing"
"testing/synctest"
"time"
"github.com/stretchr/testify/require"
)
type stubReader struct {
mu atomic.Pointer[Settings]
err atomic.Pointer[error]
getCalls atomic.Int32
}
func (s *stubReader) set(settings Settings) {
s.mu.Store(&settings)
}
func (s *stubReader) setErr(err error) {
s.err.Store(&err)
}
func (s *stubReader) GetTraceSamplerSettings(_ context.Context) (*Settings, error) {
s.getCalls.Add(1)
if e := s.err.Load(); e != nil && *e != nil {
return nil, *e
}
if cur := s.mu.Load(); cur != nil {
out := *cur
return &out, nil
}
return &Settings{
HighVolumeRatio: DefaultHighVolumeRatio,
StandardRatio: DefaultStandardRatio,
}, nil
}
func discardLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, nil))
}
func TestStartSettingsPoller_AppliesInitialReadImmediately(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
r := &stubReader{}
r.set(Settings{
HighVolumeRatio: 0.4,
StandardRatio: 0.8,
ForceFull: true,
})
sampler := NewRouteTierSampler(NewRegistry())
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
go StartSettingsPoller(ctx, sampler, r, discardLogger())
// Wait blocks until every other goroutine in the bubble is durably blocked. The poller does its synchronous initial
// read, applies, then blocks on the ticker. So once Wait returns, the apply has happened.
synctest.Wait()
require.Equal(t, int32(1), r.getCalls.Load(), "exactly one poll should have happened by now")
st := sampler.state.Load()
require.True(t, st.forceFull, "initial read must apply force_full")
})
}
func TestStartSettingsPoller_HandlesErrorGracefully(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
r := &stubReader{}
r.setErr(errors.New("db unavailable"))
sampler := NewRouteTierSampler(NewRegistry())
// Capture current state. It should remain unchanged after a failed poll.
beforeForceFull := sampler.state.Load().forceFull
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
go StartSettingsPoller(ctx, sampler, r, discardLogger())
synctest.Wait()
require.Equal(t, int32(1), r.getCalls.Load(), "the failed poll still counts as one read")
require.Equal(t, beforeForceFull, sampler.state.Load().forceFull,
"sampler state must be unchanged when the read fails")
})
}
func TestStartSettingsPoller_AppliesChangeOnTick(t *testing.T) {
// Locks in the actual 60s ticker behavior. The old tests could only assert the initial synchronous read because waiting a
// real minute per test was untenable. With synctest, advancing time is free.
synctest.Test(t, func(t *testing.T) {
r := &stubReader{}
r.set(Settings{
HighVolumeRatio: 0.4,
StandardRatio: 0.8,
ForceFull: true,
})
sampler := NewRouteTierSampler(NewRegistry())
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
go StartSettingsPoller(ctx, sampler, r, discardLogger())
// Initial synchronous poll completes.
synctest.Wait()
require.Equal(t, int32(1), r.getCalls.Load())
require.True(t, sampler.state.Load().forceFull)
// Flip the stub to a new value. Advance past the next ticker fire; the synthetic clock advances while everything is
// blocked, the ticker fires, the poller re-polls and applies the new state. Wait then ensures the poller has
// re-blocked before we assert.
r.set(Settings{
HighVolumeRatio: 0.001,
StandardRatio: 0.02,
ForceFull: false,
})
time.Sleep(settingsPollInterval + time.Nanosecond)
synctest.Wait()
require.Equal(t, int32(2), r.getCalls.Load(), "second poll must have fired after one ticker interval")
require.False(t, sampler.state.Load().forceFull, "ticker poll must apply the new state")
// One more tick with no underlying change should still call Get but should be a no-op for Apply. We verify by the
// invariant that the state is unchanged from the previous assertion.
time.Sleep(settingsPollInterval + time.Nanosecond)
synctest.Wait()
require.Equal(t, int32(3), r.getCalls.Load())
require.False(t, sampler.state.Load().forceFull)
})
}
+8 -1
View File
@@ -11,6 +11,7 @@ import (
"github.com/fleetdm/fleet/v4/server/config"
"github.com/fleetdm/fleet/v4/server/contexts/logging"
"github.com/fleetdm/fleet/v4/server/contexts/token"
"github.com/fleetdm/fleet/v4/server/contexts/viewer"
"github.com/fleetdm/fleet/v4/server/errorstore"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/service/middleware/auth"
@@ -43,7 +44,9 @@ func (m *debugAuthenticationMiddleware) Middleware(next http.Handler) http.Handl
return
}
next.ServeHTTP(w, r)
// Attach the authenticated viewer to the request context so downstream debug handlers can record who triggered an
// action (e.g. updating trace sampler settings).
next.ServeHTTP(w, r.WithContext(viewer.NewContext(r.Context(), *v)))
})
}
@@ -92,6 +95,10 @@ func MakeDebugHandler(svc fleet.Service, config config.FleetConfig, logger *slog
r.HandleFunc("/debug/db/locks", jsonHandler(logger, func(ctx context.Context) (interface{}, error) { return ds.DBLocks(ctx) }))
r.HandleFunc("/debug/db/innodb-status", jsonHandler(logger, func(ctx context.Context) (interface{}, error) { return ds.InnoDBStatus(ctx) }))
r.HandleFunc("/debug/db/process-list", jsonHandler(logger, func(ctx context.Context) (interface{}, error) { return ds.ProcessList(ctx) }))
r.HandleFunc("/debug/trace_sampler", jsonHandler(logger, func(ctx context.Context) (any, error) {
return ds.GetTraceSamplerSettings(ctx)
})).Methods(http.MethodGet)
r.HandleFunc("/debug/trace_sampler", patchTraceSamplerHandler(logger, ds)).Methods(http.MethodPatch)
mw := &debugAuthenticationMiddleware{
service: svc,
+113
View File
@@ -0,0 +1,113 @@
package service
import (
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"time"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
"github.com/fleetdm/fleet/v4/server/contexts/viewer"
"github.com/fleetdm/fleet/v4/server/fleet"
)
// traceSamplerPatchRequest is the PATCH payload. Fields are pointers so we can distinguish "unset" from a zero value. PATCH
// semantics mean only the provided fields are applied.
type traceSamplerPatchRequest struct {
HighVolumeRatio *float64 `json:"high_volume_ratio,omitempty"`
StandardRatio *float64 `json:"standard_ratio,omitempty"`
ForceFull *bool `json:"force_full,omitempty"`
}
// patchTraceSamplerHandler returns the PATCH /debug/trace_sampler handler. The GET path is wired separately in
// MakeDebugHandler via the existing jsonHandler helper, matching the convention used by /debug/migrations and /debug/db/*.
//
// PATCH is necessarily bespoke because no other /debug/ endpoint takes a request body. It validates ratios in [0, 1] and
// persists the change. The replica's in memory sampler picks up the change on the next poller tick (default 60s).
func patchTraceSamplerHandler(logger *slog.Logger, ds fleet.Datastore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// The /debug auth middleware always installs the viewer in context. If it is missing here, the middleware was bypassed
// and we should refuse to record a change rather than silently log user_id=0. That value is indistinguishable from a
// real user id of 0 and weakens the audit trail.
v, ok := viewer.FromContext(r.Context())
if !ok {
handleServerError(w, r, logger, "debug trace_sampler PATCH refused: viewer missing from context", "viewer required",
errors.New("viewer missing from context"))
return
}
var req traceSamplerPatchRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, fmt.Sprintf("invalid JSON body: %v", err), http.StatusBadRequest)
return
}
if err := validateTraceSamplerPatch(req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
current, err := ds.GetTraceSamplerSettings(r.Context())
if err != nil {
handleServerError(w, r, logger, "debug trace_sampler PATCH read-modify failed", "internal error", err)
return
}
if req.HighVolumeRatio != nil {
current.HighVolumeRatio = *req.HighVolumeRatio
}
if req.StandardRatio != nil {
current.StandardRatio = *req.StandardRatio
}
if req.ForceFull != nil {
current.ForceFull = *req.ForceFull
}
if err := ds.SetTraceSamplerSettings(r.Context(), current); err != nil {
handleServerError(w, r, logger, "debug trace_sampler PATCH write failed", "internal error", err)
return
}
logger.InfoContext(r.Context(), "trace sampler settings updated",
"high_volume_ratio", current.HighVolumeRatio,
"standard_ratio", current.StandardRatio,
"force_full", current.ForceFull,
"updated_by_user_id", v.UserID(),
)
// Return the updated row so callers can confirm what was applied. Drop UpdatedAt: the row was read before the write,
// so current.UpdatedAt is the pre-write timestamp (stale and confusing). omitzero on the struct tag skips the field
// when zero. Operators who want the post-write timestamp can do a follow-up GET.
current.UpdatedAt = time.Time{}
b, err := json.MarshalIndent(current, "", " ")
if err != nil {
handleServerError(w, r, logger, "debug trace_sampler PATCH encode response failed", "encoding response", err)
return
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_, _ = w.Write(b)
}
}
// handleServerError centralizes the internal-error response used throughout the trace sampler PATCH handler: it logs logMsg
// with err, records err on the context for the error-handling middleware, and writes clientMsg to the client as a 500.
func handleServerError(w http.ResponseWriter, r *http.Request, logger *slog.Logger, logMsg, clientMsg string, err error) {
logger.ErrorContext(r.Context(), logMsg, "err", err)
ctxerr.Handle(r.Context(), err)
http.Error(w, clientMsg, http.StatusInternalServerError)
}
func validateTraceSamplerPatch(req traceSamplerPatchRequest) error {
if req.HighVolumeRatio == nil && req.StandardRatio == nil && req.ForceFull == nil {
return errors.New("request body must include at least one of high_volume_ratio, standard_ratio, force_full")
}
if req.HighVolumeRatio != nil && (*req.HighVolumeRatio < 0 || *req.HighVolumeRatio > 1) {
return fmt.Errorf("high_volume_ratio must be in [0, 1], got %v", *req.HighVolumeRatio)
}
if req.StandardRatio != nil && (*req.StandardRatio < 0 || *req.StandardRatio > 1) {
return fmt.Errorf("standard_ratio must be in [0, 1], got %v", *req.StandardRatio)
}
return nil
}
+234
View File
@@ -0,0 +1,234 @@
package service
import (
"context"
"encoding/json"
"errors"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/fleetdm/fleet/v4/server/fleet"
mockds "github.com/fleetdm/fleet/v4/server/mock"
"github.com/fleetdm/fleet/v4/server/platform/tracing"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)
func discardLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, nil))
}
// adminAuthedRequest builds a request and primes the mockService so the debug auth middleware lets it through as a global
// admin.
func adminAuthedRequest(t *testing.T, method, target string, body string) (*mockService, *http.Request) {
t.Helper()
svc := &mockService{}
svc.On("GetSessionByKey", mock.Anything, "fake_session_key").
Return(&fleet.Session{UserID: 42, ID: 1}, nil)
svc.On("UserUnauthorized", mock.Anything, uint(42)).
Return(&fleet.User{ID: 42, GlobalRole: new(fleet.RoleAdmin)}, nil)
var reqBody io.Reader
if body != "" {
reqBody = strings.NewReader(body)
}
req := httptest.NewRequest(method, target, reqBody)
req.Header.Add("Authorization", "BEARER fake_session_key")
return svc, req
}
func TestTraceSamplerHandler_GET(t *testing.T) {
svc, req := adminAuthedRequest(t, http.MethodGet, "https://fleetdm.com/debug/trace_sampler", "")
ds := new(mockds.Store)
ds.GetTraceSamplerSettingsFunc = func(_ context.Context) (*tracing.Settings, error) {
return &tracing.Settings{
HighVolumeRatio: 0.001,
StandardRatio: 0.02,
ForceFull: false,
}, nil
}
handler := MakeDebugHandler(svc, testConfig, discardLogger(), nil, ds)
res := httptest.NewRecorder()
handler.ServeHTTP(res, req)
require.Equal(t, http.StatusOK, res.Code)
require.True(t, ds.GetTraceSamplerSettingsFuncInvoked)
var got tracing.Settings
require.NoError(t, json.Unmarshal(res.Body.Bytes(), &got))
require.InDelta(t, 0.001, got.HighVolumeRatio, 1e-9)
require.InDelta(t, 0.02, got.StandardRatio, 1e-9)
require.False(t, got.ForceFull)
}
func TestTraceSamplerHandler_PATCH_PersistsChangesAndReturnsRow(t *testing.T) {
svc, req := adminAuthedRequest(t, http.MethodPatch,
"https://fleetdm.com/debug/trace_sampler",
`{"force_full": true}`)
ds := new(mockds.Store)
ds.GetTraceSamplerSettingsFunc = func(_ context.Context) (*tracing.Settings, error) {
return &tracing.Settings{
HighVolumeRatio: 0.001,
StandardRatio: 0.02,
ForceFull: false,
}, nil
}
var saved *tracing.Settings
ds.SetTraceSamplerSettingsFunc = func(_ context.Context, s *tracing.Settings) error {
saved = s
return nil
}
handler := MakeDebugHandler(svc, testConfig, discardLogger(), nil, ds)
res := httptest.NewRecorder()
handler.ServeHTTP(res, req)
require.Equal(t, http.StatusOK, res.Code, "PATCH should return 200, body=%s", res.Body.String())
require.True(t, ds.SetTraceSamplerSettingsFuncInvoked)
require.NotNil(t, saved)
require.True(t, saved.ForceFull, "force_full should now be true")
require.InDelta(t, 0.001, saved.HighVolumeRatio, 1e-9, "other fields should be preserved")
// Verify the response body matches what was saved. If we forgot to write the response, the test would still see 200 from
// httptest's default but the body would be empty.
var returned tracing.Settings
require.NoError(t, json.Unmarshal(res.Body.Bytes(), &returned))
require.True(t, returned.ForceFull)
require.InDelta(t, saved.HighVolumeRatio, returned.HighVolumeRatio, 1e-9)
require.InDelta(t, saved.StandardRatio, returned.StandardRatio, 1e-9)
// PATCH response must NOT include updated_at. The handler reads the row before the write, so the pre-write timestamp
// would be stale. Operators do a follow-up GET to see the post-write value.
require.NotContains(t, res.Body.String(), "updated_at",
"PATCH response must drop updated_at to avoid returning a stale timestamp")
}
func TestTraceSamplerHandler_PATCH_PartialUpdatePreservesOtherFields(t *testing.T) {
// Locks in the docstring claim that "PATCH semantics mean only the provided fields are applied." Sending only
// high_volume_ratio must leave standard_ratio and force_full at their prior values.
svc, req := adminAuthedRequest(t, http.MethodPatch,
"https://fleetdm.com/debug/trace_sampler",
`{"high_volume_ratio": 0.5}`)
ds := new(mockds.Store)
ds.GetTraceSamplerSettingsFunc = func(_ context.Context) (*tracing.Settings, error) {
return &tracing.Settings{
HighVolumeRatio: 0.001,
StandardRatio: 0.07,
ForceFull: true,
}, nil
}
var saved *tracing.Settings
ds.SetTraceSamplerSettingsFunc = func(_ context.Context, s *tracing.Settings) error {
saved = s
return nil
}
handler := MakeDebugHandler(svc, testConfig, discardLogger(), nil, ds)
res := httptest.NewRecorder()
handler.ServeHTTP(res, req)
require.Equal(t, http.StatusOK, res.Code, "body=%s", res.Body.String())
require.NotNil(t, saved)
require.InDelta(t, 0.5, saved.HighVolumeRatio, 1e-9, "high_volume_ratio should be applied")
require.InDelta(t, 0.07, saved.StandardRatio, 1e-9, "standard_ratio should be preserved from the prior row")
require.True(t, saved.ForceFull, "force_full should be preserved from the prior row")
}
func TestTraceSamplerHandler_PATCH_ReadFailureReturns500(t *testing.T) {
svc, req := adminAuthedRequest(t, http.MethodPatch,
"https://fleetdm.com/debug/trace_sampler",
`{"force_full": true}`)
ds := new(mockds.Store)
ds.GetTraceSamplerSettingsFunc = func(_ context.Context) (*tracing.Settings, error) {
return nil, errors.New("db unavailable")
}
handler := MakeDebugHandler(svc, testConfig, discardLogger(), nil, ds)
res := httptest.NewRecorder()
handler.ServeHTTP(res, req)
require.Equal(t, http.StatusInternalServerError, res.Code)
require.False(t, ds.SetTraceSamplerSettingsFuncInvoked, "should not attempt to write when read fails")
}
func TestTraceSamplerHandler_PATCH_WriteFailureReturns500(t *testing.T) {
svc, req := adminAuthedRequest(t, http.MethodPatch,
"https://fleetdm.com/debug/trace_sampler",
`{"force_full": true}`)
ds := new(mockds.Store)
ds.GetTraceSamplerSettingsFunc = func(_ context.Context) (*tracing.Settings, error) {
return &tracing.Settings{HighVolumeRatio: 0.001, StandardRatio: 0.02}, nil
}
ds.SetTraceSamplerSettingsFunc = func(_ context.Context, _ *tracing.Settings) error {
return errors.New("constraint violation")
}
handler := MakeDebugHandler(svc, testConfig, discardLogger(), nil, ds)
res := httptest.NewRecorder()
handler.ServeHTTP(res, req)
require.Equal(t, http.StatusInternalServerError, res.Code)
require.True(t, ds.SetTraceSamplerSettingsFuncInvoked)
}
func TestTraceSamplerHandler_PATCH_RejectsBadJSON(t *testing.T) {
svc, req := adminAuthedRequest(t, http.MethodPatch,
"https://fleetdm.com/debug/trace_sampler",
`{"force_full":`) // malformed
ds := new(mockds.Store)
handler := MakeDebugHandler(svc, testConfig, discardLogger(), nil, ds)
res := httptest.NewRecorder()
handler.ServeHTTP(res, req)
require.Equal(t, http.StatusBadRequest, res.Code)
require.False(t, ds.SetTraceSamplerSettingsFuncInvoked)
}
func TestTraceSamplerHandler_PATCH_RejectsOutOfRangeRatio(t *testing.T) {
cases := []struct {
name string
body string
}{
{"high above 1", `{"high_volume_ratio": 1.5}`},
{"high below 0", `{"high_volume_ratio": -0.1}`},
{"standard above 1", `{"standard_ratio": 2.0}`},
{"standard below 0", `{"standard_ratio": -1.0}`},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
svc, req := adminAuthedRequest(t, http.MethodPatch,
"https://fleetdm.com/debug/trace_sampler", c.body)
ds := new(mockds.Store)
handler := MakeDebugHandler(svc, testConfig, discardLogger(), nil, ds)
res := httptest.NewRecorder()
handler.ServeHTTP(res, req)
require.Equal(t, http.StatusBadRequest, res.Code)
require.Contains(t, res.Body.String(), "must be in [0, 1]")
require.False(t, ds.SetTraceSamplerSettingsFuncInvoked)
})
}
}
func TestTraceSamplerHandler_PATCH_RequiresAtLeastOneField(t *testing.T) {
svc, req := adminAuthedRequest(t, http.MethodPatch,
"https://fleetdm.com/debug/trace_sampler", `{}`)
ds := new(mockds.Store)
handler := MakeDebugHandler(svc, testConfig, discardLogger(), nil, ds)
res := httptest.NewRecorder()
handler.ServeHTTP(res, req)
require.Equal(t, http.StatusBadRequest, res.Code)
require.Contains(t, res.Body.String(), "at least one")
}
+62
View File
@@ -0,0 +1,62 @@
package service
import (
"net/http"
"github.com/fleetdm/fleet/v4/server/platform/tracing"
)
// RegisterTracingTiers populates the trace sampling registry with the tier classifications for routes owned by this package
// (the legacy flat server/service handler).
//
// Bounded contexts that have moved out of this package (e.g. server/activity/) own their own registrations. They
// should expose a RegisterTracingTiers function the serve command calls during startup. The platform/tracing package itself
// stays free of route knowledge.
//
// Paths use "_version_" as the placeholder for the gorilla/mux fleetversion segment. The sampler normalizes incoming span
// names back to that form before lookup. Alternate path forms (e.g. /api/v1/osquery/...) are registered separately.
//
// Unregistered routes (including all cron jobs, enroll, SCEP, MDM checkin, command ack/result, GitOps batch, etc.) fall to
// TierAlways by design.
func RegisterTracingTiers(registry *tracing.Registry) {
// Hot agent endpoints. These dominate request volume at scale (tens of thousands of spans per second on a 100k-host
// fleet) without being individually interesting. Sampled at the configured high volume ratio (default 0.1%).
registry.Register(http.MethodPost, "/api/osquery/config", tracing.TierHighVolume)
registry.Register(http.MethodPost, "/api/v1/osquery/config", tracing.TierHighVolume)
registry.Register(http.MethodPost, "/api/osquery/distributed/read", tracing.TierHighVolume)
registry.Register(http.MethodPost, "/api/v1/osquery/distributed/read", tracing.TierHighVolume)
registry.Register(http.MethodPost, "/api/osquery/distributed/write", tracing.TierHighVolume)
registry.Register(http.MethodPost, "/api/v1/osquery/distributed/write", tracing.TierHighVolume)
registry.Register(http.MethodPost, "/api/osquery/log", tracing.TierHighVolume)
registry.Register(http.MethodPost, "/api/v1/osquery/log", tracing.TierHighVolume)
registry.Register(http.MethodPost, "/api/fleet/orbit/config", tracing.TierHighVolume)
registry.Register(http.MethodPost, "/api/fleet/orbit/device_token", tracing.TierHighVolume)
registry.Register(http.MethodHead, "/api/fleet/orbit/ping", tracing.TierHighVolume)
registry.Register(http.MethodHead, "/api/fleet/device/ping", tracing.TierHighVolume)
registry.Register(http.MethodHead, "/api/_version_/fleet/device/{token}/ping", tracing.TierHighVolume)
registry.Register(http.MethodGet, "/api/_version_/fleet/device/{token}/desktop", tracing.TierHighVolume)
// Admin / read endpoints. Moderate volume, moderate diagnostic value. Sampled at the configured standard ratio (default
// 2%). Starting set covers the highest traffic admin reads plus the per page load endpoints (config, me, version, etc.)
// that the UI hits on every navigation. Expand as we observe traffic patterns in dogfood and the customer pilot.
registry.Register(http.MethodGet, "/api/_version_/fleet/config", tracing.TierStandard)
registry.Register(http.MethodGet, "/api/_version_/fleet/fleets", tracing.TierStandard)
registry.Register(http.MethodGet, "/api/_version_/fleet/host_summary", tracing.TierStandard)
registry.Register(http.MethodGet, "/api/_version_/fleet/hosts", tracing.TierStandard)
registry.Register(http.MethodGet, "/api/_version_/fleet/hosts/{id}", tracing.TierStandard)
registry.Register(http.MethodGet, "/api/_version_/fleet/hosts/count", tracing.TierStandard)
registry.Register(http.MethodGet, "/api/_version_/fleet/hosts/identifier/{identifier}", tracing.TierStandard)
registry.Register(http.MethodGet, "/api/_version_/fleet/hosts/summary/mdm", tracing.TierStandard)
registry.Register(http.MethodGet, "/api/_version_/fleet/labels", tracing.TierStandard)
registry.Register(http.MethodGet, "/api/_version_/fleet/me", tracing.TierStandard)
registry.Register(http.MethodGet, "/api/_version_/fleet/policies", tracing.TierStandard)
registry.Register(http.MethodGet, "/api/_version_/fleet/reports", tracing.TierStandard)
registry.Register(http.MethodGet, "/api/_version_/fleet/software", tracing.TierStandard)
registry.Register(http.MethodGet, "/api/_version_/fleet/software/titles", tracing.TierStandard)
registry.Register(http.MethodGet, "/api/_version_/fleet/software/versions", tracing.TierStandard)
registry.Register(http.MethodGet, "/api/_version_/fleet/spec/enroll_secret", tracing.TierStandard)
registry.Register(http.MethodGet, "/api/_version_/fleet/users", tracing.TierStandard)
registry.Register(http.MethodGet, "/api/_version_/fleet/version", tracing.TierStandard)
registry.Register(http.MethodGet, "/api/_version_/fleet/charts/{metric}", tracing.TierStandard)
registry.Register(http.MethodGet, "/api/_version_/fleet/android_enterprise", tracing.TierStandard)
}