From 2320f8001e12b8a3a5eb550f3c32990453c5a868 Mon Sep 17 00:00:00 2001 From: JY Tan Date: Tue, 14 Apr 2026 14:53:26 -0700 Subject: [PATCH 1/2] Commit --- docs/environment-variables.md | 19 ++++++++ docs/initialization.md | 20 ++++++++ docs/nextjs-initialization.md | 6 +++ src/core/TuskDrift.test.ts | 36 +++++++++++++++ src/core/TuskDrift.ts | 46 ++++++++++++++++++- .../AdaptiveSamplingController.test.ts | 37 +++++++++++++++ .../sampling/AdaptiveSamplingController.ts | 8 ++++ src/core/utils/configUtils.ts | 1 + 8 files changed, 172 insertions(+), 1 deletion(-) diff --git a/docs/environment-variables.md b/docs/environment-variables.md index d93b3c5..91b1140 100644 --- a/docs/environment-variables.md +++ b/docs/environment-variables.md @@ -135,6 +135,25 @@ If `recording.sampling.mode: adaptive` is enabled in `.tusk/config.yaml`, this e For more details on sampling rate configuration methods and precedence, see the [Initialization Guide](./initialization.md#3-configure-sampling-rate). +## TUSK_RECORDING_SAMPLING_LOG_TRANSITIONS + +Controls whether adaptive sampling emits transition logs like `Adaptive sampling updated (...)`. + +- **Type:** Boolean (`true`/`false`, `1`/`0`, `yes`/`no`, `on`/`off`) +- **If unset:** Falls back to `recording.sampling.log_transitions` in `.tusk/config.yaml`, then defaults to `true` +- **Precedence:** Overrides `recording.sampling.log_transitions` +- **Scope:** Only affects adaptive sampling transition logs. It does not change recording decisions or the global SDK log level + +**Examples:** + +```bash +# Keep adaptive sampling active but silence transition logs +TUSK_RECORDING_SAMPLING_LOG_TRANSITIONS=false npm start + +# Explicitly re-enable transition logs +TUSK_RECORDING_SAMPLING_LOG_TRANSITIONS=true npm start +``` + ## TUSK_USE_RUST_CORE Control optional Rust-accelerated paths in the SDK. Truthy (`1`, `true`, `yes`, `on`) enables, falsy (`0`, `false`, `no`, `off`) disables. Enabled when unset. diff --git a/docs/initialization.md b/docs/initialization.md index 160c81b..22fe759 100644 --- a/docs/initialization.md +++ b/docs/initialization.md @@ -220,6 +220,20 @@ recording: sampling_rate: 0.1 ``` +#### Adaptive Sampling Logs + +When adaptive mode changes state or multiplier, the SDK logs an `Adaptive sampling updated (...)` line at `info` level. + +- `state`: controller state such as `healthy`, `warm`, `hot`, or `critical_pause` +- `multiplier`: factor applied to `base_rate` +- `effectiveRate`: current root-request recording rate after shedding +- `pressure`: highest normalized pressure signal (`0..1`) driving the update +- `queueFill`: smoothed export-queue usage ratio; values near `1.0` mean the exporter is falling behind +- `eventLoopLagP95Ms`: Node-only p95 event loop lag signal +- `memoryPressureRatio`: current memory usage relative to its detected limit, when available + +Set `recording.sampling.log_transitions: false` in `.tusk/config.yaml`, or set `TUSK_RECORDING_SAMPLING_LOG_TRANSITIONS=false`, if you want to suppress these transition logs without changing the overall SDK log level. Raising `logLevel` to `warn` or higher will also hide them. + #### Additional Recording Configuration Options @@ -250,6 +264,12 @@ recording: + + + + + + diff --git a/docs/nextjs-initialization.md b/docs/nextjs-initialization.md index ab8d4c1..68d0a58 100644 --- a/docs/nextjs-initialization.md +++ b/docs/nextjs-initialization.md @@ -306,6 +306,12 @@ recording: + + + + + + diff --git a/src/core/TuskDrift.test.ts b/src/core/TuskDrift.test.ts index 96d473d..052d877 100644 --- a/src/core/TuskDrift.test.ts +++ b/src/core/TuskDrift.test.ts @@ -8,6 +8,7 @@ type EnvVars = Record; type SamplingConfigResult = { baseRate: number; minRate: number; + logTransitions: boolean; mode: "fixed" | "adaptive"; }; type TestableTuskDrift = { @@ -66,3 +67,38 @@ test("falls back to the legacy alias when TUSK_RECORDING_SAMPLING_RATE is invali t.is(samplingConfig.baseRate, 0.4); }); + +test("uses recording.sampling.log_transitions from config when env var is unset", (t) => { + const drift = createTestDrift(t, { + TUSK_DRIFT_MODE: "DISABLED", + }); + drift.config = { + recording: { + sampling: { + log_transitions: false, + }, + }, + }; + + const samplingConfig = drift.determineSamplingConfig({}); + + t.false(samplingConfig.logTransitions); +}); + +test("prefers TUSK_RECORDING_SAMPLING_LOG_TRANSITIONS over config", (t) => { + const drift = createTestDrift(t, { + TUSK_DRIFT_MODE: "DISABLED", + TUSK_RECORDING_SAMPLING_LOG_TRANSITIONS: "false", + }); + drift.config = { + recording: { + sampling: { + log_transitions: true, + }, + }, + }; + + const samplingConfig = drift.determineSamplingConfig({}); + + t.false(samplingConfig.logTransitions); +}); diff --git a/src/core/TuskDrift.ts b/src/core/TuskDrift.ts index 614e2b4..36212f6 100644 --- a/src/core/TuskDrift.ts +++ b/src/core/TuskDrift.ts @@ -86,6 +86,7 @@ export class TuskDriftCore { private samplingRate = 1; private samplingMode: SamplingMode = "fixed"; private minSamplingRate = 0; + private samplingLogTransitions = true; private adaptiveSamplingController?: AdaptiveSamplingController; private adaptiveSamplingInterval: NodeJS.Timeout | null = null; private eventLoopDelayHistogram: IntervalHistogram | null = null; @@ -203,7 +204,7 @@ export class TuskDriftCore { const exportSpans = this.config.recording?.export_spans || false; logger.info( - `SDK initialized successfully (version=${SDK_VERSION}, mode=${this.mode}, env=${environment}, service=${serviceName}, serviceId=${serviceId}, exportSpans=${exportSpans}, samplingMode=${this.samplingMode}, samplingBaseRate=${this.samplingRate}, samplingMinRate=${this.minSamplingRate}, logLevel=${logger.getLogLevel()}, runtime=node ${process.version}, platform=${process.platform}/${process.arch}).`, + `SDK initialized successfully (version=${SDK_VERSION}, mode=${this.mode}, env=${environment}, service=${serviceName}, serviceId=${serviceId}, exportSpans=${exportSpans}, samplingMode=${this.samplingMode}, samplingBaseRate=${this.samplingRate}, samplingMinRate=${this.minSamplingRate}, samplingLogTransitions=${this.samplingLogTransitions}, logLevel=${logger.getLogLevel()}, runtime=node ${process.version}, platform=${process.platform}/${process.arch}).`, ); } @@ -240,6 +241,7 @@ export class TuskDriftCore { mode: SamplingMode; baseRate: number; minRate: number; + logTransitions: boolean; } { const configSampling = this.config.recording?.sampling; @@ -302,13 +304,52 @@ export class TuskDriftCore { minRate = Math.min(baseRate, minRate); } + let logTransitions = true; + const envLogTransitions = OriginalGlobalUtils.getOriginalProcessEnvVar( + "TUSK_RECORDING_SAMPLING_LOG_TRANSITIONS", + ); + if (envLogTransitions !== undefined) { + const parsed = this.parseBooleanSetting( + envLogTransitions, + "TUSK_RECORDING_SAMPLING_LOG_TRANSITIONS env var", + ); + if (parsed !== undefined) { + logger.debug( + `Using adaptive sampling log_transitions from TUSK_RECORDING_SAMPLING_LOG_TRANSITIONS env var: ${parsed}`, + ); + logTransitions = parsed; + } + } else if (configSampling?.log_transitions !== undefined) { + if (typeof configSampling.log_transitions === "boolean") { + logTransitions = configSampling.log_transitions; + } else { + logger.warn( + `Invalid sampling.log_transitions in config.yaml: expected boolean, got ${typeof configSampling.log_transitions}. Ignoring.`, + ); + } + } + return { mode, baseRate, minRate, + logTransitions, }; } + private parseBooleanSetting(value: string, source: string): boolean | undefined { + const normalizedValue = value.trim().toLowerCase(); + if (["1", "true", "yes", "on"].includes(normalizedValue)) { + return true; + } + if (["0", "false", "no", "off"].includes(normalizedValue)) { + return false; + } + + logger.warn(`Invalid ${source}: ${value}. Expected one of true/false/1/0/yes/no/on/off.`); + return undefined; + } + private registerDefaultInstrumentations(): void { const transforms = this.config.transforms ?? this.initParams.transforms; @@ -469,6 +510,8 @@ export class TuskDriftCore { mode: this.samplingMode, baseRate: this.samplingRate, minRate: this.minSamplingRate, + }, { + logTransitions: this.samplingLogTransitions, }); this.effectiveMemoryLimitBytes = this.detectEffectiveMemoryLimitBytes(); @@ -724,6 +767,7 @@ export class TuskDriftCore { this.samplingMode = samplingConfig.mode; this.samplingRate = samplingConfig.baseRate; this.minSamplingRate = samplingConfig.minRate; + this.samplingLogTransitions = samplingConfig.logTransitions; // Need to have observable service id if exporting spans to Tusk backend if (this.config.recording?.export_spans && !this.config.service?.id) { diff --git a/src/core/sampling/AdaptiveSamplingController.test.ts b/src/core/sampling/AdaptiveSamplingController.test.ts index 23e4b7e..60c3080 100644 --- a/src/core/sampling/AdaptiveSamplingController.test.ts +++ b/src/core/sampling/AdaptiveSamplingController.test.ts @@ -1,5 +1,6 @@ import test from "ava"; import { AdaptiveSamplingController } from "./AdaptiveSamplingController"; +import { logger } from "../utils/logger"; test("pre-app-start requests bypass sampling and always record", (t) => { const controller = new AdaptiveSamplingController( @@ -89,3 +90,39 @@ test("adaptive controller reports load_shed when load shedding underflows effect t.is(decision.state, "hot"); t.is(decision.reason, "load_shed"); }); + +test("adaptive controller can suppress transition logs", (t) => { + let now = 0; + const originalInfo = logger.info; + let infoCalls = 0; + + logger.info = (...args: unknown[]) => { + infoCalls += 1; + }; + + t.teardown(() => { + logger.info = originalInfo; + }); + + const controller = new AdaptiveSamplingController( + { + mode: "adaptive", + baseRate: 0.5, + minRate: 0.1, + }, + { + logTransitions: false, + nowFn: () => now, + }, + ); + + controller.update({ + queueFillRatio: 0.9, + }); + now += 1; + controller.update({ + queueFillRatio: 0.1, + }); + + t.is(infoCalls, 0); +}); diff --git a/src/core/sampling/AdaptiveSamplingController.ts b/src/core/sampling/AdaptiveSamplingController.ts index bfe97af..155e448 100644 --- a/src/core/sampling/AdaptiveSamplingController.ts +++ b/src/core/sampling/AdaptiveSamplingController.ts @@ -58,6 +58,7 @@ export class AdaptiveSamplingController { private readonly config: ResolvedSamplingConfig; private readonly randomFn: () => number; private readonly nowFn: () => number; + private readonly logTransitions: boolean; private admissionMultiplier = 1; private state: AdaptiveSamplingState; @@ -77,14 +78,17 @@ export class AdaptiveSamplingController { constructor( config: ResolvedSamplingConfig, { + logTransitions = true, randomFn = Math.random, nowFn = Date.now, }: { + logTransitions?: boolean; randomFn?: () => number; nowFn?: () => number; } = {}, ) { this.config = config; + this.logTransitions = logTransitions; this.randomFn = randomFn; this.nowFn = nowFn; this.state = config.mode === "fixed" ? "fixed" : "healthy"; @@ -271,6 +275,10 @@ export class AdaptiveSamplingController { pressure: number, snapshot: AdaptiveSamplingHealthSnapshot, ): void { + if (!this.logTransitions) { + return; + } + if ( previousState === this.state && Math.abs(previousMultiplier - this.admissionMultiplier) < 0.05 diff --git a/src/core/utils/configUtils.ts b/src/core/utils/configUtils.ts index 4aa9a2f..fa0c55a 100644 --- a/src/core/utils/configUtils.ts +++ b/src/core/utils/configUtils.ts @@ -37,6 +37,7 @@ export interface TuskConfig { mode?: "fixed" | "adaptive"; base_rate?: number; min_rate?: number; + log_transitions?: boolean; }; export_spans?: boolean; enable_env_var_recording?: boolean; From 54b34594fe2d96738180f0945bd4c714aca42d67 Mon Sep 17 00:00:00 2001 From: JY Tan Date: Tue, 14 Apr 2026 15:18:41 -0700 Subject: [PATCH 2/2] Fix --- src/core/TuskDrift.test.ts | 18 ++++++++++++++++++ src/core/TuskDrift.ts | 7 ++++++- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/src/core/TuskDrift.test.ts b/src/core/TuskDrift.test.ts index 052d877..77e9b71 100644 --- a/src/core/TuskDrift.test.ts +++ b/src/core/TuskDrift.test.ts @@ -102,3 +102,21 @@ test("prefers TUSK_RECORDING_SAMPLING_LOG_TRANSITIONS over config", (t) => { t.false(samplingConfig.logTransitions); }); + +test("falls back to config when TUSK_RECORDING_SAMPLING_LOG_TRANSITIONS is invalid", (t) => { + const drift = createTestDrift(t, { + TUSK_DRIFT_MODE: "DISABLED", + TUSK_RECORDING_SAMPLING_LOG_TRANSITIONS: "invalid", + }); + drift.config = { + recording: { + sampling: { + log_transitions: false, + }, + }, + }; + + const samplingConfig = drift.determineSamplingConfig({}); + + t.false(samplingConfig.logTransitions); +}); diff --git a/src/core/TuskDrift.ts b/src/core/TuskDrift.ts index 36212f6..3978304 100644 --- a/src/core/TuskDrift.ts +++ b/src/core/TuskDrift.ts @@ -308,6 +308,7 @@ export class TuskDriftCore { const envLogTransitions = OriginalGlobalUtils.getOriginalProcessEnvVar( "TUSK_RECORDING_SAMPLING_LOG_TRANSITIONS", ); + let parsedEnvLogTransitions: boolean | undefined; if (envLogTransitions !== undefined) { const parsed = this.parseBooleanSetting( envLogTransitions, @@ -317,8 +318,12 @@ export class TuskDriftCore { logger.debug( `Using adaptive sampling log_transitions from TUSK_RECORDING_SAMPLING_LOG_TRANSITIONS env var: ${parsed}`, ); - logTransitions = parsed; + parsedEnvLogTransitions = parsed; } + } + + if (parsedEnvLogTransitions !== undefined) { + logTransitions = parsedEnvLogTransitions; } else if (configSampling?.log_transitions !== undefined) { if (typeof configSampling.log_transitions === "boolean") { logTransitions = configSampling.log_transitions;
0.001 in adaptive mode The minimum steady-state sampling rate for adaptive mode. In critical conditions the SDK can still temporarily pause recording.
sampling.log_transitionsbooleantrueControls whether adaptive sampling emits Adaptive sampling updated (...) transition logs. Can be overridden by TUSK_RECORDING_SAMPLING_LOG_TRANSITIONS.
sampling_rate number0.001 in adaptive mode The minimum steady-state sampling rate for adaptive mode. In critical conditions the SDK can still temporarily pause recording.
sampling.log_transitionsbooleantrueControls whether adaptive sampling emits Adaptive sampling updated (...) transition logs. Can be overridden by TUSK_RECORDING_SAMPLING_LOG_TRANSITIONS.
sampling_rate number